Merge branch 'existing-app-button' of https://github.com/the1daBread/project-nomad into admin-user
This commit is contained in:
commit
0bd00ebfb4
|
|
@ -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