Merge branch 'existing-app-button' of https://github.com/the1daBread/project-nomad into admin-user

This commit is contained in:
1dabread 2026-08-12 19:26:28 -05:00
commit 0bd00ebfb4
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'