From 839cdb3495a806fe4ff32ba74d4984a5f8fb1a92 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 19:26:07 -0500 Subject: [PATCH] fixed some errors around adding apps. updated existing apps to not be managed by nomad. change so that they are marked as existing and it is displayed in the supply depot. removed unused options from the manage dropdown --- admin/app/controllers/system_controller.ts | 45 ++++++++++++++-- admin/app/jobs/check_service_updates_job.ts | 4 +- admin/app/models/service.ts | 9 ++++ admin/app/services/app_auto_update_service.ts | 10 +++- admin/app/services/docker_service.ts | 12 +++++ admin/app/services/system_service.ts | 2 + ...00004_add_existing_app_flag_to_services.ts | 27 ++++++++++ admin/database/seeders/service_seeder.ts | 1 + admin/inertia/components/CustomAppModal.tsx | 21 ++++++-- admin/inertia/lib/api.ts | 43 +++++++++++++--- admin/inertia/pages/supply-depot.tsx | 51 ++++++++++++------- admin/types/services.ts | 1 + 12 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts 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'