feat: force-reinstall option for apps

This commit is contained in:
Jake Turner 2026-01-20 06:49:45 +00:00 committed by Jake Turner
parent 04e169fe7b
commit 9bb4ff5afc
5 changed files with 210 additions and 0 deletions

View File

@ -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({

View File

@ -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

View File

@ -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<boolean>('/system/internet-status')

View File

@ -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 = () => (
<StyledButton
icon="ExclamationTriangleIcon"
variant="action"
onClick={() => {
openModal(
<StyledModal
title={'Force Reinstall?'}
onConfirm={() => handleForceReinstall(record)}
onCancel={closeAllModals}
open={true}
confirmText={'Force Reinstall'}
cancelText="Cancel"
>
<p className="text-gray-700">
Are you sure you want to force reinstall {record.service_name}? This will{' '}
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
only do this if the service is malfunctioning and other troubleshooting steps have
failed.
</p>
</StyledModal>,
`${record.service_name}-force-reinstall-modal`
)
}}
disabled={isInstalling}
>
Force Reinstall
</StyledButton>
)
if (!record) return null
if (!record.installed) {
return (
@ -120,6 +173,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
>
Install
</StyledButton>
<ForceReinstallButton />
</div>
)
}
@ -189,6 +243,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
Restart
</StyledButton>
)}
<ForceReinstallButton />
</>
)}
</div>

View File

@ -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'])