feat(supply-depot): add uninstall for curated apps (#1006)

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.
This commit is contained in:
johno10661 2026-06-15 16:30:45 -04:00 committed by jakeaturner
parent e2beb7ebeb
commit 83576ec33d
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
6 changed files with 129 additions and 1 deletions

View File

@ -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. */

View File

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

View File

@ -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(),

View File

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

View File

@ -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
</StyledModal>
)}
{/* Uninstall curated app modal */}
{modal?.type === 'uninstall' && (
<StyledModal
title={`Uninstall ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
setRemoveImage(false)
setModal(null)
}}
onConfirm={() => handleUninstall(modal.service)}
confirmText="Uninstall"
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={loading}
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
>
<div className="space-y-3 text-sm text-text-muted">
<p className="font-semibold text-desert-red">This will remove the app from this device.</p>
<p>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.</p>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={removeImage}
onChange={(e) => setRemoveImage(e.target.checked)}
className="accent-desert-red h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
</label>
</div>
</StyledModal>
)}
{/* Logs modal */}
{modal?.type === 'logs' && (
<ServiceLogsModal
@ -738,6 +783,7 @@ interface AppCardProps {
onRestart: () => 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({
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
{service.is_custom ? (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
): null}
): (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
)}
</div>
)}
</div>

View File

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