diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 08956c66..22f2cde6 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -18,6 +18,7 @@ import { preflightValidator, serviceLogsValidator, subscribeToReleaseNotesValidator, + uninstallServiceValidator, updateCustomAppValidator, updateServiceValidator, setServiceAutoUpdateValidator, @@ -467,6 +468,37 @@ export default class SystemController { return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` }) } + /** Uninstall a curated catalog app: stop + remove its container (optionally its image) and + * return the card to the available catalog. App data under the storage path stays on disk, + * so a later reinstall picks it back up. Custom apps are removed via deleteCustomApp instead, + * which also drops their DB record. */ + async uninstallService({ request, response }: HttpContext) { + const payload = await request.validateUsing(uninstallServiceValidator) + + const service = await Service.query().where('service_name', payload.service_name).first() + if (!service) { + return response.status(404).send({ error: `Service ${payload.service_name} not found` }) + } + if (service.is_custom) { + return response.status(403).send({ error: 'Custom apps are removed via delete.' }) + } + if (service.is_dependency_service) { + return response.status(403).send({ error: 'Dependency services cannot be uninstalled directly.' }) + } + if (!service.installed) { + return response.status(409).send({ error: `Service ${payload.service_name} is not installed` }) + } + + const result = await this.dockerService.uninstallService( + payload.service_name, + payload.remove_image ?? false + ) + if (!result.success) { + return response.status(500).send({ success: false, message: result.message }) + } + return response.send({ success: true, message: result.message }) + } + /** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a * metadata change — no container is touched. An empty/invalid value clears the override, after * which the default host + port link is used again. */ diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index c836705e..b9511080 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -1867,6 +1867,34 @@ export class DockerService { } } + /** + * Uninstall a curated catalog app: stop and remove its container (and, optionally, its image), + * then mark the record not-installed so the card returns to the available catalog. Host + * bind-mount data is deliberately left on disk — a later reinstall picks it back up. Contrast + * with forceReinstall, which clears volumes and immediately recreates the container. + */ + async uninstallService( + serviceName: string, + removeImage = false + ): Promise<{ success: boolean; message: string }> { + const service = await Service.query().where('service_name', serviceName).first() + if (!service || !service.installed) { + return { success: false, message: `Service ${serviceName} not found or not installed` } + } + + // Container/image removal is shared with custom-app deletion — the lookup is by container + // name, which is the service_name for curated and custom apps alike. + const removal = await this.removeCustomAppContainer(serviceName, removeImage) + if (!removal.success) return removal + + service.installed = false + service.installation_status = 'idle' + await service.save() + this.invalidateServicesStatusCache() + + return { success: true, message: `Service ${serviceName} uninstalled` } + } + /** Find a container by its managed service name (`/serviceName`), or null. */ private async _findContainerByName(serviceName: string) { const containers = await this.docker.listContainers({ all: true }) diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 8f2d5a62..45a11443 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -131,6 +131,14 @@ export const deleteCustomAppValidator = vine.compile( }) ) +export const uninstallServiceValidator = vine.compile( + vine.object({ + service_name: vine.string().trim(), + // When true, also remove the backing Docker image (best-effort). + remove_image: vine.boolean().optional(), + }) +) + export const serviceLogsValidator = vine.compile( vine.object({ tail: vine.number().min(1).max(2000).optional(), diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index f1a8a7e4..0b667585 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1078,6 +1078,16 @@ class API { })() } + async uninstallService(service_name: string, remove_image = false) { + return catchInternal(async () => { + const response = await this.client.post<{ success: boolean; message: string }>( + '/system/services/uninstall', + { service_name, remove_image } + ) + return response.data + })() + } + async updateCustomAppImage(service_name: string) { return catchInternal(async () => { const response = await this.client.post<{ success: boolean; message: string }>( diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index 44c97f33..889f5886 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -82,6 +82,7 @@ type Modal = | { type: 'restart'; service: ServiceSlim } | { type: 'reinstall'; service: ServiceSlim } | { type: 'delete'; service: ServiceSlim } + | { type: 'uninstall'; service: ServiceSlim } | { type: 'logs'; service: ServiceSlim } | { type: 'stats'; service: ServiceSlim } | { type: 'update'; service: ServiceSlim } @@ -233,6 +234,16 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim else setTimeout(() => window.location.reload(), 1000) } + async function handleUninstall(service: ServiceSlim) { + setModal(null) + setLoading(true) + const result = await api.uninstallService(service.service_name, removeImage) + setRemoveImage(false) + setLoading(false) + if (!result?.success) showError(result?.message || 'Failed to uninstall app.') + else setTimeout(() => window.location.reload(), 1000) + } + async function handleUpdate(service: ServiceSlim) { setOpenDropdown(null) setLoading(true) @@ -450,6 +461,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onRestart={() => setModal({ type: 'restart', service })} onReinstall={() => setModal({ type: 'reinstall', service })} onDelete={() => setModal({ type: 'delete', service })} + onUninstall={() => setModal({ type: 'uninstall', service })} onLogs={() => setModal({ type: 'logs', service })} onStats={() => setModal({ type: 'stats', service })} onEdit={() => handleEdit(service)} @@ -484,6 +496,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim onRestart={() => setModal({ type: 'restart', service })} onReinstall={() => setModal({ type: 'reinstall', service })} onDelete={() => setModal({ type: 'delete', service })} + onUninstall={() => setModal({ type: 'uninstall', service })} onLogs={() => setModal({ type: 'logs', service })} onStats={() => setModal({ type: 'stats', service })} onEdit={() => handleEdit(service)} @@ -659,6 +672,38 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim )} + {/* Uninstall curated app modal */} + {modal?.type === 'uninstall' && ( + { + setRemoveImage(false) + setModal(null) + }} + onConfirm={() => handleUninstall(modal.service)} + confirmText="Uninstall" + confirmIcon="IconTrash" + confirmVariant="danger" + confirmLoading={loading} + icon={} + > +
+

This will remove the app from this device.

+

The container will be stopped and removed, and the app returns to the catalog below. App data under the storage folder stays on disk, so reinstalling brings it back as it was.

+ +
+
+ )} + {/* Logs modal */} {modal?.type === 'logs' && ( void onReinstall: () => void onDelete: () => void + onUninstall: () => void onLogs: () => void onStats: () => void onEdit: () => void @@ -762,6 +808,7 @@ function AppCard({ onRestart, onReinstall, onDelete, + onUninstall, onLogs, onStats, onEdit, @@ -994,7 +1041,9 @@ function AppCard({ } label="Force Reinstall" onClick={onReinstall} danger /> {service.is_custom ? ( } label="Delete" onClick={onDelete} danger /> - ): null} + ): ( + } label="Uninstall" onClick={onUninstall} danger /> + )} )} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 5553cbc2..b48f15ba 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -169,6 +169,7 @@ router router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/install', [SystemController, 'installService']) router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']) + router.post('/services/uninstall', [SystemController, 'uninstallService']) router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']) router.get('/services/preflight', [SystemController, 'preflightCheck']) router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])