feat: supply depot
This commit is contained in:
parent
e38dbf8a65
commit
be434d755a
|
|
@ -0,0 +1,13 @@
|
|||
import { SystemService } from '#services/system_service'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
@inject()
|
||||
export default class SupplyDepotController {
|
||||
constructor(private systemService: SystemService) {}
|
||||
|
||||
async index({ inertia }: HttpContext) {
|
||||
const services = await this.systemService.getServices({ installedOnly: false })
|
||||
return inertia.render('supply-depot', { system: { services } })
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,28 @@ import { SystemService } from '#services/system_service'
|
|||
import { SystemUpdateService } from '#services/system_update_service'
|
||||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
|
||||
import {
|
||||
affectServiceValidator,
|
||||
checkLatestVersionValidator,
|
||||
customAppValidator,
|
||||
deleteCustomAppValidator,
|
||||
installServiceValidator,
|
||||
preflightCustomValidator,
|
||||
preflightValidator,
|
||||
serviceLogsValidator,
|
||||
subscribeToReleaseNotesValidator,
|
||||
updateCustomAppValidator,
|
||||
updateServiceValidator,
|
||||
} from '#validators/system'
|
||||
import {
|
||||
DEFAULT_CPUS,
|
||||
DEFAULT_MEMORY_MB,
|
||||
evaluateCustomApp,
|
||||
} from '#services/custom_app_guard'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import Service from '#models/service'
|
||||
|
||||
@inject()
|
||||
export default class SystemController {
|
||||
|
|
@ -180,4 +198,387 @@ export default class SystemController {
|
|||
return 'amd64'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-install preflight check: reports port conflicts and resource warnings for a service.
|
||||
* Results are advisory — the UI shows warnings but allows the user to force-proceed.
|
||||
*/
|
||||
async preflightCheck({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(preflightValidator)
|
||||
|
||||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
|
||||
}
|
||||
|
||||
// Extract host ports from container_config — the MySQL driver may return JSON columns
|
||||
// as an already-parsed object rather than a string, so guard before calling JSON.parse.
|
||||
const rawConfig = service.container_config
|
||||
const config = rawConfig
|
||||
? typeof rawConfig === 'object'
|
||||
? rawConfig
|
||||
: JSON.parse(rawConfig as string)
|
||||
: null
|
||||
const portBindings: Record<string, [{ HostPort: string }]> =
|
||||
config?.HostConfig?.PortBindings ?? {}
|
||||
const hostPorts = Object.values(portBindings)
|
||||
.flat()
|
||||
.map((b) => parseInt(b.HostPort, 10))
|
||||
.filter((p) => !isNaN(p))
|
||||
|
||||
// Parse resource requirements from metadata (same object-guard as container_config)
|
||||
let minMemoryMB = 256
|
||||
let minDiskMB = 512
|
||||
try {
|
||||
const rawMeta = service.metadata
|
||||
const meta = rawMeta
|
||||
? typeof rawMeta === 'object'
|
||||
? rawMeta
|
||||
: JSON.parse(rawMeta as string)
|
||||
: null
|
||||
if (meta?.minMemoryMB) minMemoryMB = meta.minMemoryMB
|
||||
if (meta?.minDiskMB) minDiskMB = meta.minDiskMB
|
||||
} catch {}
|
||||
|
||||
const [{ conflicts: portConflicts }, resourceWarnings] = await Promise.all([
|
||||
this.dockerService.checkPortConflicts(hostPorts),
|
||||
this.systemService.checkResourceWarnings(minMemoryMB, minDiskMB),
|
||||
])
|
||||
|
||||
return response.send({ portConflicts, resourceWarnings })
|
||||
}
|
||||
|
||||
/** Return the next suggested host port for a custom app (8600+ range). */
|
||||
async suggestCustomPort({ response }: HttpContext) {
|
||||
const port = await this.systemService.getNextSuggestedCustomPort()
|
||||
return response.send({ port })
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-less preflight for the custom-app form: given host ports, volumes and an image,
|
||||
* report port conflicts, host resource warnings, overridable guard warnings (risky bind
|
||||
* mounts / untrusted or moving-tag images), and hard blocks (docker socket, system dirs,
|
||||
* malformed image). Lets the form give live feedback before a Service record exists.
|
||||
*/
|
||||
async preflightCustomApp({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(preflightCustomValidator)
|
||||
const [{ conflicts }, resourceWarnings] = await Promise.all([
|
||||
this.dockerService.checkPortConflicts(payload.ports ?? []),
|
||||
this.systemService.checkResourceWarnings(256, 512),
|
||||
])
|
||||
// When editing, the app's own container legitimately holds its ports — don't flag those.
|
||||
const portConflicts = payload.exclude_service
|
||||
? conflicts.filter((c) => c.usedBy !== payload.exclude_service)
|
||||
: conflicts
|
||||
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
|
||||
return response.send({
|
||||
portConflicts,
|
||||
resourceWarnings: [...resourceWarnings, ...guard.warnings],
|
||||
blocked: guard.blocked,
|
||||
})
|
||||
}
|
||||
|
||||
/** Create and immediately begin installing a custom app container. */
|
||||
async createCustomApp({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(customAppValidator)
|
||||
|
||||
// Derive a stable service_name from the friendly name
|
||||
const slug = payload.friendly_name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
const serviceName = `nomad_custom_${slug}`
|
||||
|
||||
const existing = await Service.query().where('service_name', serviceName).first()
|
||||
if (existing) {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
message: `A custom app named "${payload.friendly_name}" already exists. Choose a different name.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Reject duplicate host ports within the request — Docker would otherwise fail at
|
||||
// start time with an opaque "port is already allocated" error.
|
||||
const hostPorts = (payload.ports ?? []).map((p) => p.host)
|
||||
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
|
||||
if (duplicateHostPorts.length) {
|
||||
return response.status(422).send({
|
||||
success: false,
|
||||
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Security guardrails: hard-block dangerous bind mounts / malformed images regardless of
|
||||
// force; surface overridable warnings (risky paths, untrusted/moving-tag images) unless forced.
|
||||
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
|
||||
if (guard.blocked.length) {
|
||||
return response.status(422).send({
|
||||
success: false,
|
||||
message: guard.blocked.join(' '),
|
||||
blocked: guard.blocked,
|
||||
})
|
||||
}
|
||||
if (!payload.force && guard.warnings.length) {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
message: guard.warnings.join(' '),
|
||||
warnings: guard.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// Advisory preflight: surface port conflicts before creating the record so a failed
|
||||
// install doesn't leave a phantom card. The user can re-submit with force=true to override.
|
||||
if (!payload.force && hostPorts.length) {
|
||||
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
|
||||
if (conflicts.length) {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
message: `Port conflict: ${conflicts
|
||||
.map((c) => `${c.port} (in use by ${c.usedBy})`)
|
||||
.join(', ')}.`,
|
||||
portConflicts: conflicts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload)
|
||||
|
||||
await Service.create({
|
||||
service_name: serviceName,
|
||||
friendly_name: payload.friendly_name,
|
||||
container_image: payload.image,
|
||||
container_config: JSON.stringify(containerConfig),
|
||||
ui_location: uiLocation,
|
||||
icon: payload.icon || 'IconBrandDocker',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: true,
|
||||
category: payload.category ?? 'custom',
|
||||
depends_on: null,
|
||||
})
|
||||
|
||||
const result = await this.dockerService.createContainerPreflight(serviceName)
|
||||
if (result.success) {
|
||||
return response.send({ success: true, message: result.message, service_name: serviceName })
|
||||
}
|
||||
return response.status(400).send({ success: false, message: result.message })
|
||||
}
|
||||
|
||||
/** Delete a custom app: stop + remove its container, then delete the DB record. */
|
||||
async deleteCustomApp({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(deleteCustomAppValidator)
|
||||
|
||||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
|
||||
}
|
||||
if (!service.is_custom) {
|
||||
return response.status(403).send({ error: 'Only custom apps can be deleted.' })
|
||||
}
|
||||
|
||||
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` })
|
||||
}
|
||||
|
||||
/** Re-pull a custom app's image and recreate its container in place (preserving volumes). */
|
||||
async updateCustomApp_pullLatest({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(installServiceValidator)
|
||||
|
||||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
|
||||
}
|
||||
if (!service.is_custom) {
|
||||
return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' })
|
||||
}
|
||||
|
||||
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, {
|
||||
forcePull: true,
|
||||
})
|
||||
if (result.success) {
|
||||
return response.send({ success: true, message: result.message })
|
||||
}
|
||||
return response.status(400).send({ success: false, message: result.message })
|
||||
}
|
||||
|
||||
/** Return the last N lines of a service container's logs. */
|
||||
async getServiceLogs({ params, request, response }: HttpContext) {
|
||||
// Scope to managed services only — otherwise any sibling container's logs (admin app,
|
||||
// database) would be readable by name on this unauthenticated API surface.
|
||||
const service = await Service.query().where('service_name', params.name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
|
||||
}
|
||||
const { tail } = await request.validateUsing(serviceLogsValidator)
|
||||
const result = await this.dockerService.getContainerLogs(params.name, tail ?? 200)
|
||||
if (!result.success) {
|
||||
return response.status(404).send({ success: false, message: result.message })
|
||||
}
|
||||
return response.send({ success: true, logs: result.logs })
|
||||
}
|
||||
|
||||
/** Return a one-shot CPU/memory usage snapshot for a running service container. */
|
||||
async getServiceStats({ params, response }: HttpContext) {
|
||||
// Scope to managed services only (see getServiceLogs).
|
||||
const service = await Service.query().where('service_name', params.name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
|
||||
}
|
||||
const result = await this.dockerService.getContainerStats(params.name)
|
||||
if (!result.success) {
|
||||
return response.status(404).send({ success: false, message: result.message })
|
||||
}
|
||||
return response.send({ success: true, running: result.running ?? false, stats: result.stats ?? null })
|
||||
}
|
||||
|
||||
/** Return a custom app's current configuration in the editable form-shape. */
|
||||
async getCustomApp({ params, response }: HttpContext) {
|
||||
const service = await Service.query().where('service_name', params.name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${params.name} not found` })
|
||||
}
|
||||
if (!service.is_custom) {
|
||||
return response.status(403).send({ error: 'Only custom apps can be edited.' })
|
||||
}
|
||||
return response.send({ success: true, app: this.parseCustomContainerConfig(service) })
|
||||
}
|
||||
|
||||
/** Reconfigure a custom app: validate + guard, persist the new config, then recreate the container. */
|
||||
async updateCustomApp({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(updateCustomAppValidator)
|
||||
|
||||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
|
||||
}
|
||||
if (!service.is_custom) {
|
||||
return response.status(403).send({ success: false, message: 'Only custom apps can be edited.' })
|
||||
}
|
||||
|
||||
// Reject duplicate host ports within the request.
|
||||
const hostPorts = (payload.ports ?? []).map((p) => p.host)
|
||||
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
|
||||
if (duplicateHostPorts.length) {
|
||||
return response.status(422).send({
|
||||
success: false,
|
||||
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Security guardrails (same posture as create).
|
||||
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
|
||||
if (guard.blocked.length) {
|
||||
return response.status(422).send({ success: false, message: guard.blocked.join(' '), blocked: guard.blocked })
|
||||
}
|
||||
if (!payload.force && guard.warnings.length) {
|
||||
return response.status(409).send({ success: false, message: guard.warnings.join(' '), warnings: guard.warnings })
|
||||
}
|
||||
|
||||
// Port conflicts — but ignore ports already held by this app's own container.
|
||||
if (!payload.force && hostPorts.length) {
|
||||
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
|
||||
const external = conflicts.filter((c) => c.usedBy !== payload.service_name)
|
||||
if (external.length) {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
message: `Port conflict: ${external
|
||||
.map((c) => `${c.port} (in use by ${c.usedBy})`)
|
||||
.join(', ')}.`,
|
||||
portConflicts: external,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload)
|
||||
service.friendly_name = payload.friendly_name
|
||||
service.container_image = payload.image
|
||||
service.container_config = JSON.stringify(containerConfig)
|
||||
service.ui_location = uiLocation
|
||||
service.category = payload.category ?? service.category ?? 'custom'
|
||||
if (payload.icon) service.icon = payload.icon
|
||||
await service.save()
|
||||
|
||||
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name)
|
||||
if (result.success) {
|
||||
return response.send({ success: true, message: result.message, service_name: payload.service_name })
|
||||
}
|
||||
return response.status(400).send({ success: false, message: result.message })
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Docker container config (HostConfig + ExposedPorts + Env) from custom-app form input,
|
||||
* applying default resource caps. Shared by create and update so both stay in lockstep.
|
||||
*/
|
||||
private buildCustomContainerConfig(payload: {
|
||||
ports?: { container: number; host: number }[]
|
||||
volumes?: { host_path: string; container_path: string }[]
|
||||
env?: string[]
|
||||
memory_mb?: number
|
||||
cpus?: number
|
||||
}): { containerConfig: Record<string, any>; uiLocation: string | null } {
|
||||
const portBindings: Record<string, [{ HostPort: string }]> = {}
|
||||
const exposedPorts: Record<string, {}> = {}
|
||||
for (const { container, host } of payload.ports ?? []) {
|
||||
portBindings[`${container}/tcp`] = [{ HostPort: String(host) }]
|
||||
exposedPorts[`${container}/tcp`] = {}
|
||||
}
|
||||
|
||||
const binds = (payload.volumes ?? []).map(
|
||||
({ host_path, container_path }) => `${host_path}:${container_path}`
|
||||
)
|
||||
|
||||
// Resource caps so a runaway custom container can't starve the host. Memory is bytes;
|
||||
// NanoCpus is CPUs × 1e9. Defaults are generous and user-overridable.
|
||||
const memoryBytes = (payload.memory_mb ?? DEFAULT_MEMORY_MB) * 1024 * 1024
|
||||
const nanoCpus = Math.round((payload.cpus ?? DEFAULT_CPUS) * 1e9)
|
||||
|
||||
const containerConfig: Record<string, any> = {
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: portBindings,
|
||||
Memory: memoryBytes,
|
||||
NanoCpus: nanoCpus,
|
||||
...(binds.length ? { Binds: binds } : {}),
|
||||
},
|
||||
ExposedPorts: exposedPorts,
|
||||
...(payload.env?.length ? { Env: payload.env } : {}),
|
||||
}
|
||||
|
||||
const firstHostPort = payload.ports?.[0]?.host
|
||||
const uiLocation = firstHostPort ? String(firstHostPort) : null
|
||||
return { containerConfig, uiLocation }
|
||||
}
|
||||
|
||||
/** Inverse of buildCustomContainerConfig: turn a stored Service into the editable form-shape. */
|
||||
private parseCustomContainerConfig(service: Service) {
|
||||
const raw = service.container_config
|
||||
const config = raw ? (typeof raw === 'object' ? raw : JSON.parse(raw as string)) : {}
|
||||
const hostConfig = config?.HostConfig ?? {}
|
||||
|
||||
const ports = Object.entries(hostConfig.PortBindings ?? {}).map(([key, val]: [string, any]) => ({
|
||||
container: Number.parseInt(key, 10),
|
||||
host: Number.parseInt(val?.[0]?.HostPort, 10),
|
||||
}))
|
||||
|
||||
const volumes = (hostConfig.Binds ?? []).map((bind: string) => {
|
||||
const idx = bind.indexOf(':')
|
||||
return { host_path: bind.slice(0, idx), container_path: bind.slice(idx + 1) }
|
||||
})
|
||||
|
||||
return {
|
||||
service_name: service.service_name,
|
||||
friendly_name: service.friendly_name,
|
||||
image: service.container_image,
|
||||
category: service.category ?? 'custom',
|
||||
icon: service.icon ?? 'IconBrandDocker',
|
||||
ports,
|
||||
volumes,
|
||||
env: (config?.Env ?? []) as string[],
|
||||
memory_mb: hostConfig.Memory ? Math.round(hostConfig.Memory / (1024 * 1024)) : undefined,
|
||||
cpus: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,16 @@ export default class Service extends BaseModel {
|
|||
@column()
|
||||
declare metadata: string | null
|
||||
|
||||
@column({
|
||||
serialize(value) {
|
||||
return Boolean(value)
|
||||
},
|
||||
})
|
||||
declare is_custom: boolean
|
||||
|
||||
@column()
|
||||
declare category: string | null
|
||||
|
||||
@column()
|
||||
declare source_repo: string | null
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
import { dirname, normalize } from 'node:path'
|
||||
import env from '#start/env'
|
||||
|
||||
/**
|
||||
* Security guardrails for user-defined ("custom app") containers.
|
||||
*
|
||||
* project-nomad runs containers as host siblings via the mounted Docker socket (DooD), so a
|
||||
* misconfigured bind mount or image is a real host-takeover vector. The posture here is
|
||||
* "guardrails with warnings": hard-block the genuinely catastrophic, warn-but-allow the merely
|
||||
* risky so a trusted admin keeps their power without an easy foot-gun.
|
||||
*/
|
||||
|
||||
export interface GuardEvaluation {
|
||||
/** Hard rejections — the install cannot proceed until these are fixed. */
|
||||
blocked: string[]
|
||||
/** Advisory warnings — overridable via the "install anyway" force flag. */
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Absolute host directories that must never be bind-mounted into a custom container. */
|
||||
const SYSTEM_BLOCK_PREFIXES = ['/etc', '/proc', '/sys', '/boot', '/dev', '/run', '/var/run']
|
||||
|
||||
/** Registries we ship curated apps from; anything else is allowed but warned on. */
|
||||
const TRUSTED_REGISTRIES = ['docker.io', 'registry-1.docker.io', 'ghcr.io', 'lscr.io', 'quay.io']
|
||||
|
||||
/** Resolve the managed storage root (where bind mounts are expected to live). */
|
||||
export function getStorageRoot(): string {
|
||||
return normalize(env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** Normalize an absolute path: collapse `..`/`.` segments and strip any trailing slash. */
|
||||
function normalizeHostPath(p: string): string {
|
||||
return normalize(p).replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** True when `child` equals `ancestor` or sits beneath it. */
|
||||
function isWithin(child: string, ancestor: string): boolean {
|
||||
return child === ancestor || child.startsWith(ancestor + '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate user-supplied bind mounts. Hard-blocks the Docker socket, core system directories,
|
||||
* and any mount at or above project-nomad's own install tree (which would expose its code/data).
|
||||
* Warns on any host path outside the managed storage root.
|
||||
*/
|
||||
export function evaluateBindMounts(
|
||||
volumes: { host_path: string; container_path: string }[]
|
||||
): GuardEvaluation {
|
||||
const blocked: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
const storageRoot = getStorageRoot()
|
||||
// The install tree is the parent of the storage root (e.g. /opt/project-nomad). Mounting it —
|
||||
// or any ancestor, up to and including `/` — would hand a container project-nomad's own files.
|
||||
const installRoot = dirname(storageRoot)
|
||||
|
||||
for (const { host_path: hostPath, container_path: containerPath } of volumes) {
|
||||
const host = normalizeHostPath(hostPath)
|
||||
|
||||
if (!hostPath.startsWith('/')) {
|
||||
blocked.push(`Volume host path "${hostPath}" must be an absolute path.`)
|
||||
continue
|
||||
}
|
||||
if (!containerPath.startsWith('/')) {
|
||||
blocked.push(`Volume container path "${containerPath}" must be an absolute path.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// A colon is Docker's bind delimiter (host:container:options). A path containing one would be
|
||||
// re-split by Docker into a different mount than the one validated here — reject it outright so
|
||||
// the checks below can't be bypassed by a parse-differential. (The validator blocks this too;
|
||||
// this keeps the guard self-defending for any caller that skips validation.)
|
||||
if (hostPath.includes(':') || containerPath.includes(':')) {
|
||||
blocked.push(`Volume paths must not contain a colon (":"): "${hostPath}" → "${containerPath}".`)
|
||||
continue
|
||||
}
|
||||
|
||||
// The Docker socket is the most dangerous mount of all — full control of the host daemon.
|
||||
if (host.endsWith('docker.sock') || /\/docker\.sock$/.test(host)) {
|
||||
blocked.push(
|
||||
`Mounting the Docker socket ("${hostPath}") is not allowed — it grants full host control.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Core system directories.
|
||||
if (host === '/' || SYSTEM_BLOCK_PREFIXES.some((p) => isWithin(host, p))) {
|
||||
blocked.push(`Mounting system directory "${hostPath}" is not allowed.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// At or above project-nomad's own install tree (covers `/`, `/opt`, `/opt/project-nomad`).
|
||||
if (host === installRoot || isWithin(installRoot, host)) {
|
||||
blocked.push(
|
||||
`Mounting "${hostPath}" would expose project-nomad's own files and is not allowed.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Anything outside the managed storage root is allowed but flagged.
|
||||
if (!isWithin(host, storageRoot)) {
|
||||
warnings.push(
|
||||
`Volume "${hostPath}" is outside the managed storage root (${storageRoot}). Make sure you trust this image with access to that path.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a Docker image reference. Hard-blocks malformed references; warns on moving tags
|
||||
* (`:latest`/untagged) and images from registries outside the trusted set.
|
||||
*/
|
||||
export function evaluateImageReference(image: string): GuardEvaluation {
|
||||
const blocked: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
const ref = image.trim()
|
||||
// Loose validity check: no whitespace/control chars, and a sane character set for an image ref.
|
||||
if (!ref || /\s/.test(ref) || !/^[\w./:@-]+$/.test(ref)) {
|
||||
blocked.push(`"${image}" is not a valid image reference.`)
|
||||
return { blocked, warnings }
|
||||
}
|
||||
|
||||
// Split off any digest, then any tag, to inspect the registry and tag.
|
||||
const [nameAndTag] = ref.split('@')
|
||||
const firstSegment = nameAndTag.split('/')[0]
|
||||
const hasRegistryHost =
|
||||
nameAndTag.includes('/') && (firstSegment.includes('.') || firstSegment.includes(':'))
|
||||
const registry = hasRegistryHost ? firstSegment.split(':')[0] : 'docker.io'
|
||||
|
||||
if (!TRUSTED_REGISTRIES.includes(registry)) {
|
||||
warnings.push(
|
||||
`Image is from "${registry}", which is outside project-nomad's trusted registries. Only install images you trust.`
|
||||
)
|
||||
}
|
||||
|
||||
// Determine the tag (ignore a colon that's part of a registry host:port in the first segment).
|
||||
const remainder = hasRegistryHost ? nameAndTag.slice(firstSegment.length + 1) : nameAndTag
|
||||
const tag = remainder.includes(':') ? remainder.split(':').pop() : undefined
|
||||
const hasDigest = ref.includes('@sha256:')
|
||||
if (!hasDigest && (!tag || tag === 'latest')) {
|
||||
warnings.push(
|
||||
`Image "${image}" uses a moving tag (${tag ? ':latest' : 'no tag'}). Pin a specific version for reproducible installs.`
|
||||
)
|
||||
}
|
||||
|
||||
return { blocked, warnings }
|
||||
}
|
||||
|
||||
/** Combine bind-mount and image evaluations into a single result. */
|
||||
export function evaluateCustomApp(input: {
|
||||
image?: string
|
||||
volumes?: { host_path: string; container_path: string }[]
|
||||
}): GuardEvaluation {
|
||||
const bind = evaluateBindMounts(input.volumes ?? [])
|
||||
const img = input.image ? evaluateImageReference(input.image) : { blocked: [], warnings: [] }
|
||||
return {
|
||||
blocked: [...bind.blocked, ...img.blocked],
|
||||
warnings: [...bind.warnings, ...img.warnings],
|
||||
}
|
||||
}
|
||||
|
||||
/** Default resource caps applied to custom containers unless the user overrides them. */
|
||||
export const DEFAULT_MEMORY_MB = 1024
|
||||
export const DEFAULT_CPUS = 1
|
||||
|
|
@ -781,8 +781,15 @@ export class DockerService {
|
|||
try {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (service) {
|
||||
service.installation_status = 'error'
|
||||
await service.save()
|
||||
if (service.is_custom) {
|
||||
// Custom apps have no seeder definition to fall back to — leaving the row would
|
||||
// surface a phantom, un-installable card. Remove the record entirely so a failed
|
||||
// install cleanly disappears. (The 'error' broadcast has already fired upstream.)
|
||||
await service.delete()
|
||||
} else {
|
||||
service.installation_status = 'error'
|
||||
await service.save()
|
||||
}
|
||||
}
|
||||
this.activeInstallations.delete(serviceName)
|
||||
|
||||
|
|
@ -1363,6 +1370,299 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any of the supplied host ports are already bound by a running or stopped
|
||||
* Docker container. Uses the Docker API exclusively — probing ports via net.createServer()
|
||||
* would only test the admin container's own network namespace (DooD pattern), not the host.
|
||||
*/
|
||||
async checkPortConflicts(
|
||||
ports: number[]
|
||||
): Promise<{ conflicts: { port: number; usedBy: string }[] }> {
|
||||
if (!ports.length) return { conflicts: [] }
|
||||
|
||||
try {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const bound = new Map<number, string>()
|
||||
|
||||
for (const c of containers) {
|
||||
const name = (c.Names[0] || '').replace('/', '')
|
||||
for (const p of c.Ports) {
|
||||
if (p.PublicPort) bound.set(p.PublicPort, name || c.Id.slice(0, 12))
|
||||
}
|
||||
}
|
||||
|
||||
const conflicts = ports
|
||||
.filter((p) => bound.has(p))
|
||||
.map((p) => ({ port: p, usedBy: bound.get(p)! }))
|
||||
|
||||
return { conflicts }
|
||||
} catch (error: any) {
|
||||
logger.warn(`[DockerService] checkPortConflicts failed: ${error.message}`)
|
||||
return { conflicts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom-app container and, when `removeImage` is set, its backing image too. Called
|
||||
* before deleting the DB record. Image removal is best-effort: a shared/in-use image is left alone.
|
||||
*/
|
||||
async removeCustomAppContainer(
|
||||
serviceName: string,
|
||||
removeImage = false
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const container = containers.find((c) => c.Names.includes(`/${serviceName}`))
|
||||
|
||||
if (!container) return { success: true, message: 'No container found — nothing to remove' }
|
||||
|
||||
const imageRef = container.Image
|
||||
const c = this.docker.getContainer(container.Id)
|
||||
if (container.State === 'running') await c.stop()
|
||||
await c.remove({ force: true })
|
||||
|
||||
if (removeImage && imageRef) {
|
||||
try {
|
||||
await this.docker.getImage(imageRef).remove()
|
||||
} catch (imgErr: any) {
|
||||
// Non-fatal: the image may be shared with another container or already gone.
|
||||
logger.warn(`[DockerService] Could not remove image ${imageRef} for ${serviceName}: ${imgErr.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.invalidateServicesStatusCache()
|
||||
return { success: true, message: `Container ${serviceName} removed` }
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error }, `[DockerService] removeCustomAppContainer failed for ${serviceName}`)
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Find a container by its managed service name (`/serviceName`), or null. */
|
||||
private async _findContainerByName(serviceName: string) {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
return containers.find((c) => c.Names.includes(`/${serviceName}`)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the multiplexed stream Docker returns for non-TTY container logs. Each frame is an
|
||||
* 8-byte header ([streamType, 0,0,0, big-endian payloadSize]) followed by the payload.
|
||||
*/
|
||||
private _demuxDockerLog(buf: Buffer): string {
|
||||
let out = ''
|
||||
let offset = 0
|
||||
while (offset + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(offset + 4)
|
||||
offset += 8
|
||||
if (offset + size > buf.length) {
|
||||
out += buf.toString('utf8', offset)
|
||||
break
|
||||
}
|
||||
out += buf.toString('utf8', offset, offset + size)
|
||||
offset += size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Return the last `tail` lines of a service container's combined stdout/stderr. */
|
||||
async getContainerLogs(
|
||||
serviceName: string,
|
||||
tail = 200
|
||||
): Promise<{ success: boolean; logs?: string; message?: string }> {
|
||||
try {
|
||||
const info = await this._findContainerByName(serviceName)
|
||||
if (!info) return { success: false, message: `No container found for ${serviceName}` }
|
||||
|
||||
const container = this.docker.getContainer(info.Id)
|
||||
const inspect = await container.inspect()
|
||||
const tty = inspect.Config?.Tty ?? false
|
||||
|
||||
const buf = (await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: false,
|
||||
tail,
|
||||
timestamps: false,
|
||||
})) as unknown as Buffer
|
||||
|
||||
const logs = tty ? buf.toString('utf8') : this._demuxDockerLog(buf)
|
||||
return { success: true, logs }
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error }, `[DockerService] getContainerLogs failed for ${serviceName}`)
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single resource-usage snapshot (CPU %, memory) for a running service container.
|
||||
* Uses Docker's non-streaming stats, which include precpu_stats so CPU % is computable.
|
||||
*/
|
||||
async getContainerStats(serviceName: string): Promise<{
|
||||
success: boolean
|
||||
running?: boolean
|
||||
stats?: { cpuPercent: number; memUsageBytes: number; memLimitBytes: number; memPercent: number }
|
||||
message?: string
|
||||
}> {
|
||||
try {
|
||||
const info = await this._findContainerByName(serviceName)
|
||||
if (!info) return { success: false, message: `No container found for ${serviceName}` }
|
||||
if (info.State !== 'running') return { success: true, running: false }
|
||||
|
||||
const container = this.docker.getContainer(info.Id)
|
||||
const s: any = await container.stats({ stream: false })
|
||||
|
||||
const cpuDelta =
|
||||
(s.cpu_stats?.cpu_usage?.total_usage ?? 0) - (s.precpu_stats?.cpu_usage?.total_usage ?? 0)
|
||||
const systemDelta =
|
||||
(s.cpu_stats?.system_cpu_usage ?? 0) - (s.precpu_stats?.system_cpu_usage ?? 0)
|
||||
const numCpus =
|
||||
s.cpu_stats?.online_cpus ?? s.cpu_stats?.cpu_usage?.percpu_usage?.length ?? 1
|
||||
const cpuPercent =
|
||||
systemDelta > 0 && cpuDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0
|
||||
|
||||
// Subtract page cache from usage to better reflect the container's working set.
|
||||
const cache = s.memory_stats?.stats?.cache ?? s.memory_stats?.stats?.inactive_file ?? 0
|
||||
const memUsageBytes = Math.max(0, (s.memory_stats?.usage ?? 0) - cache)
|
||||
const memLimitBytes = s.memory_stats?.limit ?? 0
|
||||
const memPercent = memLimitBytes > 0 ? (memUsageBytes / memLimitBytes) * 100 : 0
|
||||
|
||||
return {
|
||||
success: true,
|
||||
running: true,
|
||||
stats: {
|
||||
cpuPercent: Math.round(cpuPercent * 10) / 10,
|
||||
memUsageBytes,
|
||||
memLimitBytes,
|
||||
memPercent: Math.round(memPercent * 10) / 10,
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error }, `[DockerService] getContainerStats failed for ${serviceName}`)
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a freshly started container to be "ready". If the image declares a HEALTHCHECK we poll
|
||||
* its health until healthy/unhealthy (up to timeoutMs); otherwise we fall back to a 5s settle and
|
||||
* a plain Running check. Returns whether it's ready plus a reason when not.
|
||||
*/
|
||||
private async _awaitContainerReady(
|
||||
container: any,
|
||||
timeoutMs = 30000
|
||||
): Promise<{ ready: boolean; reason?: string }> {
|
||||
let inspect = await container.inspect()
|
||||
const hasHealthcheck = !!inspect.State?.Health
|
||||
|
||||
if (!hasHealthcheck) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
inspect = await container.inspect()
|
||||
return inspect.State?.Running
|
||||
? { ready: true }
|
||||
: { ready: false, reason: 'container did not stay running' }
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
inspect = await container.inspect()
|
||||
if (!inspect.State?.Running) return { ready: false, reason: 'container exited' }
|
||||
const status = inspect.State?.Health?.Status
|
||||
if (status === 'healthy') return { ready: true }
|
||||
if (status === 'unhealthy') return { ready: false, reason: 'failed its health check' }
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
// Still in "starting" at timeout — accept it if it's at least running rather than roll back a slow boot.
|
||||
return inspect.State?.Running ? { ready: true } : { ready: false, reason: 'health check timed out' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate a custom app's container from its (already-updated) Service record, preserving data.
|
||||
* Uses the same rename-and-rollback safety net as the update flow: the live container is renamed
|
||||
* aside, a new one is created from the new config/image, health-gated, and only then is the old one
|
||||
* removed — otherwise we roll back to it. Bind-mounted data is untouched throughout. Pass
|
||||
* `forcePull` to always re-pull the image first (used by the "update" action for moving tags).
|
||||
*/
|
||||
async recreateCustomAppContainer(
|
||||
serviceName: string,
|
||||
opts: { forcePull?: boolean } = {}
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (!service) return { success: false, message: `Service ${serviceName} not found` }
|
||||
|
||||
const containerConfig = this._parseContainerConfig(service.container_config)
|
||||
const oldInfo = await this._findContainerByName(serviceName)
|
||||
const oldName = `${serviceName}_old`
|
||||
|
||||
try {
|
||||
// Stop + rename the existing container aside as a rollback safety net.
|
||||
if (oldInfo) {
|
||||
const oldContainer = this.docker.getContainer(oldInfo.Id)
|
||||
if (oldInfo.State === 'running') await oldContainer.stop({ t: 10 }).catch(() => {})
|
||||
await oldContainer.rename({ name: oldName })
|
||||
}
|
||||
|
||||
// Pull the image if it's missing locally, or always when forcePull (e.g. :latest updates).
|
||||
if (opts.forcePull || !(await this._checkImageExists(service.container_image))) {
|
||||
const pullStream = await this.docker.pull(service.container_image)
|
||||
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
|
||||
}
|
||||
|
||||
const newContainer = await this.docker.createContainer({
|
||||
Image: service.container_image,
|
||||
name: serviceName,
|
||||
Labels: {
|
||||
...(containerConfig?.Labels ?? {}),
|
||||
'com.docker.compose.project': 'project-nomad-managed',
|
||||
'io.project-nomad.managed': 'true',
|
||||
},
|
||||
...(containerConfig?.User && { User: containerConfig.User }),
|
||||
HostConfig: containerConfig?.HostConfig ?? {},
|
||||
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
|
||||
...(containerConfig?.Env && { Env: containerConfig.Env }),
|
||||
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
|
||||
...(process.env.NODE_ENV === 'production' && {
|
||||
NetworkingConfig: { EndpointsConfig: { [DockerService.NOMAD_NETWORK]: {} } },
|
||||
}),
|
||||
})
|
||||
await newContainer.start()
|
||||
|
||||
// Health gate before discarding the old container.
|
||||
const readiness = await this._awaitContainerReady(newContainer)
|
||||
if (!readiness.ready) throw new Error(`recreated container ${readiness.reason}`)
|
||||
|
||||
if (oldInfo) {
|
||||
const oldRef = await this._findContainerByName(oldName)
|
||||
if (oldRef) await this.docker.getContainer(oldRef.Id).remove({ force: true })
|
||||
}
|
||||
service.installed = true
|
||||
service.installation_status = 'idle'
|
||||
await service.save()
|
||||
this.invalidateServicesStatusCache()
|
||||
return { success: true, message: `Service ${serviceName} reconfigured successfully` }
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error }, `[DockerService] recreateCustomAppContainer failed for ${serviceName}`)
|
||||
// Roll back: discard the failed new container and restore the renamed original.
|
||||
try {
|
||||
const failedNew = await this._findContainerByName(serviceName)
|
||||
if (failedNew) {
|
||||
const c = this.docker.getContainer(failedNew.Id)
|
||||
await c.stop({ t: 5 }).catch(() => {})
|
||||
await c.remove({ force: true }).catch(() => {})
|
||||
}
|
||||
const renamed = await this._findContainerByName(oldName)
|
||||
if (renamed) {
|
||||
const c = this.docker.getContainer(renamed.Id)
|
||||
await c.rename({ name: serviceName })
|
||||
await c.start().catch(() => {})
|
||||
}
|
||||
} catch (rollbackError: any) {
|
||||
logger.error({ err: rollbackError }, `[DockerService] rollback failed for ${serviceName}`)
|
||||
}
|
||||
this.invalidateServicesStatusCache()
|
||||
return { success: false, message: `Reconfigure failed and was rolled back: ${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Docker image exists locally.
|
||||
* @param imageName - The name and tag of the image (e.g., "nginx:latest")
|
||||
|
|
|
|||
|
|
@ -308,7 +308,9 @@ export class SystemService {
|
|||
'powered_by',
|
||||
'display_order',
|
||||
'container_image',
|
||||
'available_update_version'
|
||||
'available_update_version',
|
||||
'is_custom',
|
||||
'category'
|
||||
)
|
||||
.where('is_dependency_service', false)
|
||||
if (installedOnly) {
|
||||
|
|
@ -338,6 +340,8 @@ export class SystemService {
|
|||
display_order: service.display_order,
|
||||
container_image: service.container_image,
|
||||
available_update_version: service.available_update_version,
|
||||
is_custom: service.is_custom,
|
||||
category: service.category,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -928,4 +932,83 @@ export class SystemService {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the host has enough free memory and disk to comfortably run an app.
|
||||
* Returns an array of human-readable warning strings; an empty array means no concerns.
|
||||
* These are advisory only — the caller decides whether to block or warn.
|
||||
*/
|
||||
async checkResourceWarnings(minMemoryMB: number, minDiskMB: number): Promise<string[]> {
|
||||
const warnings: string[] = []
|
||||
|
||||
try {
|
||||
const mem = await si.mem()
|
||||
const availableMB = Math.floor(mem.available / 1024 / 1024)
|
||||
if (availableMB < minMemoryMB) {
|
||||
warnings.push(
|
||||
`Low memory: ${availableMB} MB available, this app recommends at least ${minMemoryMB} MB free.`
|
||||
)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`[SystemService] checkResourceWarnings mem check failed: ${err.message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const storagePath = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
|
||||
const fsSizes = await si.fsSize()
|
||||
// Find the filesystem whose mount point is the longest prefix of storagePath
|
||||
const fs = fsSizes
|
||||
.filter((f) => storagePath.startsWith(f.mount))
|
||||
.sort((a, b) => b.mount.length - a.mount.length)[0]
|
||||
|
||||
if (fs) {
|
||||
const availableDiskMB = Math.floor((fs.size - fs.used) / 1024 / 1024)
|
||||
if (availableDiskMB < minDiskMB) {
|
||||
warnings.push(
|
||||
`Low disk space: ${availableDiskMB} MB available on ${fs.mount}, this app recommends at least ${minDiskMB} MB free.`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`[SystemService] checkResourceWarnings disk check failed: ${err.message}`)
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the next suggested host port for a custom app in the 8600+ range.
|
||||
* Looks at existing custom service records and all Docker container port bindings.
|
||||
*/
|
||||
async getNextSuggestedCustomPort(): Promise<number> {
|
||||
const CUSTOM_PORT_START = 8600
|
||||
const occupied = new Set<number>()
|
||||
|
||||
try {
|
||||
// Ports used by existing custom services in the DB
|
||||
const customServices = await Service.query().where('is_custom', true)
|
||||
for (const svc of customServices) {
|
||||
const config = svc.container_config ? JSON.parse(svc.container_config) : null
|
||||
const bindings = config?.HostConfig?.PortBindings ?? {}
|
||||
for (const binding of Object.values(bindings) as any[]) {
|
||||
const port = parseInt(binding?.[0]?.HostPort, 10)
|
||||
if (!isNaN(port)) occupied.add(port)
|
||||
}
|
||||
}
|
||||
|
||||
// Ports used by any running Docker container in the 8600+ range
|
||||
const containers = await this.dockerService.docker.listContainers({ all: true })
|
||||
for (const c of containers) {
|
||||
for (const p of c.Ports) {
|
||||
if (p.PublicPort && p.PublicPort >= CUSTOM_PORT_START) occupied.add(p.PublicPort)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`[SystemService] getNextSuggestedCustomPort probe failed: ${err.message}`)
|
||||
}
|
||||
|
||||
let candidate = CUSTOM_PORT_START
|
||||
while (occupied.has(candidate)) candidate += 10
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,97 @@ export const updateServiceValidator = vine.compile(
|
|||
target_version: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
|
||||
export const preflightValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
|
||||
// Shared sub-schema for a volume bind mapping. A colon is Docker's bind delimiter
|
||||
// (host:container:options) — forbid it in either field so a path can't smuggle in an
|
||||
// extra segment that the guard reads as safe but Docker re-parses as a different mount.
|
||||
const volumeSchema = vine.object({
|
||||
host_path: vine.string().trim().regex(/^[^:]+$/),
|
||||
container_path: vine.string().trim().regex(/^[^:]+$/),
|
||||
})
|
||||
|
||||
// Environment variables must be KEY=value (value may be empty), matching Docker's Env format.
|
||||
const envVarSchema = vine.string().trim().regex(/^[A-Za-z_][A-Za-z0-9_]*=[\s\S]*$/)
|
||||
|
||||
// Service-less preflight for the custom-app form: evaluates ports, volumes and image together.
|
||||
export const preflightCustomValidator = vine.compile(
|
||||
vine.object({
|
||||
image: vine.string().trim().optional(),
|
||||
ports: vine.array(vine.number().min(1).max(65535)).optional(),
|
||||
volumes: vine.array(volumeSchema).optional(),
|
||||
// When editing, ignore port conflicts caused by this app's own running container.
|
||||
exclude_service: vine.string().trim().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const customAppValidator = vine.compile(
|
||||
vine.object({
|
||||
friendly_name: vine.string().trim().minLength(1).maxLength(100),
|
||||
image: vine.string().trim().minLength(1),
|
||||
ports: vine
|
||||
.array(
|
||||
vine.object({
|
||||
container: vine.number().min(1).max(65535),
|
||||
host: vine.number().min(1024).max(65535),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
volumes: vine.array(volumeSchema).optional(),
|
||||
env: vine.array(envVarSchema).optional(),
|
||||
category: vine
|
||||
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
|
||||
.optional(),
|
||||
icon: vine.string().trim().optional(),
|
||||
// Optional resource caps (advanced). Default caps are applied when omitted.
|
||||
memory_mb: vine.number().min(64).optional(),
|
||||
cpus: vine.number().min(0.1).max(64).optional(),
|
||||
// When true, bypass advisory preflight (port conflicts / guard warnings) and install anyway.
|
||||
force: vine.boolean().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const deleteCustomAppValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
// When true, also remove the backing Docker image (best-effort).
|
||||
remove_image: vine.boolean().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const serviceLogsValidator = vine.compile(
|
||||
vine.object({
|
||||
tail: vine.number().min(1).max(2000).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
// Reconfigure an existing custom app: the create shape plus the target service_name.
|
||||
export const updateCustomAppValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
friendly_name: vine.string().trim().minLength(1).maxLength(100),
|
||||
image: vine.string().trim().minLength(1),
|
||||
ports: vine
|
||||
.array(
|
||||
vine.object({
|
||||
container: vine.number().min(1).max(65535),
|
||||
host: vine.number().min(1024).max(65535),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
volumes: vine.array(volumeSchema).optional(),
|
||||
env: vine.array(envVarSchema).optional(),
|
||||
category: vine
|
||||
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
|
||||
.optional(),
|
||||
icon: vine.string().trim().optional(),
|
||||
memory_mb: vine.number().min(64).optional(),
|
||||
cpus: vine.number().min(0.1).max(64).optional(),
|
||||
force: vine.boolean().optional(),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,4 +5,15 @@ export const SERVICE_NAMES = {
|
|||
CYBERCHEF: 'nomad_cyberchef',
|
||||
FLATNOTES: 'nomad_flatnotes',
|
||||
KOLIBRI: 'nomad_kolibri',
|
||||
// Supply Depot — curated catalog (ports 8400–8499)
|
||||
STIRLING_PDF: 'nomad_stirling_pdf',
|
||||
FILEBROWSER: 'nomad_filebrowser',
|
||||
CALIBREWEB: 'nomad_calibreweb',
|
||||
IT_TOOLS: 'nomad_it_tools',
|
||||
EXCALIDRAW: 'nomad_excalidraw',
|
||||
MESHTASTIC_WEB: 'nomad_meshtastic_web',
|
||||
MESHTASTICD: 'nomad_meshtasticd',
|
||||
HOMEBOX: 'nomad_homebox',
|
||||
VAULTWARDEN: 'nomad_vaultwarden',
|
||||
JELLYFIN: 'nomad_jellyfin',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
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_custom').notNullable().defaultTo(false)
|
||||
table.string('category').nullable()
|
||||
})
|
||||
|
||||
// Backfill categories for existing curated services
|
||||
this.defer(async (db) => {
|
||||
const updates: Array<{ service_name: string; category: string }> = [
|
||||
{ service_name: 'nomad_kiwix_server', category: 'education' },
|
||||
{ service_name: 'nomad_kolibri', category: 'education' },
|
||||
{ service_name: 'nomad_ollama', category: 'ai' },
|
||||
{ service_name: 'nomad_cyberchef', category: 'utility' },
|
||||
{ service_name: 'nomad_flatnotes', category: 'productivity' },
|
||||
]
|
||||
|
||||
for (const { service_name, category } of updates) {
|
||||
await db.from('services').where('service_name', service_name).update({ category })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('is_custom')
|
||||
table.dropColumn('category')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,19 @@ import env from '#start/env'
|
|||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
|
||||
|
||||
type ServiceSeedRecord = Omit<
|
||||
ModelAttributes<Service>,
|
||||
'created_at' | 'updated_at' | 'id' | 'available_update_version' | 'update_checked_at' | 'metadata'
|
||||
> & { metadata?: string | null }
|
||||
|
||||
export default class ServiceSeeder extends BaseSeeder {
|
||||
// Use environment variable with fallback to production default
|
||||
private static NOMAD_STORAGE_ABS_PATH = env.get(
|
||||
'NOMAD_STORAGE_PATH',
|
||||
'/opt/project-nomad/storage'
|
||||
)
|
||||
private static DEFAULT_SERVICES: Omit<
|
||||
ModelAttributes<Service>,
|
||||
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
|
||||
>[] = [
|
||||
private static DEFAULT_SERVICES: ServiceSeedRecord[] = [
|
||||
// ── Core / original services ──────────────────────────────────────────────
|
||||
{
|
||||
service_name: SERVICE_NAMES.KIWIX,
|
||||
friendly_name: 'Information Library',
|
||||
|
|
@ -38,13 +41,15 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'education',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.QDRANT,
|
||||
friendly_name: 'Qdrant Vector Database',
|
||||
powered_by: null,
|
||||
display_order: 100, // Dependency service, not shown directly
|
||||
display_order: 100,
|
||||
description: 'Vector database for storing and searching embeddings',
|
||||
icon: 'IconRobot',
|
||||
container_image: 'qdrant/qdrant:v1.16',
|
||||
|
|
@ -57,15 +62,15 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] },
|
||||
},
|
||||
ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} },
|
||||
// Disable Qdrant's anonymous telemetry to telemetry.qdrant.io. NOMAD is offline-first
|
||||
// and ships with zero telemetry by default — Qdrant's upstream default of enabled
|
||||
// telemetry doesn't match that posture.
|
||||
// Disable anonymous telemetry — NOMAD is offline-first
|
||||
Env: ['QDRANT__TELEMETRY_DISABLED=true'],
|
||||
}),
|
||||
ui_location: '6333',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: true,
|
||||
is_custom: false,
|
||||
category: null,
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
|
|
@ -90,6 +95,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'ai',
|
||||
depends_on: SERVICE_NAMES.QDRANT,
|
||||
},
|
||||
{
|
||||
|
|
@ -113,6 +120,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'utility',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
|
|
@ -138,6 +147,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'productivity',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
|
|
@ -162,18 +173,316 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'education',
|
||||
depends_on: null,
|
||||
},
|
||||
|
||||
// ── Supply Depot — curated catalog (ports 8400–8499) ─────────────────────
|
||||
|
||||
{
|
||||
service_name: SERVICE_NAMES.STIRLING_PDF,
|
||||
friendly_name: 'Stirling PDF',
|
||||
powered_by: 'Stirling-Tools',
|
||||
display_order: 20,
|
||||
description: 'Locally-hosted PDF manipulation tool — merge, split, compress, convert, and more',
|
||||
icon: 'IconFileDescription',
|
||||
container_image: 'ghcr.io/stirling-tools/s-pdf:latest',
|
||||
source_repo: 'https://github.com/Stirling-Tools/Stirling-PDF',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '8080/tcp': [{ HostPort: '8400' }] },
|
||||
Binds: [
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/configs:/configs`,
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/logs:/logs`,
|
||||
],
|
||||
},
|
||||
ExposedPorts: { '8080/tcp': {} },
|
||||
Env: ['DOCKER_ENABLE_SECURITY=false', 'LANGS=en_GB'],
|
||||
}),
|
||||
ui_location: '8400',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'productivity',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.FILEBROWSER,
|
||||
friendly_name: 'File Browser',
|
||||
powered_by: 'FileBrowser',
|
||||
display_order: 21,
|
||||
description: 'Web-based file manager — browse, upload, download, and organize files on your device',
|
||||
icon: 'IconFolderOpen',
|
||||
container_image: 'filebrowser/filebrowser:v2',
|
||||
source_repo: 'https://github.com/filebrowser/filebrowser',
|
||||
// Stores the database alongside the files it serves so a single volume covers everything.
|
||||
// User: root — host directories auto-created by Docker are owned root:root 755; FileBrowser's
|
||||
// image runs as UID 1000 by default which can't write to them (DooD: we can't chown on host).
|
||||
container_command: '--root /srv --database /srv/.filebrowser.db',
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '80/tcp': [{ HostPort: '8410' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser:/srv`],
|
||||
},
|
||||
ExposedPorts: { '80/tcp': {} },
|
||||
User: 'root'
|
||||
}),
|
||||
ui_location: '8410',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'utility',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.CALIBREWEB,
|
||||
friendly_name: 'Calibre Web',
|
||||
powered_by: 'Calibre-Web',
|
||||
display_order: 22,
|
||||
description: 'Web-based e-book reader and library manager for your Calibre collection',
|
||||
icon: 'IconBook',
|
||||
container_image: 'lscr.io/linuxserver/calibre-web:latest',
|
||||
source_repo: 'https://github.com/janeczku/calibre-web',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '8083/tcp': [{ HostPort: '8420' }] },
|
||||
Binds: [
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/calibreweb/config:/config`,
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/books`,
|
||||
],
|
||||
},
|
||||
ExposedPorts: { '8083/tcp': {} },
|
||||
Env: ['PUID=1000', 'PGID=1000'],
|
||||
}),
|
||||
ui_location: '8420',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'media',
|
||||
depends_on: null,
|
||||
metadata: JSON.stringify({ minMemoryMB: 512, minDiskMB: 5120 }),
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.IT_TOOLS,
|
||||
friendly_name: 'IT Tools',
|
||||
powered_by: 'IT-Tools',
|
||||
display_order: 23,
|
||||
description: 'Collection of handy utilities for developers — UUID, hash, encoding, formatters, and more',
|
||||
icon: 'IconTool',
|
||||
container_image: 'ghcr.io/corentinth/it-tools:latest',
|
||||
source_repo: 'https://github.com/CorentinTh/it-tools',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '80/tcp': [{ HostPort: '8430' }] },
|
||||
},
|
||||
ExposedPorts: { '80/tcp': {} },
|
||||
}),
|
||||
ui_location: '8430',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'utility',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.EXCALIDRAW,
|
||||
friendly_name: 'Excalidraw',
|
||||
powered_by: 'Excalidraw',
|
||||
display_order: 24,
|
||||
description: 'Virtual whiteboard for sketching hand-drawn-style diagrams — works fully offline',
|
||||
icon: 'IconPencil',
|
||||
container_image: 'excalidraw/excalidraw:latest',
|
||||
source_repo: 'https://github.com/excalidraw/excalidraw',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '80/tcp': [{ HostPort: '8440' }] },
|
||||
},
|
||||
ExposedPorts: { '80/tcp': {} },
|
||||
}),
|
||||
ui_location: '8440',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'productivity',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.MESHTASTIC_WEB,
|
||||
friendly_name: 'Meshtastic Web',
|
||||
powered_by: 'Meshtastic',
|
||||
display_order: 30,
|
||||
description: 'Browser-based client for managing Meshtastic mesh radio devices',
|
||||
icon: 'IconWifi',
|
||||
container_image: 'ghcr.io/meshtastic/web:latest',
|
||||
source_repo: 'https://github.com/meshtastic/web',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '80/tcp': [{ HostPort: '8450' }] },
|
||||
},
|
||||
ExposedPorts: { '80/tcp': {} },
|
||||
}),
|
||||
ui_location: '8450',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'networking',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.MESHTASTICD,
|
||||
friendly_name: 'Meshtastic Daemon',
|
||||
powered_by: 'Meshtastic',
|
||||
display_order: 31,
|
||||
description: 'Software-defined Meshtastic node with REST API — connect devices and build mesh networks',
|
||||
icon: 'IconBroadcast',
|
||||
container_image: 'meshtastic/meshtasticd:latest',
|
||||
source_repo: 'https://github.com/meshtastic/meshtasticd',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '4403/tcp': [{ HostPort: '8460' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshtasticd:/root/.portduino`],
|
||||
},
|
||||
ExposedPorts: { '4403/tcp': {} },
|
||||
}),
|
||||
ui_location: '8460',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'networking',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.HOMEBOX,
|
||||
friendly_name: 'Homebox',
|
||||
powered_by: 'Homebox',
|
||||
display_order: 25,
|
||||
description: 'Home inventory and asset management — track everything you own',
|
||||
icon: 'IconBox',
|
||||
container_image: 'ghcr.io/hay-kot/homebox:latest',
|
||||
source_repo: 'https://github.com/hay-kot/homebox',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '7745/tcp': [{ HostPort: '8470' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/homebox:/data`],
|
||||
},
|
||||
ExposedPorts: { '7745/tcp': {} },
|
||||
}),
|
||||
ui_location: '8470',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'productivity',
|
||||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.VAULTWARDEN,
|
||||
friendly_name: 'Vaultwarden',
|
||||
powered_by: 'Vaultwarden',
|
||||
display_order: 26,
|
||||
description: 'Lightweight Bitwarden-compatible password manager server — secure your credentials offline',
|
||||
icon: 'IconShieldLock',
|
||||
container_image: 'vaultwarden/server:latest',
|
||||
source_repo: 'https://github.com/dani-garcia/vaultwarden',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '80/tcp': [{ HostPort: '8480' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/vaultwarden:/data`],
|
||||
},
|
||||
ExposedPorts: { '80/tcp': {} },
|
||||
Env: ['WEBSOCKET_ENABLED=true'],
|
||||
}),
|
||||
ui_location: '8480',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'security',
|
||||
depends_on: null,
|
||||
metadata: JSON.stringify({ minMemoryMB: 256, minDiskMB: 512 }),
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.JELLYFIN,
|
||||
friendly_name: 'Jellyfin',
|
||||
powered_by: 'Jellyfin',
|
||||
display_order: 27,
|
||||
description: 'Open-source media server — stream your video, music, and photo libraries',
|
||||
icon: 'IconMovie',
|
||||
container_image: 'jellyfin/jellyfin:latest',
|
||||
source_repo: 'https://github.com/jellyfin/jellyfin',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '8096/tcp': [{ HostPort: '8490' }] },
|
||||
Binds: [
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/config:/config`,
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/cache:/cache`,
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/media`,
|
||||
],
|
||||
},
|
||||
ExposedPorts: { '8096/tcp': {} },
|
||||
}),
|
||||
ui_location: '8490',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
is_custom: false,
|
||||
category: 'media',
|
||||
depends_on: null,
|
||||
metadata: JSON.stringify({ minMemoryMB: 2048, minDiskMB: 20480 }),
|
||||
},
|
||||
]
|
||||
|
||||
async run() {
|
||||
const existingServices = await Service.query().select('service_name')
|
||||
const existingServiceNames = new Set(existingServices.map((service) => service.service_name))
|
||||
const existingServices = await Service.query().select(['service_name', 'is_custom'])
|
||||
const existingServiceMap = new Map(existingServices.map((s) => [s.service_name, s]))
|
||||
|
||||
const newServices = ServiceSeeder.DEFAULT_SERVICES.filter(
|
||||
(service) => !existingServiceNames.has(service.service_name)
|
||||
(service) => !existingServiceMap.has(service.service_name)
|
||||
)
|
||||
|
||||
await Service.createMany([...newServices])
|
||||
if (newServices.length > 0) {
|
||||
await Service.createMany([...newServices])
|
||||
}
|
||||
|
||||
// Keep container_config, container_command, and metadata in sync for curated services.
|
||||
// Custom services are user-defined and must never be overwritten.
|
||||
for (const service of ServiceSeeder.DEFAULT_SERVICES) {
|
||||
const existing = existingServiceMap.get(service.service_name)
|
||||
if (existing && !existing.is_custom) {
|
||||
await Service.query().where('service_name', service.service_name).update({
|
||||
container_config: service.container_config,
|
||||
container_command: service.container_command ?? null,
|
||||
metadata: (service as any).metadata ?? null,
|
||||
category: service.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,540 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import StyledModal from './StyledModal'
|
||||
import StyledButton from './StyledButton'
|
||||
import Alert from './Alert'
|
||||
import api from '~/lib/api'
|
||||
import Input from './inputs/Input'
|
||||
import Select from './inputs/Select'
|
||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||
import { IconTrash } from '@tabler/icons-react'
|
||||
|
||||
interface PortMapping {
|
||||
container: string
|
||||
host: string
|
||||
}
|
||||
|
||||
interface VolumeMapping {
|
||||
host_path: string
|
||||
container_path: string
|
||||
}
|
||||
|
||||
interface EnvVar {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface CustomAppInitial {
|
||||
service_name: string
|
||||
friendly_name: string | null
|
||||
image: string
|
||||
category: string
|
||||
icon: string
|
||||
ports: Array<{ container: number; host: number }>
|
||||
volumes: Array<{ host_path: string; container_path: string }>
|
||||
env: string[]
|
||||
memory_mb?: number
|
||||
cpus?: number
|
||||
}
|
||||
|
||||
interface CustomAppModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (serviceName: string) => void
|
||||
showError: (msg: string) => void
|
||||
/** 'edit' reconfigures an existing custom app (prefilled from `initial`); defaults to 'create'. */
|
||||
mode?: 'create' | 'edit'
|
||||
initial?: CustomAppInitial | null
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
{ value: 'productivity', label: 'Productivity' },
|
||||
{ value: 'media', label: 'Media' },
|
||||
{ value: 'security', label: 'Security' },
|
||||
{ value: 'networking', label: 'Networking' },
|
||||
{ value: 'utility', label: 'Utility' },
|
||||
{ value: 'ai', label: 'AI' },
|
||||
{ value: 'education', label: 'Education' },
|
||||
]
|
||||
|
||||
// Curated subset of the DynamicIcon map suitable for custom apps.
|
||||
const ICON_OPTIONS = [
|
||||
{ value: 'IconBrandDocker', label: 'Docker (default)' },
|
||||
{ value: 'IconBox', label: 'Box' },
|
||||
{ value: 'IconServer', label: 'Server' },
|
||||
{ value: 'IconDatabase', label: 'Database' },
|
||||
{ value: 'IconCode', label: 'Code' },
|
||||
{ value: 'IconTool', label: 'Tool' },
|
||||
{ value: 'IconWorld', label: 'Web' },
|
||||
{ value: 'IconShieldLock', label: 'Security' },
|
||||
{ value: 'IconMovie', label: 'Media' },
|
||||
{ value: 'IconBook', label: 'Book' },
|
||||
{ value: 'IconNotes', label: 'Notes' },
|
||||
{ value: 'IconCpu', label: 'Compute' },
|
||||
{ value: 'IconRobot', label: 'AI / Bot' },
|
||||
{ value: 'IconWifi', label: 'Network' },
|
||||
{ value: 'IconHome', label: 'Home' },
|
||||
]
|
||||
|
||||
export default function CustomAppModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
showError,
|
||||
mode = 'create',
|
||||
initial = null,
|
||||
}: CustomAppModalProps) {
|
||||
const isEdit = mode === 'edit'
|
||||
const [friendlyName, setFriendlyName] = useState('')
|
||||
const [image, setImage] = useState('')
|
||||
const [category, setCategory] = useState('custom')
|
||||
const [icon, setIcon] = useState('IconBrandDocker')
|
||||
const [ports, setPorts] = useState<PortMapping[]>([{ container: '', host: '' }])
|
||||
const [volumes, setVolumes] = useState<VolumeMapping[]>([])
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([])
|
||||
const [memoryMb, setMemoryMb] = useState('')
|
||||
const [cpus, setCpus] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [portConflicts, setPortConflicts] = useState<Array<{ port: number; usedBy: string }>>([])
|
||||
const [resourceWarnings, setResourceWarnings] = useState<string[]>([])
|
||||
const [blocked, setBlocked] = useState<string[]>([])
|
||||
const [forceInstall, setForceInstall] = useState(false)
|
||||
const [checkingPreflight, setCheckingPreflight] = useState(false)
|
||||
const [suggestedPort, setSuggestedPort] = useState<number | null>(null)
|
||||
|
||||
// On open: prefill from the existing app (edit) or fetch a suggested port (create).
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (isEdit && initial) {
|
||||
setFriendlyName(initial.friendly_name ?? '')
|
||||
setImage(initial.image)
|
||||
setCategory(initial.category)
|
||||
setIcon(initial.icon || 'IconBrandDocker')
|
||||
setPorts(
|
||||
initial.ports.length
|
||||
? initial.ports.map((p) => ({ container: String(p.container), host: String(p.host) }))
|
||||
: [{ container: '', host: '' }]
|
||||
)
|
||||
setVolumes(initial.volumes)
|
||||
setEnvVars(initial.env.map((v) => ({ value: v })))
|
||||
setMemoryMb(initial.memory_mb != null ? String(initial.memory_mb) : '')
|
||||
setCpus(initial.cpus != null ? String(initial.cpus) : '')
|
||||
return
|
||||
}
|
||||
api.suggestCustomPort().then((res) => {
|
||||
if (res?.port) {
|
||||
setSuggestedPort(res.port)
|
||||
setPorts([{ container: '', host: String(res.port) }])
|
||||
}
|
||||
})
|
||||
}, [open, isEdit, initial])
|
||||
|
||||
// Live preflight: whenever ports, volumes or the image change, debounce a check for port
|
||||
// conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const validPorts = ports
|
||||
.map((p) => parseInt(p.host, 10))
|
||||
.filter((p) => !isNaN(p))
|
||||
const validVolumes = volumes.filter((v) => v.host_path && v.container_path)
|
||||
|
||||
if (validPorts.length === 0 && validVolumes.length === 0 && !image.trim()) {
|
||||
setPortConflicts([])
|
||||
setResourceWarnings([])
|
||||
setBlocked([])
|
||||
setCheckingPreflight(false)
|
||||
return
|
||||
}
|
||||
|
||||
setCheckingPreflight(true)
|
||||
const handle = setTimeout(async () => {
|
||||
const res = await api.preflightCustomApp({
|
||||
image: image.trim() || undefined,
|
||||
ports: validPorts.length ? validPorts : undefined,
|
||||
volumes: validVolumes.length ? validVolumes : undefined,
|
||||
exclude_service: isEdit && initial ? initial.service_name : undefined,
|
||||
})
|
||||
if (res) {
|
||||
setPortConflicts(res.portConflicts ?? [])
|
||||
setResourceWarnings(res.resourceWarnings ?? [])
|
||||
setBlocked(res.blocked ?? [])
|
||||
}
|
||||
setCheckingPreflight(false)
|
||||
}, 400)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [open, ports, volumes, image])
|
||||
|
||||
function resetForm() {
|
||||
setFriendlyName('')
|
||||
setImage('')
|
||||
setCategory('custom')
|
||||
setIcon('IconBrandDocker')
|
||||
setPorts([{ container: '', host: '' }])
|
||||
setVolumes([])
|
||||
setEnvVars([])
|
||||
setMemoryMb('')
|
||||
setCpus('')
|
||||
setPortConflicts([])
|
||||
setResourceWarnings([])
|
||||
setBlocked([])
|
||||
setForceInstall(false)
|
||||
setSuggestedPort(null)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
onClose()
|
||||
}
|
||||
|
||||
// ── Port row helpers ──────────────────────────────────────────────────────
|
||||
function updatePort(idx: number, field: keyof PortMapping, value: string) {
|
||||
setPorts((prev) => prev.map((p, i) => (i === idx ? { ...p, [field]: value } : p)))
|
||||
}
|
||||
function addPort() {
|
||||
const nextHost = suggestedPort ? suggestedPort + ports.length * 10 : 8600 + ports.length * 10
|
||||
setPorts((prev) => [...prev, { container: '', host: String(nextHost) }])
|
||||
}
|
||||
function removePort(idx: number) {
|
||||
setPorts((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
// ── Volume row helpers ────────────────────────────────────────────────────
|
||||
function updateVolume(idx: number, field: keyof VolumeMapping, value: string) {
|
||||
setVolumes((prev) => prev.map((v, i) => (i === idx ? { ...v, [field]: value } : v)))
|
||||
}
|
||||
function addVolume() {
|
||||
setVolumes((prev) => [...prev, { host_path: '', container_path: '' }])
|
||||
}
|
||||
function removeVolume(idx: number) {
|
||||
setVolumes((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
// ── Env var helpers ───────────────────────────────────────────────────────
|
||||
function updateEnv(idx: number, value: string) {
|
||||
setEnvVars((prev) => prev.map((e, i) => (i === idx ? { value } : e)))
|
||||
}
|
||||
function addEnv() {
|
||||
setEnvVars((prev) => [...prev, { value: '' }])
|
||||
}
|
||||
function removeEnv(idx: number) {
|
||||
setEnvVars((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
async function handleSubmit() {
|
||||
if (!friendlyName.trim() || !image.trim()) {
|
||||
showError('Name and image are required.')
|
||||
return
|
||||
}
|
||||
if (blocked.length > 0) {
|
||||
showError('Resolve the blocked issues before installing.')
|
||||
return
|
||||
}
|
||||
|
||||
const validPorts = ports
|
||||
.filter((p) => p.container && p.host)
|
||||
.map((p) => ({ container: parseInt(p.container, 10), host: parseInt(p.host, 10) }))
|
||||
.filter((p) => !isNaN(p.container) && !isNaN(p.host))
|
||||
|
||||
const validVolumes = volumes.filter((v) => v.host_path && v.container_path)
|
||||
const validEnv = envVars.map((e) => e.value).filter(Boolean)
|
||||
const parsedMemory = parseInt(memoryMb, 10)
|
||||
const parsedCpus = parseFloat(cpus)
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const common = {
|
||||
friendly_name: friendlyName.trim(),
|
||||
image: image.trim(),
|
||||
ports: validPorts.length ? validPorts : undefined,
|
||||
volumes: validVolumes.length ? validVolumes : undefined,
|
||||
env: validEnv.length ? validEnv : undefined,
|
||||
category,
|
||||
icon,
|
||||
memory_mb: !isNaN(parsedMemory) ? parsedMemory : undefined,
|
||||
cpus: !isNaN(parsedCpus) ? parsedCpus : undefined,
|
||||
// The user has already acknowledged any conflicts via the "install anyway" checkbox.
|
||||
force: forceInstall,
|
||||
}
|
||||
const result =
|
||||
isEdit && initial
|
||||
? await api.updateCustomApp({ service_name: initial.service_name, ...common })
|
||||
: await api.createCustomApp(common)
|
||||
|
||||
if (result?.success && result.service_name) {
|
||||
resetForm()
|
||||
onCreated(result.service_name)
|
||||
} else {
|
||||
// Check if it's a port conflict error — show warnings and let user force
|
||||
if (result?.message?.toLowerCase().includes('port') || result?.message?.toLowerCase().includes('conflict')) {
|
||||
showError(result.message)
|
||||
} else {
|
||||
showError(result?.message || 'Failed to create custom app.')
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.message || 'Unexpected error creating custom app.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0
|
||||
const hasBlocks = blocked.length > 0
|
||||
const canSubmit =
|
||||
friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall)
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
title={isEdit ? 'Edit Custom App' : 'Add Custom App'}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleSubmit}
|
||||
confirmVariant='primary'
|
||||
confirmText={isEdit ? 'Save & Recreate' : 'Install'}
|
||||
confirmIcon="IconBrandDocker"
|
||||
confirmLoading={submitting}
|
||||
confirmDisabled={!canSubmit}
|
||||
large
|
||||
>
|
||||
<div className="space-y-6 text-sm">
|
||||
{/* Image + Name */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
name='image'
|
||||
label="Docker Image"
|
||||
placeholder="e.g. nginx:latest"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
name='friendlyName'
|
||||
label="Display Name"
|
||||
placeholder="My App"
|
||||
value={friendlyName}
|
||||
onChange={(e) => setFriendlyName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category + Icon */}
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
<Select
|
||||
name='category'
|
||||
label='Category'
|
||||
helpText='Select the most relevant category for this app. This helps with visual organization and filtering.'
|
||||
value={category}
|
||||
onChange={(newVal) => setCategory(newVal)}
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<Select
|
||||
name='icon'
|
||||
label='Icon'
|
||||
helpText='Pick an icon shown on the app card.'
|
||||
value={icon}
|
||||
onChange={(newVal) => setIcon(newVal)}
|
||||
options={ICON_OPTIONS}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center h-[42px] w-[42px] rounded-md border border-border-default bg-surface-secondary"
|
||||
title="Icon preview"
|
||||
>
|
||||
<DynamicIcon icon={icon as DynamicIconName} className="h-6 w-6 text-desert-green" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Port Mappings */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Port Mappings</label>
|
||||
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addPort}>Add Port</StyledButton>
|
||||
</div>
|
||||
{ports.length === 0 && (
|
||||
<p className="text-xs italic">No port mappings — the app won't be accessible from a browser.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{ports.map((p, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
name={`containerPort${idx}`}
|
||||
label=''
|
||||
type="number"
|
||||
placeholder="Container port"
|
||||
value={p.container}
|
||||
onChange={(e) => updatePort(idx, 'container', e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
<span className="text-xs">→</span>
|
||||
<Input
|
||||
name={`hostPort${idx}`}
|
||||
label=''
|
||||
type="number"
|
||||
placeholder="Host port (8600+)"
|
||||
value={p.host}
|
||||
onChange={(e) => updatePort(idx, 'host', e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePort(idx)}
|
||||
className="hover:text-desert-red transition-colors cursor-pointer"
|
||||
>
|
||||
<IconTrash className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-2">Host ports should be in the 8600+ range. Custom apps get ports starting at {suggestedPort ?? 8600}.</p>
|
||||
{checkingPreflight && (
|
||||
<p className="text-xs mt-1 italic text-text-muted">Checking port availability…</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume Mappings */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Volume Mounts</label>
|
||||
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addVolume}>Add Volume</StyledButton>
|
||||
</div>
|
||||
{volumes.length === 0 && (
|
||||
<p className="text-xs italic">No volumes — data won't persist across restarts.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{volumes.map((v, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Input
|
||||
name={`hostPath${idx}`}
|
||||
label=''
|
||||
type="text"
|
||||
placeholder="Host path (absolute)"
|
||||
value={v.host_path}
|
||||
onChange={(e) => updateVolume(idx, 'host_path', e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
<span className="text-xs">:</span>
|
||||
<Input
|
||||
name={`containerPath${idx}`}
|
||||
label=''
|
||||
type="text"
|
||||
placeholder="Container path"
|
||||
value={v.container_path}
|
||||
onChange={(e) => updateVolume(idx, 'container_path', e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeVolume(idx)}
|
||||
className="hover:text-desert-red transition-colors cursor-pointer"
|
||||
>
|
||||
<IconTrash className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Environment Variables</label>
|
||||
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addEnv}>Add Variable</StyledButton>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{envVars.map((e, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Input
|
||||
name={`envVar${idx}`}
|
||||
label=''
|
||||
placeholder="KEY=value"
|
||||
value={e.value}
|
||||
onChange={(ev) => updateEnv(idx, ev.target.value)}
|
||||
className='w-full font-mono'
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnv(idx)}
|
||||
className="hover:text-desert-red transition-colors cursor-pointer"
|
||||
>
|
||||
<IconTrash className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{envVars.length === 0 && (
|
||||
<p className="text-xs italic">No environment variables provided.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced: resource limits */}
|
||||
<div>
|
||||
<label className="text-sm font-medium">Resource Limits (optional)</label>
|
||||
<div className="grid grid-cols-2 gap-4 mt-1">
|
||||
<Input
|
||||
name='memoryMb'
|
||||
label=''
|
||||
type="number"
|
||||
placeholder="Memory (MB) — default 1024"
|
||||
value={memoryMb}
|
||||
onChange={(e) => setMemoryMb(e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
<Input
|
||||
name='cpus'
|
||||
label=''
|
||||
type="number"
|
||||
placeholder="CPUs — default 1"
|
||||
value={cpus}
|
||||
onChange={(e) => setCpus(e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs mt-1 italic">Caps prevent a runaway container from starving the host. Leave blank to use the defaults (1024 MB / 1 CPU).</p>
|
||||
</div>
|
||||
|
||||
{/* Hard blocks — must be resolved before installing */}
|
||||
{hasBlocks && (
|
||||
<div className="space-y-2">
|
||||
{blocked.map((b, i) => (
|
||||
<Alert key={i} type="error" title="Not allowed" message={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{hasWarnings && (
|
||||
<div className="space-y-2">
|
||||
{portConflicts.map((c) => (
|
||||
<Alert
|
||||
key={c.port}
|
||||
type="warning"
|
||||
title={`Port ${c.port} is already in use`}
|
||||
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
|
||||
/>
|
||||
))}
|
||||
{resourceWarnings.map((w, i) => (
|
||||
<Alert key={i} type="warning" title="Resource warning" message={w} />
|
||||
))}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceInstall}
|
||||
onChange={(e) => setForceInstall(e.target.checked)}
|
||||
className="accent-desert-orange h-4 w-4 rounded"
|
||||
/>
|
||||
<span className="text-text-muted text-xs">I understand — install anyway</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm">
|
||||
Containers are created with <code className="font-mono">--restart=unless-stopped</code>. Data is not persisted unless you add volume mounts above.
|
||||
</p>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import StyledModal from './StyledModal'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface ServiceLogsModalProps {
|
||||
serviceName: string
|
||||
friendlyName: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Shows the tail of a service container's logs with a manual refresh. */
|
||||
export default function ServiceLogsModal({
|
||||
serviceName,
|
||||
friendlyName,
|
||||
open,
|
||||
onClose,
|
||||
}: ServiceLogsModalProps) {
|
||||
const [logs, setLogs] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await api.getServiceLogs(serviceName, 500)
|
||||
setLogs(res?.success ? res.logs || '' : 'Unable to load logs for this container.')
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, serviceName])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
title={`Logs — ${friendlyName}`}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
cancelText="Close"
|
||||
onConfirm={load}
|
||||
confirmText="Refresh"
|
||||
confirmIcon="IconRefresh"
|
||||
confirmVariant="outline"
|
||||
confirmLoading={loading}
|
||||
large
|
||||
>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all max-h-[60vh] overflow-auto bg-surface-secondary rounded-md p-3 text-text-primary text-left">
|
||||
{logs || (loading ? 'Loading…' : 'No log output.')}
|
||||
</pre>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import StyledModal from './StyledModal'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface Stats {
|
||||
cpuPercent: number
|
||||
memUsageBytes: number
|
||||
memLimitBytes: number
|
||||
memPercent: number
|
||||
}
|
||||
|
||||
interface ServiceStatsModalProps {
|
||||
serviceName: string
|
||||
friendlyName: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function Bar({ percent, label, value }: { percent: number; label: string; value: string }) {
|
||||
const clamped = Math.min(100, Math.max(0, percent))
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="font-medium text-text-primary">{label}</span>
|
||||
<span className="text-text-muted font-mono">{value}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
clamped > 90 ? 'bg-desert-red' : clamped > 70 ? 'bg-desert-orange' : 'bg-desert-green'
|
||||
}`}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Polls and displays live CPU/memory usage for a running service container. */
|
||||
export default function ServiceStatsModal({
|
||||
serviceName,
|
||||
friendlyName,
|
||||
open,
|
||||
onClose,
|
||||
}: ServiceStatsModalProps) {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [running, setRunning] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
const res = await api.getServiceStats(serviceName)
|
||||
if (cancelled || !res) return
|
||||
setRunning(res.running)
|
||||
setStats(res.stats)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
poll()
|
||||
const id = setInterval(poll, 2000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [open, serviceName])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
title={`Stats — ${friendlyName}`}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
cancelText="Close"
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
{!running ? (
|
||||
<p className="text-text-muted text-center py-6">
|
||||
This app is not running. Start it to see live resource usage.
|
||||
</p>
|
||||
) : !stats ? (
|
||||
<p className="text-text-muted text-center py-6">
|
||||
{loading ? 'Loading…' : 'No stats available.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Bar label="CPU" percent={stats.cpuPercent} value={`${stats.cpuPercent.toFixed(1)}%`} />
|
||||
<Bar
|
||||
label="Memory"
|
||||
percent={stats.memPercent}
|
||||
value={`${formatBytes(stats.memUsageBytes)} / ${formatBytes(stats.memLimitBytes)} (${stats.memPercent.toFixed(1)}%)`}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">Updates every 2 seconds.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export type StyledModalProps = {
|
|||
confirmIcon?: StyledButtonProps['icon']
|
||||
confirmVariant?: StyledButtonProps['variant']
|
||||
confirmLoading?: boolean
|
||||
confirmDisabled?: boolean
|
||||
open: boolean
|
||||
onCancel?: () => void
|
||||
onConfirm?: () => void
|
||||
|
|
@ -33,6 +34,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
|||
confirmIcon,
|
||||
confirmVariant = 'action',
|
||||
confirmLoading = false,
|
||||
confirmDisabled = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
icon,
|
||||
|
|
@ -94,6 +96,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
|||
}}
|
||||
icon={confirmIcon}
|
||||
loading={confirmLoading}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmText}
|
||||
</StyledButton>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import classNames from "classnames";
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
export interface SelectOption<T = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps<T = string> {
|
||||
name: string;
|
||||
label: string;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: SelectOption<T>[];
|
||||
helpText?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
selectClassName?: string;
|
||||
containerClassName?: string;
|
||||
error?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Select = <T,>({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
helpText,
|
||||
placeholder,
|
||||
className,
|
||||
labelClassName,
|
||||
selectClassName,
|
||||
containerClassName,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
}: SelectProps<T>) => {
|
||||
const selectedOption = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<div className={classNames(className)}>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
|
||||
>
|
||||
{label}{required ? "*" : ""}
|
||||
</label>
|
||||
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
|
||||
<div className={classNames("mt-1.5", containerClassName)}>
|
||||
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
id={name}
|
||||
className={classNames(
|
||||
"flex items-center w-full rounded-md bg-surface-primary px-3 py-2 text-base border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 text-left",
|
||||
selectedOption ? "text-text-primary" : "text-text-muted",
|
||||
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : "",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||
selectClassName
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{selectedOption ? selectedOption.label : (placeholder ?? label)}
|
||||
</span>
|
||||
<IconChevronDown className="w-4 h-4 text-text-muted ml-2 shrink-0 transition-transform duration-150 group-data-[open]:rotate-180 data-[open]:rotate-180 ui-open:rotate-180" />
|
||||
</ListboxButton>
|
||||
|
||||
<ListboxOptions
|
||||
transition
|
||||
anchor="bottom start"
|
||||
className={classNames(
|
||||
"z-50 w-[var(--button-width)] rounded-md bg-surface-primary border border-border-default shadow-lg max-h-60 overflow-auto",
|
||||
"transition duration-100 ease-out data-[closed]:opacity-0 data-[closed]:scale-95",
|
||||
"mt-1 focus:outline-none"
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<ListboxOption
|
||||
key={index}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={classNames(
|
||||
"px-3 py-2 text-sm text-text-primary select-none",
|
||||
option.disabled
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "cursor-pointer data-[focus]:bg-surface-secondary data-[selected]:font-medium data-[selected]:text-primary"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
||||
|
|
@ -23,7 +23,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||
|
||||
const navigation = [
|
||||
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
|
||||
{ name: 'Apps', href: '/settings/apps', icon: IconTerminal2, current: false },
|
||||
{ name: 'Supply Depot', href: '/supply-depot', icon: IconTerminal2, current: false },
|
||||
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
|
||||
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
|
||||
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },
|
||||
|
|
|
|||
|
|
@ -969,6 +969,151 @@ class API {
|
|||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async preflightCheck(service_name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{
|
||||
portConflicts: Array<{ port: number; usedBy: string }>
|
||||
resourceWarnings: string[]
|
||||
}>('/system/services/preflight', { params: { service_name } })
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async suggestCustomPort() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ port: number }>('/system/services/suggest-port')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async preflightCustomApp(payload: {
|
||||
image?: string
|
||||
ports?: number[]
|
||||
volumes?: Array<{ host_path: string; container_path: string }>
|
||||
exclude_service?: string
|
||||
}) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{
|
||||
portConflicts: Array<{ port: number; usedBy: string }>
|
||||
resourceWarnings: string[]
|
||||
blocked: string[]
|
||||
}>('/system/services/preflight-custom', payload)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async createCustomApp(payload: {
|
||||
friendly_name: string
|
||||
image: string
|
||||
ports?: Array<{ container: number; host: number }>
|
||||
volumes?: Array<{ host_path: string; container_path: string }>
|
||||
env?: string[]
|
||||
category?: string
|
||||
icon?: string
|
||||
memory_mb?: number
|
||||
cpus?: number
|
||||
force?: boolean
|
||||
}) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{
|
||||
success: boolean
|
||||
message: string
|
||||
service_name: string
|
||||
}>('/system/services/custom', payload)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async deleteCustomApp(service_name: string, remove_image = false) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.delete<{ success: boolean; message: string }>(
|
||||
'/system/services/custom',
|
||||
{ data: { service_name, remove_image } }
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async updateCustomAppImage(service_name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||
'/system/services/custom/update',
|
||||
{ service_name }
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getServiceLogs(service_name: string, tail = 200) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ success: boolean; logs: string }>(
|
||||
`/system/services/${service_name}/logs`,
|
||||
{ params: { tail } }
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getServiceStats(service_name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{
|
||||
success: boolean
|
||||
running: boolean
|
||||
stats: {
|
||||
cpuPercent: number
|
||||
memUsageBytes: number
|
||||
memLimitBytes: number
|
||||
memPercent: number
|
||||
} | null
|
||||
}>(`/system/services/${service_name}/stats`)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getCustomApp(service_name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{
|
||||
success: boolean
|
||||
app: {
|
||||
service_name: string
|
||||
friendly_name: string | null
|
||||
image: string
|
||||
category: string
|
||||
icon: string
|
||||
ports: Array<{ container: number; host: number }>
|
||||
volumes: Array<{ host_path: string; container_path: string }>
|
||||
env: string[]
|
||||
memory_mb?: number
|
||||
cpus?: number
|
||||
}
|
||||
}>(`/system/services/custom/${service_name}`)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async updateCustomApp(payload: {
|
||||
service_name: string
|
||||
friendly_name: string
|
||||
image: string
|
||||
ports?: Array<{ container: number; host: number }>
|
||||
volumes?: Array<{ host_path: string; container_path: string }>
|
||||
env?: string[]
|
||||
category?: string
|
||||
icon?: string
|
||||
memory_mb?: number
|
||||
cpus?: number
|
||||
force?: boolean
|
||||
}) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.put<{
|
||||
success: boolean
|
||||
message: string
|
||||
service_name: string
|
||||
}>('/system/services/custom', payload)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
export default new API()
|
||||
|
|
|
|||
|
|
@ -1,35 +1,60 @@
|
|||
import {
|
||||
IconArrowUp,
|
||||
IconBook,
|
||||
IconBooks,
|
||||
IconBox,
|
||||
IconBrain,
|
||||
IconBrandDocker,
|
||||
IconBroadcast,
|
||||
IconChefHat,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconCircleCheck,
|
||||
IconCloudDownload,
|
||||
IconCloudUpload,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFileDescription,
|
||||
IconFolderOpen,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconLogs,
|
||||
IconMap,
|
||||
IconMenu2,
|
||||
IconMoon,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPencil,
|
||||
IconPlant,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRefreshAlert,
|
||||
IconRobot,
|
||||
IconSchool,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconStethoscope,
|
||||
IconSun,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconWand,
|
||||
IconWifi,
|
||||
IconWorld,
|
||||
IconX,
|
||||
IconAlertTriangle,
|
||||
IconXboxX,
|
||||
IconCircleCheck,
|
||||
IconInfoCircle,
|
||||
IconBug,
|
||||
IconCopy,
|
||||
IconLibrary,
|
||||
|
|
@ -37,14 +62,6 @@ import {
|
|||
IconMenu2,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconStethoscope,
|
||||
IconShieldCheck,
|
||||
IconTool,
|
||||
IconPlant,
|
||||
IconCode,
|
||||
IconMap,
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
/**
|
||||
|
|
@ -59,11 +76,16 @@ export const icons = {
|
|||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBook,
|
||||
IconBooks,
|
||||
IconBox,
|
||||
IconBrain,
|
||||
IconBrandDocker,
|
||||
IconBroadcast,
|
||||
IconBug,
|
||||
IconChefHat,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconCircleCheck,
|
||||
|
|
@ -74,6 +96,9 @@ export const icons = {
|
|||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFileDescription,
|
||||
IconFolderOpen,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconLibrary,
|
||||
|
|
@ -81,23 +106,29 @@ export const icons = {
|
|||
IconMap,
|
||||
IconMenu2,
|
||||
IconMoon,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPencil,
|
||||
IconPlant,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRefreshAlert,
|
||||
IconRobot,
|
||||
IconSchool,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconStethoscope,
|
||||
IconSun,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconWand,
|
||||
IconWifi,
|
||||
IconWorld,
|
||||
IconX,
|
||||
IconXboxX
|
||||
|
|
|
|||
|
|
@ -42,10 +42,10 @@ const SYSTEM_ITEMS = [
|
|||
poweredBy: null,
|
||||
},
|
||||
{
|
||||
label: 'Install Apps',
|
||||
to: '/settings/apps',
|
||||
label: 'Supply Depot',
|
||||
to: '/supply-depot',
|
||||
target: '',
|
||||
description: 'Not seeing your favorite app? Install it here!',
|
||||
description: 'Browse and install curated apps, or add your own Docker container',
|
||||
icon: <IconPlus size={48} />,
|
||||
installed: true,
|
||||
displayOrder: 51,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,812 @@
|
|||
import { Head } from '@inertiajs/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBox,
|
||||
IconBrandDocker,
|
||||
IconChartBar,
|
||||
IconCloudDownload,
|
||||
IconFileText,
|
||||
IconPackage,
|
||||
IconPencil,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react'
|
||||
import AppLayout from '~/layouts/AppLayout'
|
||||
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import StyledModal from '~/components/StyledModal'
|
||||
import InstallActivityFeed from '~/components/InstallActivityFeed'
|
||||
import LoadingSpinner from '~/components/LoadingSpinner'
|
||||
import Alert from '~/components/Alert'
|
||||
import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
|
||||
import ServiceLogsModal from '~/components/ServiceLogsModal'
|
||||
import ServiceStatsModal from '~/components/ServiceStatsModal'
|
||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||
import useErrorNotification from '~/hooks/useErrorNotification'
|
||||
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
|
||||
import { ServiceSlim } from '../../types/services'
|
||||
import { getServiceLink } from '~/lib/navigation'
|
||||
import api from '~/lib/api'
|
||||
import { toTitleCase } from '../../app/utils/misc'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{ id: 'productivity', label: 'Productivity' },
|
||||
{ id: 'media', label: 'Media' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
{ id: 'networking', label: 'Networking' },
|
||||
{ id: 'utility', label: 'Utility' },
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'education', label: 'Education' },
|
||||
{ id: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
productivity: 'border border-desert-green-light bg-desert-green-lighter text-desert-green-dark',
|
||||
media: 'border border-desert-tan-light bg-desert-tan-lighter text-desert-tan-dark',
|
||||
security: 'border border-desert-red-light bg-desert-red-lighter text-desert-red-dark',
|
||||
networking: 'border border-desert-stone-light bg-desert-stone-lighter text-desert-stone-dark',
|
||||
utility: 'border border-desert-olive-light bg-desert-olive-lighter text-desert-olive-dark',
|
||||
ai: 'border border-desert-green bg-desert-green-light text-desert-green-darker',
|
||||
education: 'border border-desert-orange-light bg-desert-orange-lighter text-desert-orange-dark',
|
||||
custom: 'border border-border-subtle bg-surface-secondary text-text-secondary',
|
||||
}
|
||||
|
||||
type Modal =
|
||||
| { type: 'install'; service: ServiceSlim }
|
||||
| { type: 'start'; service: ServiceSlim }
|
||||
| { type: 'stop'; service: ServiceSlim }
|
||||
| { type: 'restart'; service: ServiceSlim }
|
||||
| { type: 'reinstall'; service: ServiceSlim }
|
||||
| { type: 'delete'; service: ServiceSlim }
|
||||
| { type: 'logs'; service: ServiceSlim }
|
||||
| { type: 'stats'; service: ServiceSlim }
|
||||
| null
|
||||
|
||||
export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) {
|
||||
const { showError } = useErrorNotification()
|
||||
const installActivity = useServiceInstallationActivity()
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [modal, setModal] = useState<Modal>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
const [customAppOpen, setCustomAppOpen] = useState(false)
|
||||
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
|
||||
const [removeImage, setRemoveImage] = useState(false)
|
||||
|
||||
// Preflight state — scoped to the current install modal
|
||||
const [preflight, setPreflight] = useState<{
|
||||
portConflicts: Array<{ port: number; usedBy: string }>
|
||||
resourceWarnings: string[]
|
||||
} | null>(null)
|
||||
const [preflightLoading, setPreflightLoading] = useState(false)
|
||||
const [forceInstall, setForceInstall] = useState(false)
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-reload when installation completes
|
||||
useEffect(() => {
|
||||
if (!installActivity.length) return
|
||||
if (installActivity.some((a) => a.type === 'completed' || a.type === 'update-complete')) {
|
||||
setTimeout(() => window.location.reload(), 3000)
|
||||
}
|
||||
}, [installActivity])
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
// Run preflight when install modal opens
|
||||
useEffect(() => {
|
||||
if (modal?.type !== 'install') {
|
||||
setPreflight(null)
|
||||
setForceInstall(false)
|
||||
return
|
||||
}
|
||||
setPreflightLoading(true)
|
||||
api
|
||||
.preflightCheck(modal.service.service_name)
|
||||
.then((res) => {
|
||||
if (res) setPreflight(res)
|
||||
})
|
||||
.catch(() => {}) // non-fatal; proceed without warnings
|
||||
.finally(() => setPreflightLoading(false))
|
||||
}, [modal])
|
||||
|
||||
// ── Filtering ─────────────────────────────────────────────────────────────
|
||||
const filteredServices = props.system.services.filter((s) => {
|
||||
if (activeCategory === 'installed' && !s.installed) return false
|
||||
if (activeCategory !== 'all' && activeCategory !== 'installed') {
|
||||
if (s.category !== activeCategory) return false
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
s.friendly_name?.toLowerCase().includes(q) ||
|
||||
s.description?.toLowerCase().includes(q) ||
|
||||
s.powered_by?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const installedServices = filteredServices.filter((s) => s.installed)
|
||||
const availableServices = filteredServices.filter((s) => !s.installed)
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
async function handleInstall(service: ServiceSlim) {
|
||||
const hasWarnings =
|
||||
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
|
||||
|
||||
if (hasWarnings && !forceInstall) return
|
||||
|
||||
setLoading(true)
|
||||
setModal(null)
|
||||
const result = await api.installService(service.service_name)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to start installation.')
|
||||
}
|
||||
|
||||
async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') {
|
||||
setModal(null)
|
||||
setLoading(true)
|
||||
const result = await api.affectService(service.service_name, action)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || `Failed to ${action} service.`)
|
||||
else setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
|
||||
async function handleForceReinstall(service: ServiceSlim) {
|
||||
setModal(null)
|
||||
setLoading(true)
|
||||
const result = await api.forceReinstallService(service.service_name)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to start reinstall.')
|
||||
}
|
||||
|
||||
async function handleDelete(service: ServiceSlim) {
|
||||
setModal(null)
|
||||
setLoading(true)
|
||||
const result = await api.deleteCustomApp(service.service_name, removeImage)
|
||||
setRemoveImage(false)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to delete app.')
|
||||
else setTimeout(() => window.location.reload(), 1000)
|
||||
}
|
||||
|
||||
async function handleUpdate(service: ServiceSlim) {
|
||||
setOpenDropdown(null)
|
||||
setLoading(true)
|
||||
const result = await api.updateCustomAppImage(service.service_name)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to update app.')
|
||||
else setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
|
||||
function handleCustomAppCreated() {
|
||||
setCustomAppOpen(false)
|
||||
// Page will reload when installation completes via broadcast
|
||||
}
|
||||
|
||||
async function handleEdit(service: ServiceSlim) {
|
||||
setOpenDropdown(null)
|
||||
setLoading(true)
|
||||
const res = await api.getCustomApp(service.service_name)
|
||||
setLoading(false)
|
||||
if (res?.success && res.app) {
|
||||
setEditApp(res.app)
|
||||
} else {
|
||||
showError('Could not load this app for editing.')
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdited() {
|
||||
setEditApp(null)
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
|
||||
// ── Install modal helpers ─────────────────────────────────────────────────
|
||||
const hasPreflightWarnings =
|
||||
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Supply Depot" />
|
||||
|
||||
{loading && <LoadingSpinner fullscreen text="Working..." />}
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
|
||||
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
|
||||
{/* Green header band */}
|
||||
<div className="relative bg-desert-green px-6 py-5 overflow-hidden">
|
||||
{/* Diagonal line pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgba(255, 255, 255, 0.1) 10px,
|
||||
rgba(255, 255, 255, 0.1) 20px
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
|
||||
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white uppercase tracking-wide leading-tight">
|
||||
Supply Depot
|
||||
</h1>
|
||||
<p className="text-sm text-white/70 mt-1 max-w-xl">
|
||||
Browse and install curated apps, or add your own custom apps by providing a Docker
|
||||
image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls body */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Activity feed (shown only while installing) */}
|
||||
{installActivity.length > 0 && (
|
||||
<InstallActivityFeed activity={installActivity} withHeader />
|
||||
)}
|
||||
|
||||
{/* Search + Add Custom App */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-md bg-surface-secondary border border-desert-stone-lighter text-text-primary text-sm focus:outline-none focus:ring-1 focus:ring-desert-green placeholder:text-text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
<StyledButton
|
||||
icon="IconBrandDocker"
|
||||
variant="outline"
|
||||
onClick={() => setCustomAppOpen(true)}
|
||||
>
|
||||
Add Custom App
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-desert-green text-white border-desert-green'
|
||||
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom accent bar */}
|
||||
<div className="h-1 bg-desert-green" />
|
||||
</div>
|
||||
|
||||
{/* App cards */}
|
||||
{filteredServices.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<IconPackage className="mx-auto mb-3 opacity-40 text-desert-stone-light" size={48} />
|
||||
<p className="text-text-muted">No apps match your filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
{installedServices.length > 0 && (
|
||||
<section>
|
||||
<StyledSectionHeader title={`Installed (${installedServices.length})`} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{installedServices.map((service) => (
|
||||
<AppCard
|
||||
key={service.service_name}
|
||||
service={service}
|
||||
openDropdown={openDropdown}
|
||||
dropdownRef={dropdownRef}
|
||||
onOpenDropdown={setOpenDropdown}
|
||||
onInstall={() => setModal({ type: 'install', service })}
|
||||
onStart={() => setModal({ type: 'start', service })}
|
||||
onStop={() => setModal({ type: 'stop', service })}
|
||||
onRestart={() => setModal({ type: 'restart', service })}
|
||||
onReinstall={() => setModal({ type: 'reinstall', service })}
|
||||
onDelete={() => setModal({ type: 'delete', service })}
|
||||
onLogs={() => setModal({ type: 'logs', service })}
|
||||
onStats={() => setModal({ type: 'stats', service })}
|
||||
onEdit={() => handleEdit(service)}
|
||||
onUpdate={() => handleUpdate(service)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{availableServices.length > 0 && (
|
||||
<section>
|
||||
<StyledSectionHeader title={`Available (${availableServices.length})`} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{availableServices.map((service) => (
|
||||
<AppCard
|
||||
key={service.service_name}
|
||||
service={service}
|
||||
openDropdown={openDropdown}
|
||||
dropdownRef={dropdownRef}
|
||||
onOpenDropdown={setOpenDropdown}
|
||||
onInstall={() => setModal({ type: 'install', service })}
|
||||
onStart={() => setModal({ type: 'start', service })}
|
||||
onStop={() => setModal({ type: 'stop', service })}
|
||||
onRestart={() => setModal({ type: 'restart', service })}
|
||||
onReinstall={() => setModal({ type: 'reinstall', service })}
|
||||
onDelete={() => setModal({ type: 'delete', service })}
|
||||
onLogs={() => setModal({ type: 'logs', service })}
|
||||
onStats={() => setModal({ type: 'stats', service })}
|
||||
onEdit={() => handleEdit(service)}
|
||||
onUpdate={() => handleUpdate(service)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
||||
|
||||
{/* Install modal */}
|
||||
{modal?.type === 'install' && (
|
||||
<StyledModal
|
||||
title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={() => handleInstall(modal.service)}
|
||||
confirmText="Install"
|
||||
confirmIcon="IconDownload"
|
||||
confirmVariant="primary"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-text-muted">
|
||||
<p>
|
||||
This will download and start <strong className="text-text-primary">{modal.service.friendly_name}</strong>
|
||||
{modal.service.ui_location && (
|
||||
<> on port <strong className="text-text-primary">{modal.service.ui_location}</strong></>
|
||||
)}.
|
||||
</p>
|
||||
{modal.service.powered_by && (
|
||||
<p className="text-xs">Powered by {modal.service.powered_by}</p>
|
||||
)}
|
||||
|
||||
{preflightLoading && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted py-2">
|
||||
<span className="animate-spin inline-block w-3 h-3 border border-desert-green border-t-transparent rounded-full" />
|
||||
Checking for conflicts…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!preflightLoading && preflight && hasPreflightWarnings && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{preflight.portConflicts.map((c) => (
|
||||
<Alert
|
||||
key={c.port}
|
||||
type="warning"
|
||||
title={`Port ${c.port} already in use`}
|
||||
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
|
||||
/>
|
||||
))}
|
||||
{preflight.resourceWarnings.map((w, i) => (
|
||||
<Alert key={i} type="warning" title="Resource warning" message={w} />
|
||||
))}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceInstall}
|
||||
onChange={(e) => setForceInstall(e.target.checked)}
|
||||
className="accent-desert-orange h-4 w-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">I understand — install anyway</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Start modal */}
|
||||
{modal?.type === 'start' && (
|
||||
<StyledModal
|
||||
title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={() => handleAffect(modal.service, 'start')}
|
||||
confirmText="Start"
|
||||
confirmIcon="IconPlayerPlay"
|
||||
confirmVariant="primary"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<p className="text-sm text-text-muted">This will start the container.</p>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Stop modal */}
|
||||
{modal?.type === 'stop' && (
|
||||
<StyledModal
|
||||
title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={() => handleAffect(modal.service, 'stop')}
|
||||
confirmText="Stop"
|
||||
confirmIcon="IconPlayerStop"
|
||||
confirmVariant="action"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<p className="text-sm text-text-muted">The container will be stopped. Your data is preserved.</p>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Restart modal */}
|
||||
{modal?.type === 'restart' && (
|
||||
<StyledModal
|
||||
title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={() => handleAffect(modal.service, 'restart')}
|
||||
confirmText="Restart"
|
||||
confirmIcon="IconRefresh"
|
||||
confirmVariant="action"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<p className="text-sm text-text-muted">The container will be briefly stopped and restarted.</p>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Force reinstall modal */}
|
||||
{modal?.type === 'reinstall' && (
|
||||
<StyledModal
|
||||
title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={() => handleForceReinstall(modal.service)}
|
||||
confirmText="Wipe & Reinstall"
|
||||
confirmIcon="IconRefresh"
|
||||
confirmVariant="danger"
|
||||
confirmLoading={loading}
|
||||
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
|
||||
>
|
||||
<div className="space-y-2 text-sm text-text-muted">
|
||||
<p className="font-semibold text-desert-red">This will delete all app data and cannot be undone.</p>
|
||||
<p>The container and its associated volumes will be removed, then a fresh installation will begin.</p>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Delete custom app modal */}
|
||||
{modal?.type === 'delete' && (
|
||||
<StyledModal
|
||||
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => {
|
||||
setRemoveImage(false)
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleDelete(modal.service)}
|
||||
confirmText="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>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Logs modal */}
|
||||
{modal?.type === 'logs' && (
|
||||
<ServiceLogsModal
|
||||
serviceName={modal.service.service_name}
|
||||
friendlyName={modal.service.friendly_name ?? modal.service.service_name}
|
||||
open
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats modal */}
|
||||
{modal?.type === 'stats' && (
|
||||
<ServiceStatsModal
|
||||
serviceName={modal.service.service_name}
|
||||
friendlyName={modal.service.friendly_name ?? modal.service.service_name}
|
||||
open
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom app creation modal */}
|
||||
<CustomAppModal
|
||||
open={customAppOpen}
|
||||
onClose={() => setCustomAppOpen(false)}
|
||||
onCreated={handleCustomAppCreated}
|
||||
showError={showError}
|
||||
/>
|
||||
|
||||
{/* Custom app edit modal */}
|
||||
<CustomAppModal
|
||||
open={!!editApp}
|
||||
mode="edit"
|
||||
initial={editApp}
|
||||
onClose={() => setEditApp(null)}
|
||||
onCreated={handleEdited}
|
||||
showError={showError}
|
||||
/>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ── App Card component ────────────────────────────────────────────────────────
|
||||
|
||||
interface AppCardProps {
|
||||
service: ServiceSlim
|
||||
openDropdown: string | null
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>
|
||||
onOpenDropdown: (name: string | null) => void
|
||||
onInstall: () => void
|
||||
onStart: () => void
|
||||
onStop: () => void
|
||||
onRestart: () => void
|
||||
onReinstall: () => void
|
||||
onDelete: () => void
|
||||
onLogs: () => void
|
||||
onStats: () => void
|
||||
onEdit: () => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
service,
|
||||
openDropdown,
|
||||
dropdownRef,
|
||||
onOpenDropdown,
|
||||
onInstall,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onReinstall,
|
||||
onDelete,
|
||||
onLogs,
|
||||
onStats,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
}: AppCardProps) {
|
||||
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 isDropdownOpen = openDropdown === service.service_name
|
||||
|
||||
function toggleDropdown(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
onOpenDropdown(isDropdownOpen ? null : service.service_name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 overflow-hidden ${
|
||||
service.installed
|
||||
? 'border-desert-stone-light'
|
||||
: 'border-desert-stone-lighter hover:border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
{/* Installed accent spine */}
|
||||
{service.installed ? (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-desert-green" />
|
||||
) : null}
|
||||
|
||||
{/* Top row: icon + status badge */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-desert-green-lighter border border-desert-green-light flex items-center justify-center flex-shrink-0">
|
||||
{service.icon ? (
|
||||
<DynamicIcon icon={service.icon as DynamicIconName} className="h-7 w-7 text-desert-green" />
|
||||
) : (
|
||||
<IconBrandDocker className="h-7 w-7 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-text-primary text-sm leading-tight truncate">
|
||||
{service.friendly_name ?? service.service_name}
|
||||
</p>
|
||||
{service.powered_by && (
|
||||
<p className="text-xs text-text-muted truncate">{service.powered_by}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
{service.installation_status === 'installing' ? (
|
||||
<span className="flex items-center gap-1 text-xs text-desert-orange">
|
||||
<span className="animate-spin inline-block w-3 h-3 border border-desert-orange border-t-transparent rounded-full" />
|
||||
Installing
|
||||
</span>
|
||||
) : isRunning ? (
|
||||
<span className="flex items-center gap-1 text-xs text-desert-green">
|
||||
<span className="h-2 w-2 rounded-full bg-desert-green" />
|
||||
Running
|
||||
</span>
|
||||
) : isStopped ? (
|
||||
<span className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span className="h-2 w-2 rounded-full bg-text-muted" />
|
||||
Stopped
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{service.description && (
|
||||
<p className="text-xs text-text-muted leading-relaxed mb-3 flex-1 line-clamp-2">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata row: category badge + port pill */}
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
{service.category && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${catColor}`}>
|
||||
{toTitleCase(service.category)}
|
||||
</span>
|
||||
)}
|
||||
{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
|
||||
</span>
|
||||
) : null}
|
||||
{service.ui_location && !service.ui_location.startsWith('/') && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
|
||||
:{service.ui_location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!service.installed && service.installation_status !== 'installing' && (
|
||||
<StyledButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="IconDownload"
|
||||
onClick={onInstall}
|
||||
fullWidth
|
||||
>
|
||||
Install
|
||||
</StyledButton>
|
||||
)}
|
||||
|
||||
{service.installed ? (
|
||||
<>
|
||||
{/* Open button */}
|
||||
{service.ui_location && (
|
||||
<a
|
||||
href={getServiceLink(service.ui_location)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1"
|
||||
>
|
||||
<StyledButton size="sm" variant="primary" icon="IconExternalLink" fullWidth>
|
||||
Open
|
||||
</StyledButton>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Manage dropdown */}
|
||||
<div className="relative" ref={isDropdownOpen ? dropdownRef : null}>
|
||||
<StyledButton size="sm" variant="outline" onClick={toggleDropdown} icon="IconChevronDown">
|
||||
Manage
|
||||
</StyledButton>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 bottom-full mb-1 w-44 bg-surface-primary border border-surface-secondary rounded-lg shadow-xl z-20 overflow-hidden">
|
||||
{isStopped && (
|
||||
<DropdownItem icon={<IconPlayerPlay className="h-4 w-4" />} label="Start" onClick={onStart} />
|
||||
)}
|
||||
{isRunning && (
|
||||
<DropdownItem icon={<IconPlayerStop className="h-4 w-4" />} label="Stop" onClick={onStop} />
|
||||
)}
|
||||
<DropdownItem icon={<IconRefresh className="h-4 w-4" />} label="Restart" onClick={onRestart} />
|
||||
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
|
||||
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
|
||||
{service.is_custom ? (
|
||||
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
|
||||
) : null}
|
||||
{service.is_custom ? (
|
||||
<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_custom ? (
|
||||
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
|
||||
): null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{service.installation_status === 'installing' && (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-text-muted gap-1 py-1">
|
||||
<span className="animate-spin inline-block w-3 h-3 border border-desert-green border-t-transparent rounded-full" />
|
||||
In progress…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${
|
||||
danger
|
||||
? 'text-desert-red hover:bg-desert-red/10'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import MapsController from '#controllers/maps_controller'
|
|||
import OllamaController from '#controllers/ollama_controller'
|
||||
import RagController from '#controllers/rag_controller'
|
||||
import SettingsController from '#controllers/settings_controller'
|
||||
import SupplyDepotController from '#controllers/supply_depot_controller'
|
||||
import SystemController from '#controllers/system_controller'
|
||||
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
||||
import ZimController from '#controllers/zim_controller'
|
||||
|
|
@ -29,6 +30,7 @@ router.get('/home', [HomeController, 'home'])
|
|||
router.on('/about').renderInertia('about')
|
||||
router.get('/chat', [ChatsController, 'inertia'])
|
||||
router.get('/maps', [MapsController, 'index'])
|
||||
router.get('/supply-depot', [SupplyDepotController, 'index'])
|
||||
router.on('/knowledge-base').redirectToPath('/chat?knowledge_base=true') // redirect for legacy knowledge-base links
|
||||
|
||||
router.get('/easy-setup', [EasySetupController, 'index'])
|
||||
|
|
@ -46,7 +48,7 @@ router
|
|||
router
|
||||
.group(() => {
|
||||
router.get('/system', [SettingsController, 'system'])
|
||||
router.get('/apps', [SettingsController, 'apps'])
|
||||
router.on('/apps').redirectToPath('/supply-depot') // superseded by Supply Depot
|
||||
router.get('/legal', [SettingsController, 'legal'])
|
||||
router.get('/maps', [SettingsController, 'maps'])
|
||||
router.get('/models', [SettingsController, 'models'])
|
||||
|
|
@ -168,6 +170,16 @@ router
|
|||
router.post('/services/install', [SystemController, 'installService'])
|
||||
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
|
||||
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
||||
router.get('/services/preflight', [SystemController, 'preflightCheck'])
|
||||
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])
|
||||
router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp'])
|
||||
router.post('/services/custom', [SystemController, 'createCustomApp'])
|
||||
router.put('/services/custom', [SystemController, 'updateCustomApp'])
|
||||
router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest'])
|
||||
router.delete('/services/custom', [SystemController, 'deleteCustomApp'])
|
||||
router.get('/services/custom/:name', [SystemController, 'getCustomApp'])
|
||||
router.get('/services/:name/logs', [SystemController, 'getServiceLogs'])
|
||||
router.get('/services/:name/stats', [SystemController, 'getServiceStats'])
|
||||
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])
|
||||
router.post('/services/update', [SystemController, 'updateService'])
|
||||
router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import * as assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { evaluateBindMounts, evaluateImageReference } from '../../app/services/custom_app_guard.js'
|
||||
|
||||
// ── Bind mounts ──────────────────────────────────────────────────────────────
|
||||
// These assume the default storage root (/opt/project-nomad/storage), i.e. NOMAD_STORAGE_PATH unset.
|
||||
|
||||
test('evaluateBindMounts hard-blocks the Docker socket', () => {
|
||||
const { blocked } = evaluateBindMounts([
|
||||
{ host_path: '/var/run/docker.sock', container_path: '/var/run/docker.sock' },
|
||||
])
|
||||
assert.equal(blocked.length, 1)
|
||||
})
|
||||
|
||||
test('evaluateBindMounts hard-blocks core system directories', () => {
|
||||
for (const dir of ['/etc', '/proc/foo', '/sys', '/boot', '/dev/sda']) {
|
||||
const { blocked } = evaluateBindMounts([{ host_path: dir, container_path: '/data' }])
|
||||
assert.equal(blocked.length, 1, `${dir} should be blocked`)
|
||||
}
|
||||
})
|
||||
|
||||
test('evaluateBindMounts hard-blocks mounting at or above the install tree', () => {
|
||||
for (const dir of ['/', '/opt', '/opt/project-nomad']) {
|
||||
const { blocked } = evaluateBindMounts([{ host_path: dir, container_path: '/data' }])
|
||||
assert.equal(blocked.length, 1, `${dir} should be blocked`)
|
||||
}
|
||||
})
|
||||
|
||||
test('evaluateBindMounts allows paths under the storage root without warning', () => {
|
||||
const { blocked, warnings } = evaluateBindMounts([
|
||||
{ host_path: '/opt/project-nomad/storage/myapp', container_path: '/data' },
|
||||
])
|
||||
assert.equal(blocked.length, 0)
|
||||
assert.equal(warnings.length, 0)
|
||||
})
|
||||
|
||||
test('evaluateBindMounts warns (but allows) paths outside the storage root', () => {
|
||||
const { blocked, warnings } = evaluateBindMounts([
|
||||
{ host_path: '/home/user/data', container_path: '/data' },
|
||||
])
|
||||
assert.equal(blocked.length, 0)
|
||||
assert.equal(warnings.length, 1)
|
||||
})
|
||||
|
||||
test('evaluateBindMounts resolves .. before matching (no traversal escape)', () => {
|
||||
// Normalizes to /etc, which must still be blocked despite the dressing-up.
|
||||
const { blocked } = evaluateBindMounts([
|
||||
{ host_path: '/srv/../etc/shadow', container_path: '/data' },
|
||||
])
|
||||
assert.equal(blocked.length, 1)
|
||||
})
|
||||
|
||||
test('evaluateBindMounts requires absolute container paths', () => {
|
||||
const { blocked } = evaluateBindMounts([
|
||||
{ host_path: '/opt/project-nomad/storage/x', container_path: 'relative' },
|
||||
])
|
||||
assert.equal(blocked.length, 1)
|
||||
})
|
||||
|
||||
test('evaluateBindMounts hard-blocks a colon in the host path', () => {
|
||||
// Without this, Docker would re-split "/etc:foo" on the colon and mount /etc — bypassing the
|
||||
// system-directory block, which only matches the string as a whole path.
|
||||
const { blocked } = evaluateBindMounts([
|
||||
{ host_path: '/etc:foo', container_path: '/data' },
|
||||
])
|
||||
assert.equal(blocked.length, 1)
|
||||
})
|
||||
|
||||
test('evaluateBindMounts hard-blocks a colon in the container path', () => {
|
||||
const { blocked } = evaluateBindMounts([
|
||||
{ host_path: '/opt/project-nomad/storage/x', container_path: '/data:ro' },
|
||||
])
|
||||
assert.equal(blocked.length, 1)
|
||||
})
|
||||
|
||||
// ── Image references ─────────────────────────────────────────────────────────
|
||||
|
||||
test('evaluateImageReference warns on the latest tag', () => {
|
||||
const { blocked, warnings } = evaluateImageReference('nginx:latest')
|
||||
assert.equal(blocked.length, 0)
|
||||
assert.ok(warnings.some((w) => w.includes('moving tag')))
|
||||
})
|
||||
|
||||
test('evaluateImageReference warns when no tag is given', () => {
|
||||
const { warnings } = evaluateImageReference('nginx')
|
||||
assert.ok(warnings.some((w) => w.includes('moving tag')))
|
||||
})
|
||||
|
||||
test('evaluateImageReference is clean for a pinned image from a trusted registry', () => {
|
||||
const { blocked, warnings } = evaluateImageReference('ghcr.io/stirling-tools/s-pdf:0.30.1')
|
||||
assert.equal(blocked.length, 0)
|
||||
assert.equal(warnings.length, 0)
|
||||
})
|
||||
|
||||
test('evaluateImageReference warns on an untrusted registry', () => {
|
||||
const { warnings } = evaluateImageReference('myregistry.example.com/app:1.0.0')
|
||||
assert.ok(warnings.some((w) => w.includes('trusted registries')))
|
||||
})
|
||||
|
||||
test('evaluateImageReference blocks a malformed reference', () => {
|
||||
const { blocked } = evaluateImageReference('not a valid image!!')
|
||||
assert.equal(blocked.length, 1)
|
||||
})
|
||||
|
||||
test('evaluateImageReference accepts a digest-pinned image without a moving-tag warning', () => {
|
||||
const { blocked, warnings } = evaluateImageReference(
|
||||
'ghcr.io/org/app@sha256:' + 'a'.repeat(64)
|
||||
)
|
||||
assert.equal(blocked.length, 0)
|
||||
assert.equal(warnings.length, 0)
|
||||
})
|
||||
|
|
@ -14,4 +14,6 @@ export type ServiceSlim = Pick<
|
|||
| 'display_order'
|
||||
| 'container_image'
|
||||
| 'available_update_version'
|
||||
| 'is_custom'
|
||||
| 'category'
|
||||
> & { status?: string }
|
||||
|
|
|
|||
Loading…
Reference in New Issue