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:
parent
e2beb7ebeb
commit
83576ec33d
|
|
@ -18,6 +18,7 @@ import {
|
||||||
preflightValidator,
|
preflightValidator,
|
||||||
serviceLogsValidator,
|
serviceLogsValidator,
|
||||||
subscribeToReleaseNotesValidator,
|
subscribeToReleaseNotesValidator,
|
||||||
|
uninstallServiceValidator,
|
||||||
updateCustomAppValidator,
|
updateCustomAppValidator,
|
||||||
updateServiceValidator,
|
updateServiceValidator,
|
||||||
setServiceAutoUpdateValidator,
|
setServiceAutoUpdateValidator,
|
||||||
|
|
@ -467,6 +468,37 @@ export default class SystemController {
|
||||||
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
|
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
|
/** 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
|
* metadata change — no container is touched. An empty/invalid value clears the override, after
|
||||||
* which the default host + port link is used again. */
|
* which the default host + port link is used again. */
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** Find a container by its managed service name (`/serviceName`), or null. */
|
||||||
private async _findContainerByName(serviceName: string) {
|
private async _findContainerByName(serviceName: string) {
|
||||||
const containers = await this.docker.listContainers({ all: true })
|
const containers = await this.docker.listContainers({ all: true })
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export const serviceLogsValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
tail: vine.number().min(1).max(2000).optional(),
|
tail: vine.number().min(1).max(2000).optional(),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
async updateCustomAppImage(service_name: string) {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ type Modal =
|
||||||
| { type: 'restart'; service: ServiceSlim }
|
| { type: 'restart'; service: ServiceSlim }
|
||||||
| { type: 'reinstall'; service: ServiceSlim }
|
| { type: 'reinstall'; service: ServiceSlim }
|
||||||
| { type: 'delete'; service: ServiceSlim }
|
| { type: 'delete'; service: ServiceSlim }
|
||||||
|
| { type: 'uninstall'; service: ServiceSlim }
|
||||||
| { type: 'logs'; service: ServiceSlim }
|
| { type: 'logs'; service: ServiceSlim }
|
||||||
| { type: 'stats'; service: ServiceSlim }
|
| { type: 'stats'; service: ServiceSlim }
|
||||||
| { type: 'update'; service: ServiceSlim }
|
| { type: 'update'; service: ServiceSlim }
|
||||||
|
|
@ -233,6 +234,16 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
else setTimeout(() => window.location.reload(), 1000)
|
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) {
|
async function handleUpdate(service: ServiceSlim) {
|
||||||
setOpenDropdown(null)
|
setOpenDropdown(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -450,6 +461,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
onRestart={() => setModal({ type: 'restart', service })}
|
onRestart={() => setModal({ type: 'restart', service })}
|
||||||
onReinstall={() => setModal({ type: 'reinstall', service })}
|
onReinstall={() => setModal({ type: 'reinstall', service })}
|
||||||
onDelete={() => setModal({ type: 'delete', service })}
|
onDelete={() => setModal({ type: 'delete', service })}
|
||||||
|
onUninstall={() => setModal({ type: 'uninstall', service })}
|
||||||
onLogs={() => setModal({ type: 'logs', service })}
|
onLogs={() => setModal({ type: 'logs', service })}
|
||||||
onStats={() => setModal({ type: 'stats', service })}
|
onStats={() => setModal({ type: 'stats', service })}
|
||||||
onEdit={() => handleEdit(service)}
|
onEdit={() => handleEdit(service)}
|
||||||
|
|
@ -484,6 +496,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
onRestart={() => setModal({ type: 'restart', service })}
|
onRestart={() => setModal({ type: 'restart', service })}
|
||||||
onReinstall={() => setModal({ type: 'reinstall', service })}
|
onReinstall={() => setModal({ type: 'reinstall', service })}
|
||||||
onDelete={() => setModal({ type: 'delete', service })}
|
onDelete={() => setModal({ type: 'delete', service })}
|
||||||
|
onUninstall={() => setModal({ type: 'uninstall', service })}
|
||||||
onLogs={() => setModal({ type: 'logs', service })}
|
onLogs={() => setModal({ type: 'logs', service })}
|
||||||
onStats={() => setModal({ type: 'stats', service })}
|
onStats={() => setModal({ type: 'stats', service })}
|
||||||
onEdit={() => handleEdit(service)}
|
onEdit={() => handleEdit(service)}
|
||||||
|
|
@ -659,6 +672,38 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
</StyledModal>
|
</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 */}
|
{/* Logs modal */}
|
||||||
{modal?.type === 'logs' && (
|
{modal?.type === 'logs' && (
|
||||||
<ServiceLogsModal
|
<ServiceLogsModal
|
||||||
|
|
@ -738,6 +783,7 @@ interface AppCardProps {
|
||||||
onRestart: () => void
|
onRestart: () => void
|
||||||
onReinstall: () => void
|
onReinstall: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onUninstall: () => void
|
||||||
onLogs: () => void
|
onLogs: () => void
|
||||||
onStats: () => void
|
onStats: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
|
|
@ -762,6 +808,7 @@ function AppCard({
|
||||||
onRestart,
|
onRestart,
|
||||||
onReinstall,
|
onReinstall,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onUninstall,
|
||||||
onLogs,
|
onLogs,
|
||||||
onStats,
|
onStats,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|
@ -994,7 +1041,9 @@ function AppCard({
|
||||||
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
|
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
|
||||||
{service.is_custom ? (
|
{service.is_custom ? (
|
||||||
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ router
|
||||||
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.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
|
||||||
|
router.post('/services/uninstall', [SystemController, 'uninstallService'])
|
||||||
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
||||||
router.get('/services/preflight', [SystemController, 'preflightCheck'])
|
router.get('/services/preflight', [SystemController, 'preflightCheck'])
|
||||||
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])
|
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue