Skip to content

Commit 9bb4ff5

Browse files
committed
feat: force-reinstall option for apps
1 parent 04e169f commit 9bb4ff5

File tree

5 files changed

+210
-0
lines changed

5 files changed

+210
-0
lines changed

admin/app/controllers/system_controller.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ export default class SystemController {
5050
return await this.systemService.checkLatestVersion();
5151
}
5252

53+
async forceReinstallService({ request, response }: HttpContext) {
54+
const payload = await request.validateUsing(installServiceValidator);
55+
const result = await this.dockerService.forceReinstall(payload.service_name);
56+
if (!result) {
57+
response.internalServerError({ error: 'Failed to force reinstall service' });
58+
return;
59+
}
60+
response.send({ success: result.success, message: result.message });
61+
}
62+
5363
async requestSystemUpdate({ response }: HttpContext) {
5464
if (!this.systemUpdateService.isSidecarAvailable()) {
5565
response.status(503).send({

admin/app/services/docker_service.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,140 @@ export class DockerService {
194194
}
195195
}
196196

197+
/**
198+
* Force reinstall a service by stopping, removing, and recreating its container.
199+
* This method will also clear any associated volumes/data.
200+
* Handles edge cases gracefully (e.g., container not running, container not found).
201+
*/
202+
async forceReinstall(serviceName: string): Promise<{ success: boolean; message: string }> {
203+
try {
204+
const service = await Service.query().where('service_name', serviceName).first()
205+
if (!service) {
206+
return {
207+
success: false,
208+
message: `Service ${serviceName} not found`,
209+
}
210+
}
211+
212+
// Check if installation is already in progress
213+
if (this.activeInstallations.has(serviceName)) {
214+
return {
215+
success: false,
216+
message: `Service ${serviceName} installation is already in progress`,
217+
}
218+
}
219+
220+
// Mark as installing to prevent concurrent operations
221+
this.activeInstallations.add(serviceName)
222+
service.installation_status = 'installing'
223+
await service.save()
224+
225+
this._broadcast(
226+
serviceName,
227+
'reinstall-starting',
228+
`Starting force reinstall for ${serviceName}...`
229+
)
230+
231+
// Step 1: Try to stop and remove the container if it exists
232+
try {
233+
const containers = await this.docker.listContainers({ all: true })
234+
const container = containers.find((c) => c.Names.includes(`/${serviceName}`))
235+
236+
if (container) {
237+
const dockerContainer = this.docker.getContainer(container.Id)
238+
239+
// Only try to stop if it's running
240+
if (container.State === 'running') {
241+
this._broadcast(serviceName, 'stopping', `Stopping container...`)
242+
await dockerContainer.stop({ t: 10 }).catch((error) => {
243+
// If already stopped, continue
244+
if (!error.message.includes('already stopped')) {
245+
logger.warn(`Error stopping container: ${error.message}`)
246+
}
247+
})
248+
}
249+
250+
// Step 2: Remove the container
251+
this._broadcast(serviceName, 'removing', `Removing container...`)
252+
await dockerContainer.remove({ force: true }).catch((error) => {
253+
logger.warn(`Error removing container: ${error.message}`)
254+
})
255+
} else {
256+
this._broadcast(
257+
serviceName,
258+
'no-container',
259+
`No existing container found, proceeding with installation...`
260+
)
261+
}
262+
} catch (error) {
263+
logger.warn(`Error during container cleanup: ${error.message}`)
264+
this._broadcast(
265+
serviceName,
266+
'cleanup-warning',
267+
`Warning during cleanup: ${error.message}`
268+
)
269+
}
270+
271+
// Step 3: Clear volumes/data if needed
272+
try {
273+
this._broadcast(serviceName, 'clearing-volumes', `Checking for volumes to clear...`)
274+
const volumes = await this.docker.listVolumes()
275+
const serviceVolumes =
276+
volumes.Volumes?.filter(
277+
(v) => v.Name.includes(serviceName) || v.Labels?.service === serviceName
278+
) || []
279+
280+
for (const vol of serviceVolumes) {
281+
try {
282+
const volume = this.docker.getVolume(vol.Name)
283+
await volume.remove({ force: true })
284+
this._broadcast(serviceName, 'volume-removed', `Removed volume: ${vol.Name}`)
285+
} catch (error) {
286+
logger.warn(`Failed to remove volume ${vol.Name}: ${error.message}`)
287+
}
288+
}
289+
290+
if (serviceVolumes.length === 0) {
291+
this._broadcast(serviceName, 'no-volumes', `No volumes found to clear`)
292+
}
293+
} catch (error) {
294+
logger.warn(`Error during volume cleanup: ${error.message}`)
295+
this._broadcast(
296+
serviceName,
297+
'volume-cleanup-warning',
298+
`Warning during volume cleanup: ${error.message}`
299+
)
300+
}
301+
302+
// Step 4: Mark service as uninstalled
303+
service.installed = false
304+
service.installation_status = 'installing'
305+
await service.save()
306+
307+
// Step 5: Recreate the container
308+
this._broadcast(serviceName, 'recreating', `Recreating container...`)
309+
const containerConfig = this._parseContainerConfig(service.container_config)
310+
311+
// Execute installation asynchronously and handle cleanup
312+
this._createContainer(service, containerConfig).catch(async (error) => {
313+
logger.error(`Reinstallation failed for ${serviceName}: ${error.message}`)
314+
await this._cleanupFailedInstallation(serviceName)
315+
})
316+
317+
return {
318+
success: true,
319+
message: `Service ${serviceName} force reinstall initiated successfully. You can receive updates via server-sent events.`,
320+
}
321+
} catch (error) {
322+
logger.error(`Force reinstall failed for ${serviceName}: ${error.message}`)
323+
await this._cleanupFailedInstallation(serviceName)
324+
return {
325+
success: false,
326+
message: `Failed to force reinstall service ${serviceName}: ${error.message}`,
327+
}
328+
}
329+
}
330+
197331
/**
198332
* Handles the long-running process of creating a Docker container for a service.
199333
* NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first

admin/inertia/lib/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ class API {
121121
})()
122122
}
123123

124+
async forceReinstallService(service_name: string) {
125+
return catchInternal(async () => {
126+
const response = await this.client.post<{ success: boolean; message: string }>(
127+
`/system/services/force-reinstall`,
128+
{ service_name }
129+
)
130+
return response.data
131+
})()
132+
}
133+
124134
async getInternetStatus() {
125135
return catchInternal(async () => {
126136
const response = await this.client.get<boolean>('/system/internet-status')

admin/inertia/pages/settings/apps.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,60 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
106106
}
107107
}
108108

109+
async function handleForceReinstall(record: ServiceSlim) {
110+
try {
111+
setLoading(true)
112+
const response = await api.forceReinstallService(record.service_name)
113+
if (!response) {
114+
throw new Error('An internal error occurred while trying to force reinstall the service.')
115+
}
116+
if (!response.success) {
117+
throw new Error(response.message)
118+
}
119+
120+
closeAllModals()
121+
122+
setTimeout(() => {
123+
setLoading(false)
124+
window.location.reload() // Reload the page to reflect changes
125+
}, 3000) // Add small delay to allow for the action to complete
126+
} catch (error) {
127+
console.error(`Error force reinstalling service ${record.service_name}:`, error)
128+
showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`)
129+
}
130+
}
131+
109132
const AppActions = ({ record }: { record: ServiceSlim }) => {
133+
const ForceReinstallButton = () => (
134+
<StyledButton
135+
icon="ExclamationTriangleIcon"
136+
variant="action"
137+
onClick={() => {
138+
openModal(
139+
<StyledModal
140+
title={'Force Reinstall?'}
141+
onConfirm={() => handleForceReinstall(record)}
142+
onCancel={closeAllModals}
143+
open={true}
144+
confirmText={'Force Reinstall'}
145+
cancelText="Cancel"
146+
>
147+
<p className="text-gray-700">
148+
Are you sure you want to force reinstall {record.service_name}? This will{' '}
149+
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
150+
only do this if the service is malfunctioning and other troubleshooting steps have
151+
failed.
152+
</p>
153+
</StyledModal>,
154+
`${record.service_name}-force-reinstall-modal`
155+
)
156+
}}
157+
disabled={isInstalling}
158+
>
159+
Force Reinstall
160+
</StyledButton>
161+
)
162+
110163
if (!record) return null
111164
if (!record.installed) {
112165
return (
@@ -120,6 +173,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
120173
>
121174
Install
122175
</StyledButton>
176+
<ForceReinstallButton />
123177
</div>
124178
)
125179
}
@@ -189,6 +243,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
189243
Restart
190244
</StyledButton>
191245
)}
246+
<ForceReinstallButton />
192247
</>
193248
)}
194249
</div>

admin/start/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ router
103103
router.get('/services', [SystemController, 'getServices'])
104104
router.post('/services/affect', [SystemController, 'affectService'])
105105
router.post('/services/install', [SystemController, 'installService'])
106+
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
106107
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
107108
router.post('/update', [SystemController, 'requestSystemUpdate'])
108109
router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])

0 commit comments

Comments
 (0)