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:
parent
79a09717a2
commit
839cdb3495
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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` }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ type ServiceSeedRecord = Omit<
|
|||
| 'available_update_version'
|
||||
| 'update_checked_at'
|
||||
| 'metadata'
|
||||
| 'is_existing'
|
||||
| 'is_user_modified'
|
||||
| 'is_deprecated'
|
||||
| 'custom_url'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type ServiceSlim = Pick<
|
|||
| 'available_update_version'
|
||||
| 'auto_update_enabled'
|
||||
| 'is_custom'
|
||||
| 'is_existing'
|
||||
| 'is_user_modified'
|
||||
| 'is_deprecated'
|
||||
| 'category'
|
||||
|
|
|
|||
Loading…
Reference in New Issue