From 83576ec33d8a16ef758c978abd9b8f6eed14dfd4 Mon Sep 17 00:00:00 2001 From: johno10661 Date: Mon, 15 Jun 2026 16:30:45 -0400 Subject: [PATCH] feat(supply-depot): add uninstall for curated apps (#1006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curated catalog apps could be installed, stopped, and force-reinstalled, but never removed — the only path off a device was manual docker + DB surgery. Custom apps already had delete; this adds the equivalent for curated apps. POST /api/system/services/uninstall stops and removes the app's container (optionally its image, same best-effort semantics as custom app delete) and flips the record back to not-installed so the card returns to the available catalog. Host bind-mount data is deliberately left on disk, so a later reinstall picks the app back up where it left off — unlike force-reinstall, which clears volumes. Guards: custom apps are rejected (use delete), dependency services are rejected, and uninstalling a not-installed app is a 409. UI: installed curated cards get an Uninstall action in the card menu, with a confirm modal that explains data is preserved and offers the same remove-image checkbox as custom app delete. --- admin/app/controllers/system_controller.ts | 32 ++++++++++++++ admin/app/services/docker_service.ts | 28 ++++++++++++ admin/app/validators/system.ts | 8 ++++ admin/inertia/lib/api.ts | 10 +++++ admin/inertia/pages/supply-depot.tsx | 51 +++++++++++++++++++++- admin/start/routes.ts | 1 + 6 files changed, 129 insertions(+), 1 deletion(-) diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 08956c6..22f2cde 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 c836705..b951108 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 8f2d5a6..45a1144 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 f1a8a7e..0b66758 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 44c97f3..889f588 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 5553cbc..b48f15b 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'])