diff --git a/admin/app/controllers/supply_depot_controller.ts b/admin/app/controllers/supply_depot_controller.ts new file mode 100644 index 0000000..5225dd4 --- /dev/null +++ b/admin/app/controllers/supply_depot_controller.ts @@ -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 } }) + } +} diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 8967e6d..abace4f 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -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 = + 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; uiLocation: string | null } { + const portBindings: Record = {} + const exposedPorts: Record = {} + 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 = { + 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, + } + } } \ No newline at end of file diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 1c06ed5..452122a 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -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 diff --git a/admin/app/services/custom_app_guard.ts b/admin/app/services/custom_app_guard.ts new file mode 100644 index 0000000..cd1f00e --- /dev/null +++ b/admin/app/services/custom_app_guard.ts @@ -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 diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index aad3927..760162b 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -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() + + 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") diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 42daac6..ed3e5e0 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -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 { + 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 { + const CUSTOM_PORT_START = 8600 + const occupied = new Set() + + 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 + } } diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 41eb6a6..5e9b915 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -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(), + }) +) diff --git a/admin/constants/service_names.ts b/admin/constants/service_names.ts index 8009222..01d3299 100644 --- a/admin/constants/service_names.ts +++ b/admin/constants/service_names.ts @@ -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', } diff --git a/admin/database/migrations/1772000000001_add_supply_depot_fields_to_services.ts b/admin/database/migrations/1772000000001_add_supply_depot_fields_to_services.ts new file mode 100644 index 0000000..b88c66f --- /dev/null +++ b/admin/database/migrations/1772000000001_add_supply_depot_fields_to_services.ts @@ -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') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index fd0fcec..6221201 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -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, + '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, - '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, + }) + } + } } } diff --git a/admin/inertia/components/CustomAppModal.tsx b/admin/inertia/components/CustomAppModal.tsx new file mode 100644 index 0000000..fb04bb5 --- /dev/null +++ b/admin/inertia/components/CustomAppModal.tsx @@ -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([{ container: '', host: '' }]) + const [volumes, setVolumes] = useState([]) + const [envVars, setEnvVars] = useState([]) + const [memoryMb, setMemoryMb] = useState('') + const [cpus, setCpus] = useState('') + const [submitting, setSubmitting] = useState(false) + const [portConflicts, setPortConflicts] = useState>([]) + const [resourceWarnings, setResourceWarnings] = useState([]) + const [blocked, setBlocked] = useState([]) + const [forceInstall, setForceInstall] = useState(false) + const [checkingPreflight, setCheckingPreflight] = useState(false) + const [suggestedPort, setSuggestedPort] = useState(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 ( + +
+ {/* Image + Name */} +
+ setImage(e.target.value)} + required + /> + setFriendlyName(e.target.value)} + required + /> +
+ + {/* Category + Icon */} +
+ setIcon(newVal)} + options={ICON_OPTIONS} + className="flex-1 min-w-0" + /> +
+ +
+
+
+ + {/* Port Mappings */} +
+
+ + Add Port +
+ {ports.length === 0 && ( +

No port mappings — the app won't be accessible from a browser.

+ )} +
+ {ports.map((p, idx) => ( +
+ updatePort(idx, 'container', e.target.value)} + className='w-full' + /> + + updatePort(idx, 'host', e.target.value)} + className='w-full' + /> + +
+ ))} +
+

Host ports should be in the 8600+ range. Custom apps get ports starting at {suggestedPort ?? 8600}.

+ {checkingPreflight && ( +

Checking port availability…

+ )} +
+ + {/* Volume Mappings */} +
+
+ + Add Volume +
+ {volumes.length === 0 && ( +

No volumes — data won't persist across restarts.

+ )} +
+ {volumes.map((v, idx) => ( +
+ updateVolume(idx, 'host_path', e.target.value)} + className='w-full' + /> + : + updateVolume(idx, 'container_path', e.target.value)} + className='w-full' + /> + +
+ ))} +
+
+ + {/* Environment Variables */} +
+
+ + Add Variable +
+
+ {envVars.map((e, idx) => ( +
+ updateEnv(idx, ev.target.value)} + className='w-full font-mono' + /> + +
+ ))} + {envVars.length === 0 && ( +

No environment variables provided.

+ )} +
+
+ + {/* Advanced: resource limits */} +
+ +
+ setMemoryMb(e.target.value)} + className='w-full' + /> + setCpus(e.target.value)} + className='w-full' + /> +
+

