From 9bb4ff5afcea6be244564b246151e07fc85b82c7 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Tue, 20 Jan 2026 06:49:45 +0000 Subject: [PATCH] feat: force-reinstall option for apps --- admin/app/controllers/system_controller.ts | 10 ++ admin/app/services/docker_service.ts | 134 +++++++++++++++++++++ admin/inertia/lib/api.ts | 10 ++ admin/inertia/pages/settings/apps.tsx | 55 +++++++++ admin/start/routes.ts | 1 + 5 files changed, 210 insertions(+) diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index a664658..d681a4d 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -50,6 +50,16 @@ export default class SystemController { return await this.systemService.checkLatestVersion(); } + async forceReinstallService({ request, response }: HttpContext) { + const payload = await request.validateUsing(installServiceValidator); + const result = await this.dockerService.forceReinstall(payload.service_name); + if (!result) { + response.internalServerError({ error: 'Failed to force reinstall service' }); + return; + } + response.send({ success: result.success, message: result.message }); + } + async requestSystemUpdate({ response }: HttpContext) { if (!this.systemUpdateService.isSidecarAvailable()) { response.status(503).send({ diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 57a7945..4659dd8 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -194,6 +194,140 @@ export class DockerService { } } + /** + * Force reinstall a service by stopping, removing, and recreating its container. + * This method will also clear any associated volumes/data. + * Handles edge cases gracefully (e.g., container not running, container not found). + */ + async forceReinstall(serviceName: string): Promise<{ success: boolean; message: string }> { + try { + const service = await Service.query().where('service_name', serviceName).first() + if (!service) { + return { + success: false, + message: `Service ${serviceName} not found`, + } + } + + // Check if installation is already in progress + if (this.activeInstallations.has(serviceName)) { + return { + success: false, + message: `Service ${serviceName} installation is already in progress`, + } + } + + // Mark as installing to prevent concurrent operations + this.activeInstallations.add(serviceName) + service.installation_status = 'installing' + await service.save() + + this._broadcast( + serviceName, + 'reinstall-starting', + `Starting force reinstall for ${serviceName}...` + ) + + // Step 1: Try to stop and remove the container if it exists + try { + const containers = await this.docker.listContainers({ all: true }) + const container = containers.find((c) => c.Names.includes(`/${serviceName}`)) + + if (container) { + const dockerContainer = this.docker.getContainer(container.Id) + + // Only try to stop if it's running + if (container.State === 'running') { + this._broadcast(serviceName, 'stopping', `Stopping container...`) + await dockerContainer.stop({ t: 10 }).catch((error) => { + // If already stopped, continue + if (!error.message.includes('already stopped')) { + logger.warn(`Error stopping container: ${error.message}`) + } + }) + } + + // Step 2: Remove the container + this._broadcast(serviceName, 'removing', `Removing container...`) + await dockerContainer.remove({ force: true }).catch((error) => { + logger.warn(`Error removing container: ${error.message}`) + }) + } else { + this._broadcast( + serviceName, + 'no-container', + `No existing container found, proceeding with installation...` + ) + } + } catch (error) { + logger.warn(`Error during container cleanup: ${error.message}`) + this._broadcast( + serviceName, + 'cleanup-warning', + `Warning during cleanup: ${error.message}` + ) + } + + // Step 3: Clear volumes/data if needed + try { + this._broadcast(serviceName, 'clearing-volumes', `Checking for volumes to clear...`) + const volumes = await this.docker.listVolumes() + const serviceVolumes = + volumes.Volumes?.filter( + (v) => v.Name.includes(serviceName) || v.Labels?.service === serviceName + ) || [] + + for (const vol of serviceVolumes) { + try { + const volume = this.docker.getVolume(vol.Name) + await volume.remove({ force: true }) + this._broadcast(serviceName, 'volume-removed', `Removed volume: ${vol.Name}`) + } catch (error) { + logger.warn(`Failed to remove volume ${vol.Name}: ${error.message}`) + } + } + + if (serviceVolumes.length === 0) { + this._broadcast(serviceName, 'no-volumes', `No volumes found to clear`) + } + } catch (error) { + logger.warn(`Error during volume cleanup: ${error.message}`) + this._broadcast( + serviceName, + 'volume-cleanup-warning', + `Warning during volume cleanup: ${error.message}` + ) + } + + // Step 4: Mark service as uninstalled + service.installed = false + service.installation_status = 'installing' + await service.save() + + // Step 5: Recreate the container + this._broadcast(serviceName, 'recreating', `Recreating container...`) + const containerConfig = this._parseContainerConfig(service.container_config) + + // Execute installation asynchronously and handle cleanup + this._createContainer(service, containerConfig).catch(async (error) => { + logger.error(`Reinstallation failed for ${serviceName}: ${error.message}`) + await this._cleanupFailedInstallation(serviceName) + }) + + return { + success: true, + message: `Service ${serviceName} force reinstall initiated successfully. You can receive updates via server-sent events.`, + } + } catch (error) { + logger.error(`Force reinstall failed for ${serviceName}: ${error.message}`) + await this._cleanupFailedInstallation(serviceName) + return { + success: false, + message: `Failed to force reinstall service ${serviceName}: ${error.message}`, + } + } + } + /** * Handles the long-running process of creating a Docker container for a service. * NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 30bb0fb..ff43a60 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -121,6 +121,16 @@ class API { })() } + async forceReinstallService(service_name: string) { + return catchInternal(async () => { + const response = await this.client.post<{ success: boolean; message: string }>( + `/system/services/force-reinstall`, + { service_name } + ) + return response.data + })() + } + async getInternetStatus() { return catchInternal(async () => { const response = await this.client.get('/system/internet-status') diff --git a/admin/inertia/pages/settings/apps.tsx b/admin/inertia/pages/settings/apps.tsx index 3ffec47..38e1855 100644 --- a/admin/inertia/pages/settings/apps.tsx +++ b/admin/inertia/pages/settings/apps.tsx @@ -106,7 +106,60 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] } } + async function handleForceReinstall(record: ServiceSlim) { + try { + setLoading(true) + const response = await api.forceReinstallService(record.service_name) + if (!response) { + throw new Error('An internal error occurred while trying to force reinstall the service.') + } + if (!response.success) { + throw new Error(response.message) + } + + closeAllModals() + + setTimeout(() => { + setLoading(false) + window.location.reload() // Reload the page to reflect changes + }, 3000) // Add small delay to allow for the action to complete + } catch (error) { + console.error(`Error force reinstalling service ${record.service_name}:`, error) + showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`) + } + } + const AppActions = ({ record }: { record: ServiceSlim }) => { + const ForceReinstallButton = () => ( + { + openModal( + handleForceReinstall(record)} + onCancel={closeAllModals} + open={true} + confirmText={'Force Reinstall'} + cancelText="Cancel" + > +

+ Are you sure you want to force reinstall {record.service_name}? This will{' '} + WIPE ALL DATA for this service and cannot be undone. You should + only do this if the service is malfunctioning and other troubleshooting steps have + failed. +

+
, + `${record.service_name}-force-reinstall-modal` + ) + }} + disabled={isInstalling} + > + Force Reinstall +
+ ) + if (!record) return null if (!record.installed) { return ( @@ -120,6 +173,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] > Install + ) } @@ -189,6 +243,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] Restart )} + )} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index b48da53..98e93b0 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -103,6 +103,7 @@ router router.get('/services', [SystemController, 'getServices']) router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/install', [SystemController, 'installService']) + router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']) router.get('/latest-version', [SystemController, 'checkLatestVersion']) router.post('/update', [SystemController, 'requestSystemUpdate']) router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])