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

This commit is contained in:
1dabread 2026-08-12 19:26:07 -05:00
parent 79a09717a2
commit 839cdb3495
12 changed files with 192 additions and 34 deletions

View File

@ -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',

View File

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

View File

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

View File

@ -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<AppUpdateTarget[]> {
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 {

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ type ServiceSeedRecord = Omit<
| 'available_update_version'
| 'update_checked_at'
| 'metadata'
| 'is_existing'
| 'is_user_modified'
| 'is_deprecated'
| 'custom_url'

View File

@ -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 (
<StyledModal
title={isEdit ? 'Edit App' : 'Add Custom App'}
title={isExisting ? 'Edit Existing App' : isEdit ? 'Edit App' : 'Add Custom App'}
open={open}
onCancel={handleClose}
cancelText="Cancel"
onConfirm={handleSubmit}
confirmVariant='primary'
confirmText={isEdit ? 'Save & Recreate' : 'Install'}
confirmText={isExisting ? 'Save' : isEdit ? 'Save & Recreate' : 'Install'}
confirmIcon="IconBrandDocker"
confirmLoading={submitting}
confirmDisabled={!canSubmit}

View File

@ -1165,14 +1165,26 @@ class API {
cpus?: number
force?: boolean
}) {
return catchInternal(async () => {
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
}
}
}

View File

@ -703,7 +703,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
{/* Delete custom app modal */}
{modal?.type === 'delete' && (
<StyledModal
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
title={`${modal.service.is_existing ? 'Remove' : 'Delete'} ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
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={<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 permanently remove this custom app.</p>
<p>The container will be stopped and removed. Host volume data will remain on disk.</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>
{modal.service.is_existing ? (
<>
<p className="font-semibold text-desert-red">This will remove this existing app from Supply Depot.</p>
<p>The Docker container and image will not be stopped, removed, or changed.</p>
</>
) : (
<>
<p className="font-semibold text-desert-red">This will permanently remove this custom app.</p>
<p>The container will be stopped and removed. Host volume data will remain on disk.</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>
)}
@ -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 ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-surface-secondary text-text-muted border border-surface-secondary">
custom
{customKindLabel}
</span>
) : 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 ? (
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
) : null}
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
{!service.is_existing ? (
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
) : null}
{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={service.is_existing ? 'Remove' : 'Delete'}
onClick={onDelete}
danger
/>
) : (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
)}

View File

@ -17,6 +17,7 @@ export type ServiceSlim = Pick<
| 'available_update_version'
| 'auto_update_enabled'
| 'is_custom'
| 'is_existing'
| 'is_user_modified'
| 'is_deprecated'
| 'category'