Caps prevent a runaway container from starving the host. Leave blank to use the defaults (1024 MB / 1 CPU).

+
+ + {/* Hard blocks — must be resolved before installing */} + {hasBlocks && ( +
+ {blocked.map((b, i) => ( + + ))} +
+ )} + + {/* Warnings */} + {hasWarnings && ( +
+ {portConflicts.map((c) => ( + + ))} + {resourceWarnings.map((w, i) => ( + + ))} + +
+ )} + +

+ Containers are created with --restart=unless-stopped. Data is not persisted unless you add volume mounts above. +

+ +
+ ) +} diff --git a/admin/inertia/components/ServiceLogsModal.tsx b/admin/inertia/components/ServiceLogsModal.tsx new file mode 100644 index 0000000..12cfdaf --- /dev/null +++ b/admin/inertia/components/ServiceLogsModal.tsx @@ -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 ( + +
+        {logs || (loading ? 'Loading…' : 'No log output.')}
+      
+
+ ) +} diff --git a/admin/inertia/components/ServiceStatsModal.tsx b/admin/inertia/components/ServiceStatsModal.tsx new file mode 100644 index 0000000..e61b18b --- /dev/null +++ b/admin/inertia/components/ServiceStatsModal.tsx @@ -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 ( +
+
+ {label} + {value} +
+
+
90 ? 'bg-desert-red' : clamped > 70 ? 'bg-desert-orange' : 'bg-desert-green' + }`} + style={{ width: `${clamped}%` }} + /> +
+
+ ) +} + +/** 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(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 ( + +
+ {!running ? ( +

+ This app is not running. Start it to see live resource usage. +

+ ) : !stats ? ( +

+ {loading ? 'Loading…' : 'No stats available.'} +

+ ) : ( + <> + + +

Updates every 2 seconds.

+ + )} +
+
+ ) +} diff --git a/admin/inertia/components/StyledModal.tsx b/admin/inertia/components/StyledModal.tsx index 0e0d784..9634590 100644 --- a/admin/inertia/components/StyledModal.tsx +++ b/admin/inertia/components/StyledModal.tsx @@ -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 = ({ confirmIcon, confirmVariant = 'action', confirmLoading = false, + confirmDisabled = false, onCancel, onConfirm, icon, @@ -94,6 +96,7 @@ const StyledModal: React.FC = ({ }} icon={confirmIcon} loading={confirmLoading} + disabled={confirmDisabled} > {confirmText} diff --git a/admin/inertia/components/inputs/Select.tsx b/admin/inertia/components/inputs/Select.tsx new file mode 100644 index 0000000..d7b1b32 --- /dev/null +++ b/admin/inertia/components/inputs/Select.tsx @@ -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 { + value: T; + label: string; + disabled?: boolean; +} + +export interface SelectProps { + name: string; + label: string; + value: T; + onChange: (value: T) => void; + options: SelectOption[]; + helpText?: string; + placeholder?: string; + className?: string; + labelClassName?: string; + selectClassName?: string; + containerClassName?: string; + error?: boolean; + required?: boolean; + disabled?: boolean; +} + +const Select = ({ + name, + label, + value, + onChange, + options, + helpText, + placeholder, + className, + labelClassName, + selectClassName, + containerClassName, + error, + required, + disabled, +}: SelectProps) => { + const selectedOption = options.find((o) => o.value === value); + + return ( +
+ + {helpText &&

{helpText}

} +
+ +
+ + + {selectedOption ? selectedOption.label : (placeholder ?? label)} + + + + + + {options.map((option, index) => ( + + {option.label} + + ))} + +
+
+
+
+ ); +}; + +export default Select; diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index 0ecad83..2f02be6 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -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 }, diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 83950a9..d0db48e 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -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() diff --git a/admin/inertia/lib/icons.ts b/admin/inertia/lib/icons.ts index 75a039d..f021366 100644 --- a/admin/inertia/lib/icons.ts +++ b/admin/inertia/lib/icons.ts @@ -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 diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index 1feebb2..b9581bc 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -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: , installed: true, displayOrder: 51, diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx new file mode 100644 index 0000000..f8cebe1 --- /dev/null +++ b/admin/inertia/pages/supply-depot.tsx @@ -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 = { + 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(null) + const [loading, setLoading] = useState(false) + const [openDropdown, setOpenDropdown] = useState(null) + const [customAppOpen, setCustomAppOpen] = useState(false) + const [editApp, setEditApp] = useState(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(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 ( + + + + {loading && } + +
+ {/* ── Hero / controls panel ─────────────────────────────────────────── */} +
+ {/* Green header band */} +
+ {/* Diagonal line pattern */} +
+
+
+
+ +
+ +
+

+ Supply Depot +

+

+ Browse and install curated apps, or add your own custom apps by providing a Docker + image. +

+
+
+
+ + {/* Controls body */} +
+ {/* Activity feed (shown only while installing) */} + {installActivity.length > 0 && ( + + )} + + {/* Search + Add Custom App */} +
+
+ + 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" + /> +
+ setCustomAppOpen(true)} + > + Add Custom App + +
+ + {/* Category filters */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+
+ + {/* Bottom accent bar */} +
+
+ + {/* App cards */} + {filteredServices.length === 0 ? ( +
+ +

No apps match your filter.

+
+ ) : ( +
+ {installedServices.length > 0 && ( +
+ +
+ {installedServices.map((service) => ( + 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)} + /> + ))} +
+
+ )} + + {availableServices.length > 0 && ( +
+ +
+ {availableServices.map((service) => ( + 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)} + /> + ))} +
+
+ )} +
+ )} +
+ + {/* ── Modals ─────────────────────────────────────────────────────────── */} + + {/* Install modal */} + {modal?.type === 'install' && ( + setModal(null)} + onConfirm={() => handleInstall(modal.service)} + confirmText="Install" + confirmIcon="IconDownload" + confirmVariant="primary" + confirmLoading={loading} + > +
+

+ This will download and start {modal.service.friendly_name} + {modal.service.ui_location && ( + <> on port {modal.service.ui_location} + )}. +

+ {modal.service.powered_by && ( +

Powered by {modal.service.powered_by}

+ )} + + {preflightLoading && ( +
+ + Checking for conflicts… +
+ )} + + {!preflightLoading && preflight && hasPreflightWarnings && ( +
+ {preflight.portConflicts.map((c) => ( + + ))} + {preflight.resourceWarnings.map((w, i) => ( + + ))} + +
+ )} +
+
+ )} + + {/* Start modal */} + {modal?.type === 'start' && ( + setModal(null)} + onConfirm={() => handleAffect(modal.service, 'start')} + confirmText="Start" + confirmIcon="IconPlayerPlay" + confirmVariant="primary" + confirmLoading={loading} + > +

This will start the container.

+
+ )} + + {/* Stop modal */} + {modal?.type === 'stop' && ( + setModal(null)} + onConfirm={() => handleAffect(modal.service, 'stop')} + confirmText="Stop" + confirmIcon="IconPlayerStop" + confirmVariant="action" + confirmLoading={loading} + > +

The container will be stopped. Your data is preserved.

+
+ )} + + {/* Restart modal */} + {modal?.type === 'restart' && ( + setModal(null)} + onConfirm={() => handleAffect(modal.service, 'restart')} + confirmText="Restart" + confirmIcon="IconRefresh" + confirmVariant="action" + confirmLoading={loading} + > +

The container will be briefly stopped and restarted.

+
+ )} + + {/* Force reinstall modal */} + {modal?.type === 'reinstall' && ( + setModal(null)} + onConfirm={() => handleForceReinstall(modal.service)} + confirmText="Wipe & Reinstall" + confirmIcon="IconRefresh" + confirmVariant="danger" + confirmLoading={loading} + icon={} + > +
+

This will delete all app data and cannot be undone.

+

The container and its associated volumes will be removed, then a fresh installation will begin.

+
+
+ )} + + {/* Delete custom app modal */} + {modal?.type === 'delete' && ( + { + setRemoveImage(false) + setModal(null) + }} + onConfirm={() => handleDelete(modal.service)} + confirmText="Delete" + confirmIcon="IconTrash" + confirmVariant="danger" + confirmLoading={loading} + icon={} + > +
+

This will permanently remove this custom app.

+

The container will be stopped and removed. Host volume data will remain on disk.

+ +
+
+ )} + + {/* Logs modal */} + {modal?.type === 'logs' && ( + setModal(null)} + /> + )} + + {/* Stats modal */} + {modal?.type === 'stats' && ( + setModal(null)} + /> + )} + + {/* Custom app creation modal */} + setCustomAppOpen(false)} + onCreated={handleCustomAppCreated} + showError={showError} + /> + + {/* Custom app edit modal */} + setEditApp(null)} + onCreated={handleEdited} + showError={showError} + /> + + ) +} + +// ── App Card component ──────────────────────────────────────────────────────── + +interface AppCardProps { + service: ServiceSlim + openDropdown: string | null + dropdownRef: React.RefObject + 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 ( +
+ {/* Installed accent spine */} + {service.installed ? ( +
+ ) : null} + + {/* Top row: icon + status badge */} +
+
+
+ {service.icon ? ( + + ) : ( + + )} +
+
+

+ {service.friendly_name ?? service.service_name} +

+ {service.powered_by && ( +

{service.powered_by}

+ )} +
+
+ + {/* Status indicator */} +
+ {service.installation_status === 'installing' ? ( + + + Installing + + ) : isRunning ? ( + + + Running + + ) : isStopped ? ( + + + Stopped + + ) : null} +
+
+ + {/* Description */} + {service.description && ( +

+ {service.description} +

+ )} + + {/* Metadata row: category badge + port pill */} +
+ {service.category && ( + + {toTitleCase(service.category)} + + )} + {service.is_custom ? ( + + custom + + ) : null} + {service.ui_location && !service.ui_location.startsWith('/') && ( + + :{service.ui_location} + + )} +
+ + {/* Action buttons */} +
+ {!service.installed && service.installation_status !== 'installing' && ( + + Install + + )} + + {service.installed ? ( + <> + {/* Open button */} + {service.ui_location && ( + + + Open + + + )} + + {/* Manage dropdown */} +
+ + Manage + + + {isDropdownOpen && ( +
+ {isStopped && ( + } label="Start" onClick={onStart} /> + )} + {isRunning && ( + } label="Stop" onClick={onStop} /> + )} + } label="Restart" onClick={onRestart} /> + } label="Logs" onClick={onLogs} /> + } label="Stats" onClick={onStats} /> + {service.is_custom ? ( + } label="Edit" onClick={onEdit} /> + ) : null} + {service.is_custom ? ( + } label="Update (pull latest)" onClick={onUpdate} /> + ) : null} + } label="Force Reinstall" onClick={onReinstall} danger /> + {service.is_custom ? ( + } label="Delete" onClick={onDelete} danger /> + ): null} +
+ )} +
+ + ) : null} + + {service.installation_status === 'installing' && ( +
+ + In progress… +
+ )} +
+
+ ) +} + +function DropdownItem({ + icon, + label, + onClick, + danger = false, +}: { + icon: React.ReactNode + label: string + onClick: () => void + danger?: boolean +}) { + return ( + + ) +} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 9372387..598c951 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -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']) diff --git a/admin/tests/unit/custom_app_guard.spec.ts b/admin/tests/unit/custom_app_guard.spec.ts new file mode 100644 index 0000000..9a72ab8 --- /dev/null +++ b/admin/tests/unit/custom_app_guard.spec.ts @@ -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) +}) diff --git a/admin/types/services.ts b/admin/types/services.ts index cb9f45a..380001e 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -14,4 +14,6 @@ export type ServiceSlim = Pick< | 'display_order' | 'container_image' | 'available_update_version' + | 'is_custom' + | 'category' > & { status?: string }