mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-03 15:26:16 +02:00
feat: force-reinstall option for apps
This commit is contained in:
parent
04e169fe7b
commit
9bb4ff5afc
|
|
@ -50,6 +50,16 @@ export default class SystemController {
|
||||||
return await this.systemService.checkLatestVersion();
|
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) {
|
async requestSystemUpdate({ response }: HttpContext) {
|
||||||
if (!this.systemUpdateService.isSidecarAvailable()) {
|
if (!this.systemUpdateService.isSidecarAvailable()) {
|
||||||
response.status(503).send({
|
response.status(503).send({
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* 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
|
* NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
async getInternetStatus() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<boolean>('/system/internet-status')
|
const response = await this.client.get<boolean>('/system/internet-status')
|
||||||
|
|
|
||||||
|
|
@ -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 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) return null
|
||||||
if (!record.installed) {
|
if (!record.installed) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -120,6 +173,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
<ForceReinstallButton />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +243,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
Restart
|
Restart
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
)}
|
)}
|
||||||
|
<ForceReinstallButton />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ router
|
||||||
router.get('/services', [SystemController, 'getServices'])
|
router.get('/services', [SystemController, 'getServices'])
|
||||||
router.post('/services/affect', [SystemController, 'affectService'])
|
router.post('/services/affect', [SystemController, 'affectService'])
|
||||||
router.post('/services/install', [SystemController, 'installService'])
|
router.post('/services/install', [SystemController, 'installService'])
|
||||||
|
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
|
||||||
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
|
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
|
||||||
router.post('/update', [SystemController, 'requestSystemUpdate'])
|
router.post('/update', [SystemController, 'requestSystemUpdate'])
|
||||||
router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])
|
router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user