diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index dea0cee..54f89ed 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -488,6 +488,7 @@ export default class SystemController { installation_status: 'idle', is_dependency_service: false, is_custom: true, + is_existing: true, category: payload.category ?? 'custom', display_order: publishedHostPort ? CUSTOM_APP_HOME_DISPLAY_ORDER : null, depends_on: null, @@ -498,7 +499,7 @@ export default class SystemController { return response.send({ success: true, message: `Existing app ${payload.friendly_name} added.`, service_name: payload.container_name }) } - /** Delete a custom app: stop + remove its container, then delete the DB record. */ + /** Delete a custom app, or unregister an existing app without touching its container. */ async deleteCustomApp({ request, response }: HttpContext) { const payload = await request.validateUsing(deleteCustomAppValidator) @@ -510,10 +511,13 @@ export default class SystemController { return response.status(403).send({ error: 'Only custom apps can be deleted.' }) } - await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false) + if (!service.is_existing) { + await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false) + } await service.delete() - return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` }) + const action = service.is_existing ? 'removed from Supply Depot' : 'deleted' + return response.send({ success: true, message: `Custom app ${payload.service_name} ${action}` }) } /** Uninstall a curated catalog app: stop + remove its container (optionally its image) and @@ -601,6 +605,9 @@ export default class SystemController { if (!service.is_custom) { return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' }) } + if (service.is_existing) { + return response.status(403).send({ success: false, message: 'Existing apps are not recreated or updated by NOMAD.' }) + } const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, { forcePull: true, @@ -668,6 +675,37 @@ export default class SystemController { if (service.is_dependency_service) { return response.status(403).send({ success: false, message: 'This service cannot be edited.' }) } + if (service.is_existing) { + service.friendly_name = payload.friendly_name + service.container_image = payload.image + service.category = payload.category ?? service.category ?? 'custom' + if (payload.icon) service.icon = payload.icon + + const inspect = await this.dockerService.inspectContainerByName(payload.service_name) + const publishedHostPort = inspect ? DockerService.getFirstPublishedHostPort(inspect) : null + service.ui_location = publishedHostPort + if ( + (service.ui_location || service.custom_url) && + (service.display_order === null || service.display_order >= 50) + ) { + service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER + } + if ( + !service.ui_location && + !service.custom_url && + service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER + ) { + service.display_order = null + } + service.is_user_modified = true + await service.save() + + return response.send({ + success: true, + message: `Existing app ${payload.service_name} updated.`, + service_name: payload.service_name, + }) + } // Reject duplicate host ports within the request. const hostPorts = (payload.ports ?? []).map((p) => p.host) @@ -868,6 +906,7 @@ export default class SystemController { return { service_name: service.service_name, friendly_name: service.friendly_name, + is_existing: service.is_existing, image: service.container_image, category: service.category ?? 'custom', icon: service.icon ?? 'IconBrandDocker', diff --git a/admin/app/jobs/check_service_updates_job.ts b/admin/app/jobs/check_service_updates_job.ts index cdbf68e..1da18ba 100644 --- a/admin/app/jobs/check_service_updates_job.ts +++ b/admin/app/jobs/check_service_updates_job.ts @@ -26,7 +26,9 @@ export class CheckServiceUpdatesJob { // Determine host architecture const hostArch = await this.getHostArch(dockerService) - const installedServices = await Service.query().where('installed', true) + const installedServices = await Service.query() + .where('installed', true) + .where('is_existing', false) let updatesFound = 0 for (const service of installedServices) { diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 8d192ef..a03abb5 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -75,6 +75,15 @@ export default class Service extends BaseModel { }) declare is_custom: boolean + // True for Docker containers the user registered after creating them outside NOMAD. These + // records are metadata-only: NOMAD may start/stop them, but must not recreate or delete them. + @column({ + serialize(value) { + return Boolean(value) + }, + }) + declare is_existing: boolean + @column({ serialize(value) { return Boolean(value) diff --git a/admin/app/services/app_auto_update_service.ts b/admin/app/services/app_auto_update_service.ts index c6317b6..9e1fa60 100644 --- a/admin/app/services/app_auto_update_service.ts +++ b/admin/app/services/app_auto_update_service.ts @@ -179,7 +179,10 @@ export class AppAutoUpdateService { /** Installed, opted-in apps that are eligible to update right now. */ async getEligibleApps(config: AppAutoUpdateConfig, now: DateTime): Promise { - const apps = await Service.query().where('installed', true).where('auto_update_enabled', true) + const apps = await Service.query() + .where('installed', true) + .where('auto_update_enabled', true) + .where('is_existing', false) const targets: AppUpdateTarget[] = [] for (const service of apps) { const verdict = this.appEligibility(service, config.cooloffHours, now) @@ -340,7 +343,10 @@ export class AppAutoUpdateService { const config = await this.getConfig() const now = DateTime.now() - const apps = await Service.query().where('installed', true).where('auto_update_enabled', true) + const apps = await Service.query() + .where('installed', true) + .where('auto_update_enabled', true) + .where('is_existing', false) const appStatuses: AppAutoUpdateAppStatus[] = apps.map((service) => { const verdict = this.appEligibility(service, config.cooloffHours, now) return { diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 7f59f09..a3809e2 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -362,6 +362,12 @@ export class DockerService { message: `Service ${serviceName} not found`, } } + if (service.is_existing) { + return { + success: false, + message: `Existing app ${serviceName} is registered only and cannot be force reinstalled by NOMAD`, + } + } // Check if installation is already in progress if (this.activeInstallations.has(serviceName)) { @@ -1588,6 +1594,12 @@ export class DockerService { if (!service.installed) { return { success: false, message: `Service ${serviceName} is not installed` } } + if (service.is_existing) { + return { + success: false, + message: `Existing app ${serviceName} is registered only and cannot be updated by NOMAD`, + } + } if (this.activeInstallations.has(serviceName)) { return { success: false, message: `Service ${serviceName} already has an operation in progress` } } diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 92798e7..d9d3b2c 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -342,6 +342,7 @@ export class SystemService { 'available_update_version', 'auto_update_enabled', 'is_custom', + 'is_existing', 'is_user_modified', 'is_deprecated', 'category' @@ -382,6 +383,7 @@ export class SystemService { available_update_version: service.available_update_version, auto_update_enabled: service.auto_update_enabled, is_custom: service.is_custom, + is_existing: service.is_existing, is_user_modified: service.is_user_modified, is_deprecated: service.is_deprecated, category: service.category, diff --git a/admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts b/admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts new file mode 100644 index 0000000..e982bc8 --- /dev/null +++ b/admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts @@ -0,0 +1,27 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.boolean('is_existing').notNullable().defaultTo(false) + }) + + this.defer(async (db) => { + // Earlier Add Existing App records were saved as custom apps with no generated + // container_config. Backfill those so they keep their external-container semantics. + await db + .from(this.tableName) + .where('is_custom', true) + .whereNull('container_config') + .update({ is_existing: true }) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('is_existing') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 77362b1..68586c8 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -13,6 +13,7 @@ type ServiceSeedRecord = Omit< | 'available_update_version' | 'update_checked_at' | 'metadata' + | 'is_existing' | 'is_user_modified' | 'is_deprecated' | 'custom_url' diff --git a/admin/inertia/components/CustomAppModal.tsx b/admin/inertia/components/CustomAppModal.tsx index 77ada8f..4cd5634 100644 --- a/admin/inertia/components/CustomAppModal.tsx +++ b/admin/inertia/components/CustomAppModal.tsx @@ -25,6 +25,7 @@ interface EnvVar { export interface CustomAppInitial { service_name: string friendly_name: string | null + is_existing?: boolean image: string category: string icon: string @@ -84,6 +85,7 @@ export default function CustomAppModal({ initial = null, }: CustomAppModalProps) { const isEdit = mode === 'edit' + const isExisting = isEdit && Boolean(initial?.is_existing) const [friendlyName, setFriendlyName] = useState('') const [image, setImage] = useState('') const [category, setCategory] = useState('custom') @@ -132,6 +134,13 @@ export default function CustomAppModal({ // conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting. useEffect(() => { if (!open) return + if (isExisting) { + setPortConflicts([]) + setResourceWarnings([]) + setBlocked([]) + setCheckingPreflight(false) + return + } const validPorts = ports .map((p) => parseInt(p.host, 10)) .filter((p) => !isNaN(p)) @@ -162,7 +171,7 @@ export default function CustomAppModal({ }, 400) return () => clearTimeout(handle) - }, [open, ports, volumes, image]) + }, [open, isExisting, ports, volumes, image]) function resetForm() { setFriendlyName('') @@ -226,7 +235,7 @@ export default function CustomAppModal({ showError('Name and image are required.') return } - if (blocked.length > 0) { + if (!isExisting && blocked.length > 0) { showError('Resolve the blocked issues before installing.') return } @@ -282,17 +291,19 @@ export default function CustomAppModal({ const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0 const hasBlocks = blocked.length > 0 const canSubmit = - friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall) + friendlyName.trim() && + image.trim() && + (isExisting || (!hasBlocks && (!hasWarnings || forceInstall))) return ( { + try { const response = await this.client.post<{ success: boolean message: string service_name: string }>('/system/services/custom', payload) return response.data - })() + } catch (error) { + if (error instanceof AxiosError && error.response?.data) { + return error.response.data as { + success: false + message: string + warnings?: string[] + portConflicts?: Array<{ port: number; usedBy: string }> + blocked?: string[] + } + } + console.error('Error creating custom app:', error) + return undefined + } } async createExistingApp(payload: { @@ -1181,14 +1193,20 @@ class API { category?: string icon?: string }) { - return catchInternal(async () => { + try { const response = await this.client.post<{ success: boolean message: string service_name: string }>('/system/services/existing', payload) return response.data - })() + } catch (error) { + if (error instanceof AxiosError && error.response?.data) { + return error.response.data as { success: false; message: string } + } + console.error('Error adding existing app:', error) + return undefined + } } async setServiceCustomUrl(service_name: string, custom_url: string | null) { @@ -1264,6 +1282,7 @@ class API { app: { service_name: string friendly_name: string | null + is_existing: boolean image: string category: string icon: string @@ -1291,14 +1310,26 @@ class API { cpus?: number force?: boolean }) { - return catchInternal(async () => { + try { const response = await this.client.put<{ success: boolean message: string service_name: string }>('/system/services/custom', payload) return response.data - })() + } catch (error) { + if (error instanceof AxiosError && error.response?.data) { + return error.response.data as { + success: false + message: string + warnings?: string[] + portConflicts?: Array<{ port: number; usedBy: string }> + blocked?: string[] + } + } + console.error('Error updating custom app:', error) + return undefined + } } } diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index d932448..6c12533 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -703,7 +703,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim {/* Delete custom app modal */} {modal?.type === 'delete' && ( { if (loading) return @@ -711,24 +711,33 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim setModal(null) }} onConfirm={() => handleDelete(modal.service)} - confirmText="Delete" + confirmText={modal.service.is_existing ? 'Remove' : 'Delete'} confirmIcon="IconTrash" confirmVariant="danger" confirmLoading={loading} icon={} >
-

This will permanently remove this custom app.

-

The container will be stopped and removed. Host volume data will remain on disk.

- + {modal.service.is_existing ? ( + <> +

This will remove this existing app from Supply Depot.

+

The Docker container and image will not be stopped, removed, or changed.

+ + ) : ( + <> +

This will permanently remove this custom app.

+

The container will be stopped and removed. Host volume data will remain on disk.

+ + + )}
)} @@ -898,6 +907,7 @@ function AppCard({ const isRunning = service.status === 'running' const isStopped = service.installed && !isRunning const catColor = service.category ? CATEGORY_COLORS[service.category] ?? CATEGORY_COLORS.custom : CATEGORY_COLORS.custom + const customKindLabel = service.is_existing ? 'existing' : 'custom' const isDropdownOpen = openDropdown === service.service_name // Port pill: an ui_location may carry an explicit scheme ("https:8480") — show just the port, // with a lock when it's served over HTTPS, rather than the raw "https:8480" string. @@ -994,7 +1004,7 @@ function AppCard({ )} {service.is_custom ? ( - custom + {customKindLabel} ) : null} {service.is_user_modified && !service.is_custom ? ( @@ -1132,12 +1142,19 @@ function AppCard({ onClick={onUpdateVersion} /> ) : null} - {service.is_custom ? ( + {service.is_custom && !service.is_existing ? ( } label="Update (pull latest)" onClick={onUpdate} /> ) : null} - } label="Force Reinstall" onClick={onReinstall} danger /> + {!service.is_existing ? ( + } label="Force Reinstall" onClick={onReinstall} danger /> + ) : null} {service.is_custom ? ( - } label="Delete" onClick={onDelete} danger /> + } + label={service.is_existing ? 'Remove' : 'Delete'} + onClick={onDelete} + danger + /> ) : ( } label="Uninstall" onClick={onUninstall} danger /> )} diff --git a/admin/types/services.ts b/admin/types/services.ts index 0883c90..6c7dc7b 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -17,6 +17,7 @@ export type ServiceSlim = Pick< | 'available_update_version' | 'auto_update_enabled' | 'is_custom' + | 'is_existing' | 'is_user_modified' | 'is_deprecated' | 'category'