feat: supply depot

This commit is contained in:
Jake Turner 2026-05-25 01:15:33 +00:00 committed by jakeaturner
parent e38dbf8a65
commit be434d755a
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
23 changed files with 3370 additions and 31 deletions

View File

@ -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 } })
}
}

View File

@ -3,10 +3,28 @@ import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
import {
affectServiceValidator,
checkLatestVersionValidator,
customAppValidator,
deleteCustomAppValidator,
installServiceValidator,
preflightCustomValidator,
preflightValidator,
serviceLogsValidator,
subscribeToReleaseNotesValidator,
updateCustomAppValidator,
updateServiceValidator,
} from '#validators/system'
import {
DEFAULT_CPUS,
DEFAULT_MEMORY_MB,
evaluateCustomApp,
} from '#services/custom_app_guard'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import Service from '#models/service'
@inject()
export default class SystemController {
@ -180,4 +198,387 @@ export default class SystemController {
return 'amd64'
}
}
/**
* Pre-install preflight check: reports port conflicts and resource warnings for a service.
* Results are advisory the UI shows warnings but allows the user to force-proceed.
*/
async preflightCheck({ request, response }: HttpContext) {
const payload = await request.validateUsing(preflightValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
// Extract host ports from container_config — the MySQL driver may return JSON columns
// as an already-parsed object rather than a string, so guard before calling JSON.parse.
const rawConfig = service.container_config
const config = rawConfig
? typeof rawConfig === 'object'
? rawConfig
: JSON.parse(rawConfig as string)
: null
const portBindings: Record<string, [{ HostPort: string }]> =
config?.HostConfig?.PortBindings ?? {}
const hostPorts = Object.values(portBindings)
.flat()
.map((b) => parseInt(b.HostPort, 10))
.filter((p) => !isNaN(p))
// Parse resource requirements from metadata (same object-guard as container_config)
let minMemoryMB = 256
let minDiskMB = 512
try {
const rawMeta = service.metadata
const meta = rawMeta
? typeof rawMeta === 'object'
? rawMeta
: JSON.parse(rawMeta as string)
: null
if (meta?.minMemoryMB) minMemoryMB = meta.minMemoryMB
if (meta?.minDiskMB) minDiskMB = meta.minDiskMB
} catch {}
const [{ conflicts: portConflicts }, resourceWarnings] = await Promise.all([
this.dockerService.checkPortConflicts(hostPorts),
this.systemService.checkResourceWarnings(minMemoryMB, minDiskMB),
])
return response.send({ portConflicts, resourceWarnings })
}
/** Return the next suggested host port for a custom app (8600+ range). */
async suggestCustomPort({ response }: HttpContext) {
const port = await this.systemService.getNextSuggestedCustomPort()
return response.send({ port })
}
/**
* Service-less preflight for the custom-app form: given host ports, volumes and an image,
* report port conflicts, host resource warnings, overridable guard warnings (risky bind
* mounts / untrusted or moving-tag images), and hard blocks (docker socket, system dirs,
* malformed image). Lets the form give live feedback before a Service record exists.
*/
async preflightCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(preflightCustomValidator)
const [{ conflicts }, resourceWarnings] = await Promise.all([
this.dockerService.checkPortConflicts(payload.ports ?? []),
this.systemService.checkResourceWarnings(256, 512),
])
// When editing, the app's own container legitimately holds its ports — don't flag those.
const portConflicts = payload.exclude_service
? conflicts.filter((c) => c.usedBy !== payload.exclude_service)
: conflicts
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
return response.send({
portConflicts,
resourceWarnings: [...resourceWarnings, ...guard.warnings],
blocked: guard.blocked,
})
}
/** Create and immediately begin installing a custom app container. */
async createCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(customAppValidator)
// Derive a stable service_name from the friendly name
const slug = payload.friendly_name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
const serviceName = `nomad_custom_${slug}`
const existing = await Service.query().where('service_name', serviceName).first()
if (existing) {
return response.status(409).send({
success: false,
message: `A custom app named "${payload.friendly_name}" already exists. Choose a different name.`,
})
}
// Reject duplicate host ports within the request — Docker would otherwise fail at
// start time with an opaque "port is already allocated" error.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
if (duplicateHostPorts.length) {
return response.status(422).send({
success: false,
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
})
}
// Security guardrails: hard-block dangerous bind mounts / malformed images regardless of
// force; surface overridable warnings (risky paths, untrusted/moving-tag images) unless forced.
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
if (guard.blocked.length) {
return response.status(422).send({
success: false,
message: guard.blocked.join(' '),
blocked: guard.blocked,
})
}
if (!payload.force && guard.warnings.length) {
return response.status(409).send({
success: false,
message: guard.warnings.join(' '),
warnings: guard.warnings,
})
}
// Advisory preflight: surface port conflicts before creating the record so a failed
// install doesn't leave a phantom card. The user can re-submit with force=true to override.
if (!payload.force && hostPorts.length) {
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
if (conflicts.length) {
return response.status(409).send({
success: false,
message: `Port conflict: ${conflicts
.map((c) => `${c.port} (in use by ${c.usedBy})`)
.join(', ')}.`,
portConflicts: conflicts,
})
}
}
const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload)
await Service.create({
service_name: serviceName,
friendly_name: payload.friendly_name,
container_image: payload.image,
container_config: JSON.stringify(containerConfig),
ui_location: uiLocation,
icon: payload.icon || 'IconBrandDocker',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: true,
category: payload.category ?? 'custom',
depends_on: null,
})
const result = await this.dockerService.createContainerPreflight(serviceName)
if (result.success) {
return response.send({ success: true, message: result.message, service_name: serviceName })
}
return response.status(400).send({ success: false, message: result.message })
}
/** Delete a custom app: stop + remove its container, then delete the DB record. */
async deleteCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(deleteCustomAppValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ error: 'Only custom apps can be deleted.' })
}
await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false)
await service.delete()
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
}
/** Re-pull a custom app's image and recreate its container in place (preserving volumes). */
async updateCustomApp_pullLatest({ request, response }: HttpContext) {
const payload = await request.validateUsing(installServiceValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' })
}
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, {
forcePull: true,
})
if (result.success) {
return response.send({ success: true, message: result.message })
}
return response.status(400).send({ success: false, message: result.message })
}
/** Return the last N lines of a service container's logs. */
async getServiceLogs({ params, request, response }: HttpContext) {
// Scope to managed services only — otherwise any sibling container's logs (admin app,
// database) would be readable by name on this unauthenticated API surface.
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
}
const { tail } = await request.validateUsing(serviceLogsValidator)
const result = await this.dockerService.getContainerLogs(params.name, tail ?? 200)
if (!result.success) {
return response.status(404).send({ success: false, message: result.message })
}
return response.send({ success: true, logs: result.logs })
}
/** Return a one-shot CPU/memory usage snapshot for a running service container. */
async getServiceStats({ params, response }: HttpContext) {
// Scope to managed services only (see getServiceLogs).
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
}
const result = await this.dockerService.getContainerStats(params.name)
if (!result.success) {
return response.status(404).send({ success: false, message: result.message })
}
return response.send({ success: true, running: result.running ?? false, stats: result.stats ?? null })
}
/** Return a custom app's current configuration in the editable form-shape. */
async getCustomApp({ params, response }: HttpContext) {
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ error: `Service ${params.name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ error: 'Only custom apps can be edited.' })
}
return response.send({ success: true, app: this.parseCustomContainerConfig(service) })
}
/** Reconfigure a custom app: validate + guard, persist the new config, then recreate the container. */
async updateCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(updateCustomAppValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ success: false, message: 'Only custom apps can be edited.' })
}
// Reject duplicate host ports within the request.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
if (duplicateHostPorts.length) {
return response.status(422).send({
success: false,
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
})
}
// Security guardrails (same posture as create).
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
if (guard.blocked.length) {
return response.status(422).send({ success: false, message: guard.blocked.join(' '), blocked: guard.blocked })
}
if (!payload.force && guard.warnings.length) {
return response.status(409).send({ success: false, message: guard.warnings.join(' '), warnings: guard.warnings })
}
// Port conflicts — but ignore ports already held by this app's own container.
if (!payload.force && hostPorts.length) {
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
const external = conflicts.filter((c) => c.usedBy !== payload.service_name)
if (external.length) {
return response.status(409).send({
success: false,
message: `Port conflict: ${external
.map((c) => `${c.port} (in use by ${c.usedBy})`)
.join(', ')}.`,
portConflicts: external,
})
}
}
const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload)
service.friendly_name = payload.friendly_name
service.container_image = payload.image
service.container_config = JSON.stringify(containerConfig)
service.ui_location = uiLocation
service.category = payload.category ?? service.category ?? 'custom'
if (payload.icon) service.icon = payload.icon
await service.save()
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name)
if (result.success) {
return response.send({ success: true, message: result.message, service_name: payload.service_name })
}
return response.status(400).send({ success: false, message: result.message })
}
/**
* Build a Docker container config (HostConfig + ExposedPorts + Env) from custom-app form input,
* applying default resource caps. Shared by create and update so both stay in lockstep.
*/
private buildCustomContainerConfig(payload: {
ports?: { container: number; host: number }[]
volumes?: { host_path: string; container_path: string }[]
env?: string[]
memory_mb?: number
cpus?: number
}): { containerConfig: Record<string, any>; uiLocation: string | null } {
const portBindings: Record<string, [{ HostPort: string }]> = {}
const exposedPorts: Record<string, {}> = {}
for (const { container, host } of payload.ports ?? []) {
portBindings[`${container}/tcp`] = [{ HostPort: String(host) }]
exposedPorts[`${container}/tcp`] = {}
}
const binds = (payload.volumes ?? []).map(
({ host_path, container_path }) => `${host_path}:${container_path}`
)
// Resource caps so a runaway custom container can't starve the host. Memory is bytes;
// NanoCpus is CPUs × 1e9. Defaults are generous and user-overridable.
const memoryBytes = (payload.memory_mb ?? DEFAULT_MEMORY_MB) * 1024 * 1024
const nanoCpus = Math.round((payload.cpus ?? DEFAULT_CPUS) * 1e9)
const containerConfig: Record<string, any> = {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: portBindings,
Memory: memoryBytes,
NanoCpus: nanoCpus,
...(binds.length ? { Binds: binds } : {}),
},
ExposedPorts: exposedPorts,
...(payload.env?.length ? { Env: payload.env } : {}),
}
const firstHostPort = payload.ports?.[0]?.host
const uiLocation = firstHostPort ? String(firstHostPort) : null
return { containerConfig, uiLocation }
}
/** Inverse of buildCustomContainerConfig: turn a stored Service into the editable form-shape. */
private parseCustomContainerConfig(service: Service) {
const raw = service.container_config
const config = raw ? (typeof raw === 'object' ? raw : JSON.parse(raw as string)) : {}
const hostConfig = config?.HostConfig ?? {}
const ports = Object.entries(hostConfig.PortBindings ?? {}).map(([key, val]: [string, any]) => ({
container: Number.parseInt(key, 10),
host: Number.parseInt(val?.[0]?.HostPort, 10),
}))
const volumes = (hostConfig.Binds ?? []).map((bind: string) => {
const idx = bind.indexOf(':')
return { host_path: bind.slice(0, idx), container_path: bind.slice(idx + 1) }
})
return {
service_name: service.service_name,
friendly_name: service.friendly_name,
image: service.container_image,
category: service.category ?? 'custom',
icon: service.icon ?? 'IconBrandDocker',
ports,
volumes,
env: (config?.Env ?? []) as string[],
memory_mb: hostConfig.Memory ? Math.round(hostConfig.Memory / (1024 * 1024)) : undefined,
cpus: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : undefined,
}
}
}

View File

@ -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

View File

@ -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

View File

@ -781,8 +781,15 @@ export class DockerService {
try {
const service = await Service.query().where('service_name', serviceName).first()
if (service) {
service.installation_status = 'error'
await service.save()
if (service.is_custom) {
// Custom apps have no seeder definition to fall back to — leaving the row would
// surface a phantom, un-installable card. Remove the record entirely so a failed
// install cleanly disappears. (The 'error' broadcast has already fired upstream.)
await service.delete()
} else {
service.installation_status = 'error'
await service.save()
}
}
this.activeInstallations.delete(serviceName)
@ -1363,6 +1370,299 @@ export class DockerService {
}
}
/**
* Check whether any of the supplied host ports are already bound by a running or stopped
* Docker container. Uses the Docker API exclusively probing ports via net.createServer()
* would only test the admin container's own network namespace (DooD pattern), not the host.
*/
async checkPortConflicts(
ports: number[]
): Promise<{ conflicts: { port: number; usedBy: string }[] }> {
if (!ports.length) return { conflicts: [] }
try {
const containers = await this.docker.listContainers({ all: true })
const bound = new Map<number, string>()
for (const c of containers) {
const name = (c.Names[0] || '').replace('/', '')
for (const p of c.Ports) {
if (p.PublicPort) bound.set(p.PublicPort, name || c.Id.slice(0, 12))
}
}
const conflicts = ports
.filter((p) => bound.has(p))
.map((p) => ({ port: p, usedBy: bound.get(p)! }))
return { conflicts }
} catch (error: any) {
logger.warn(`[DockerService] checkPortConflicts failed: ${error.message}`)
return { conflicts: [] }
}
}
/**
* Remove a custom-app container and, when `removeImage` is set, its backing image too. Called
* before deleting the DB record. Image removal is best-effort: a shared/in-use image is left alone.
*/
async removeCustomAppContainer(
serviceName: string,
removeImage = false
): Promise<{ success: boolean; message: string }> {
try {
const containers = await this.docker.listContainers({ all: true })
const container = containers.find((c) => c.Names.includes(`/${serviceName}`))
if (!container) return { success: true, message: 'No container found — nothing to remove' }
const imageRef = container.Image
const c = this.docker.getContainer(container.Id)
if (container.State === 'running') await c.stop()
await c.remove({ force: true })
if (removeImage && imageRef) {
try {
await this.docker.getImage(imageRef).remove()
} catch (imgErr: any) {
// Non-fatal: the image may be shared with another container or already gone.
logger.warn(`[DockerService] Could not remove image ${imageRef} for ${serviceName}: ${imgErr.message}`)
}
}
this.invalidateServicesStatusCache()
return { success: true, message: `Container ${serviceName} removed` }
} catch (error: any) {
logger.error({ err: error }, `[DockerService] removeCustomAppContainer failed for ${serviceName}`)
return { success: false, message: error.message }
}
}
/** Find a container by its managed service name (`/serviceName`), or null. */
private async _findContainerByName(serviceName: string) {
const containers = await this.docker.listContainers({ all: true })
return containers.find((c) => c.Names.includes(`/${serviceName}`)) ?? null
}
/**
* Decode the multiplexed stream Docker returns for non-TTY container logs. Each frame is an
* 8-byte header ([streamType, 0,0,0, big-endian payloadSize]) followed by the payload.
*/
private _demuxDockerLog(buf: Buffer): string {
let out = ''
let offset = 0
while (offset + 8 <= buf.length) {
const size = buf.readUInt32BE(offset + 4)
offset += 8
if (offset + size > buf.length) {
out += buf.toString('utf8', offset)
break
}
out += buf.toString('utf8', offset, offset + size)
offset += size
}
return out
}
/** Return the last `tail` lines of a service container's combined stdout/stderr. */
async getContainerLogs(
serviceName: string,
tail = 200
): Promise<{ success: boolean; logs?: string; message?: string }> {
try {
const info = await this._findContainerByName(serviceName)
if (!info) return { success: false, message: `No container found for ${serviceName}` }
const container = this.docker.getContainer(info.Id)
const inspect = await container.inspect()
const tty = inspect.Config?.Tty ?? false
const buf = (await container.logs({
stdout: true,
stderr: true,
follow: false,
tail,
timestamps: false,
})) as unknown as Buffer
const logs = tty ? buf.toString('utf8') : this._demuxDockerLog(buf)
return { success: true, logs }
} catch (error: any) {
logger.error({ err: error }, `[DockerService] getContainerLogs failed for ${serviceName}`)
return { success: false, message: error.message }
}
}
/**
* Return a single resource-usage snapshot (CPU %, memory) for a running service container.
* Uses Docker's non-streaming stats, which include precpu_stats so CPU % is computable.
*/
async getContainerStats(serviceName: string): Promise<{
success: boolean
running?: boolean
stats?: { cpuPercent: number; memUsageBytes: number; memLimitBytes: number; memPercent: number }
message?: string
}> {
try {
const info = await this._findContainerByName(serviceName)
if (!info) return { success: false, message: `No container found for ${serviceName}` }
if (info.State !== 'running') return { success: true, running: false }
const container = this.docker.getContainer(info.Id)
const s: any = await container.stats({ stream: false })
const cpuDelta =
(s.cpu_stats?.cpu_usage?.total_usage ?? 0) - (s.precpu_stats?.cpu_usage?.total_usage ?? 0)
const systemDelta =
(s.cpu_stats?.system_cpu_usage ?? 0) - (s.precpu_stats?.system_cpu_usage ?? 0)
const numCpus =
s.cpu_stats?.online_cpus ?? s.cpu_stats?.cpu_usage?.percpu_usage?.length ?? 1
const cpuPercent =
systemDelta > 0 && cpuDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0
// Subtract page cache from usage to better reflect the container's working set.
const cache = s.memory_stats?.stats?.cache ?? s.memory_stats?.stats?.inactive_file ?? 0
const memUsageBytes = Math.max(0, (s.memory_stats?.usage ?? 0) - cache)
const memLimitBytes = s.memory_stats?.limit ?? 0
const memPercent = memLimitBytes > 0 ? (memUsageBytes / memLimitBytes) * 100 : 0
return {
success: true,
running: true,
stats: {
cpuPercent: Math.round(cpuPercent * 10) / 10,
memUsageBytes,
memLimitBytes,
memPercent: Math.round(memPercent * 10) / 10,
},
}
} catch (error: any) {
logger.error({ err: error }, `[DockerService] getContainerStats failed for ${serviceName}`)
return { success: false, message: error.message }
}
}
/**
* Wait for a freshly started container to be "ready". If the image declares a HEALTHCHECK we poll
* its health until healthy/unhealthy (up to timeoutMs); otherwise we fall back to a 5s settle and
* a plain Running check. Returns whether it's ready plus a reason when not.
*/
private async _awaitContainerReady(
container: any,
timeoutMs = 30000
): Promise<{ ready: boolean; reason?: string }> {
let inspect = await container.inspect()
const hasHealthcheck = !!inspect.State?.Health
if (!hasHealthcheck) {
await new Promise((r) => setTimeout(r, 5000))
inspect = await container.inspect()
return inspect.State?.Running
? { ready: true }
: { ready: false, reason: 'container did not stay running' }
}
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
inspect = await container.inspect()
if (!inspect.State?.Running) return { ready: false, reason: 'container exited' }
const status = inspect.State?.Health?.Status
if (status === 'healthy') return { ready: true }
if (status === 'unhealthy') return { ready: false, reason: 'failed its health check' }
await new Promise((r) => setTimeout(r, 2000))
}
// Still in "starting" at timeout — accept it if it's at least running rather than roll back a slow boot.
return inspect.State?.Running ? { ready: true } : { ready: false, reason: 'health check timed out' }
}
/**
* Recreate a custom app's container from its (already-updated) Service record, preserving data.
* Uses the same rename-and-rollback safety net as the update flow: the live container is renamed
* aside, a new one is created from the new config/image, health-gated, and only then is the old one
* removed otherwise we roll back to it. Bind-mounted data is untouched throughout. Pass
* `forcePull` to always re-pull the image first (used by the "update" action for moving tags).
*/
async recreateCustomAppContainer(
serviceName: string,
opts: { forcePull?: boolean } = {}
): Promise<{ success: boolean; message: string }> {
const service = await Service.query().where('service_name', serviceName).first()
if (!service) return { success: false, message: `Service ${serviceName} not found` }
const containerConfig = this._parseContainerConfig(service.container_config)
const oldInfo = await this._findContainerByName(serviceName)
const oldName = `${serviceName}_old`
try {
// Stop + rename the existing container aside as a rollback safety net.
if (oldInfo) {
const oldContainer = this.docker.getContainer(oldInfo.Id)
if (oldInfo.State === 'running') await oldContainer.stop({ t: 10 }).catch(() => {})
await oldContainer.rename({ name: oldName })
}
// Pull the image if it's missing locally, or always when forcePull (e.g. :latest updates).
if (opts.forcePull || !(await this._checkImageExists(service.container_image))) {
const pullStream = await this.docker.pull(service.container_image)
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
}
const newContainer = await this.docker.createContainer({
Image: service.container_image,
name: serviceName,
Labels: {
...(containerConfig?.Labels ?? {}),
'com.docker.compose.project': 'project-nomad-managed',
'io.project-nomad.managed': 'true',
},
...(containerConfig?.User && { User: containerConfig.User }),
HostConfig: containerConfig?.HostConfig ?? {},
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
...(containerConfig?.Env && { Env: containerConfig.Env }),
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
...(process.env.NODE_ENV === 'production' && {
NetworkingConfig: { EndpointsConfig: { [DockerService.NOMAD_NETWORK]: {} } },
}),
})
await newContainer.start()
// Health gate before discarding the old container.
const readiness = await this._awaitContainerReady(newContainer)
if (!readiness.ready) throw new Error(`recreated container ${readiness.reason}`)
if (oldInfo) {
const oldRef = await this._findContainerByName(oldName)
if (oldRef) await this.docker.getContainer(oldRef.Id).remove({ force: true })
}
service.installed = true
service.installation_status = 'idle'
await service.save()
this.invalidateServicesStatusCache()
return { success: true, message: `Service ${serviceName} reconfigured successfully` }
} catch (error: any) {
logger.error({ err: error }, `[DockerService] recreateCustomAppContainer failed for ${serviceName}`)
// Roll back: discard the failed new container and restore the renamed original.
try {
const failedNew = await this._findContainerByName(serviceName)
if (failedNew) {
const c = this.docker.getContainer(failedNew.Id)
await c.stop({ t: 5 }).catch(() => {})
await c.remove({ force: true }).catch(() => {})
}
const renamed = await this._findContainerByName(oldName)
if (renamed) {
const c = this.docker.getContainer(renamed.Id)
await c.rename({ name: serviceName })
await c.start().catch(() => {})
}
} catch (rollbackError: any) {
logger.error({ err: rollbackError }, `[DockerService] rollback failed for ${serviceName}`)
}
this.invalidateServicesStatusCache()
return { success: false, message: `Reconfigure failed and was rolled back: ${error.message}` }
}
}
/**
* Check if a Docker image exists locally.
* @param imageName - The name and tag of the image (e.g., "nginx:latest")

View File

@ -308,7 +308,9 @@ export class SystemService {
'powered_by',
'display_order',
'container_image',
'available_update_version'
'available_update_version',
'is_custom',
'category'
)
.where('is_dependency_service', false)
if (installedOnly) {
@ -338,6 +340,8 @@ export class SystemService {
display_order: service.display_order,
container_image: service.container_image,
available_update_version: service.available_update_version,
is_custom: service.is_custom,
category: service.category,
})
}
@ -928,4 +932,83 @@ export class SystemService {
}
})
}
/**
* Check whether the host has enough free memory and disk to comfortably run an app.
* Returns an array of human-readable warning strings; an empty array means no concerns.
* These are advisory only the caller decides whether to block or warn.
*/
async checkResourceWarnings(minMemoryMB: number, minDiskMB: number): Promise<string[]> {
const warnings: string[] = []
try {
const mem = await si.mem()
const availableMB = Math.floor(mem.available / 1024 / 1024)
if (availableMB < minMemoryMB) {
warnings.push(
`Low memory: ${availableMB} MB available, this app recommends at least ${minMemoryMB} MB free.`
)
}
} catch (err: any) {
logger.warn(`[SystemService] checkResourceWarnings mem check failed: ${err.message}`)
}
try {
const storagePath = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
const fsSizes = await si.fsSize()
// Find the filesystem whose mount point is the longest prefix of storagePath
const fs = fsSizes
.filter((f) => storagePath.startsWith(f.mount))
.sort((a, b) => b.mount.length - a.mount.length)[0]
if (fs) {
const availableDiskMB = Math.floor((fs.size - fs.used) / 1024 / 1024)
if (availableDiskMB < minDiskMB) {
warnings.push(
`Low disk space: ${availableDiskMB} MB available on ${fs.mount}, this app recommends at least ${minDiskMB} MB free.`
)
}
}
} catch (err: any) {
logger.warn(`[SystemService] checkResourceWarnings disk check failed: ${err.message}`)
}
return warnings
}
/**
* Return the next suggested host port for a custom app in the 8600+ range.
* Looks at existing custom service records and all Docker container port bindings.
*/
async getNextSuggestedCustomPort(): Promise<number> {
const CUSTOM_PORT_START = 8600
const occupied = new Set<number>()
try {
// Ports used by existing custom services in the DB
const customServices = await Service.query().where('is_custom', true)
for (const svc of customServices) {
const config = svc.container_config ? JSON.parse(svc.container_config) : null
const bindings = config?.HostConfig?.PortBindings ?? {}
for (const binding of Object.values(bindings) as any[]) {
const port = parseInt(binding?.[0]?.HostPort, 10)
if (!isNaN(port)) occupied.add(port)
}
}
// Ports used by any running Docker container in the 8600+ range
const containers = await this.dockerService.docker.listContainers({ all: true })
for (const c of containers) {
for (const p of c.Ports) {
if (p.PublicPort && p.PublicPort >= CUSTOM_PORT_START) occupied.add(p.PublicPort)
}
}
} catch (err: any) {
logger.warn(`[SystemService] getNextSuggestedCustomPort probe failed: ${err.message}`)
}
let candidate = CUSTOM_PORT_START
while (occupied.has(candidate)) candidate += 10
return candidate
}
}

View File

@ -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(),
})
)

View File

@ -5,4 +5,15 @@ export const SERVICE_NAMES = {
CYBERCHEF: 'nomad_cyberchef',
FLATNOTES: 'nomad_flatnotes',
KOLIBRI: 'nomad_kolibri',
// Supply Depot — curated catalog (ports 84008499)
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',
}

View File

@ -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')
})
}
}

View File

@ -5,16 +5,19 @@ import env from '#start/env'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
type ServiceSeedRecord = Omit<
ModelAttributes<Service>,
'created_at' | 'updated_at' | 'id' | 'available_update_version' | 'update_checked_at' | 'metadata'
> & { metadata?: string | null }
export default class ServiceSeeder extends BaseSeeder {
// Use environment variable with fallback to production default
private static NOMAD_STORAGE_ABS_PATH = env.get(
'NOMAD_STORAGE_PATH',
'/opt/project-nomad/storage'
)
private static DEFAULT_SERVICES: Omit<
ModelAttributes<Service>,
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
>[] = [
private static DEFAULT_SERVICES: ServiceSeedRecord[] = [
// ── Core / original services ──────────────────────────────────────────────
{
service_name: SERVICE_NAMES.KIWIX,
friendly_name: 'Information Library',
@ -38,13 +41,15 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
depends_on: null,
},
{
service_name: SERVICE_NAMES.QDRANT,
friendly_name: 'Qdrant Vector Database',
powered_by: null,
display_order: 100, // Dependency service, not shown directly
display_order: 100,
description: 'Vector database for storing and searching embeddings',
icon: 'IconRobot',
container_image: 'qdrant/qdrant:v1.16',
@ -57,15 +62,15 @@ export default class ServiceSeeder extends BaseSeeder {
PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] },
},
ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} },
// Disable Qdrant's anonymous telemetry to telemetry.qdrant.io. NOMAD is offline-first
// and ships with zero telemetry by default — Qdrant's upstream default of enabled
// telemetry doesn't match that posture.
// Disable anonymous telemetry — NOMAD is offline-first
Env: ['QDRANT__TELEMETRY_DISABLED=true'],
}),
ui_location: '6333',
installed: false,
installation_status: 'idle',
is_dependency_service: true,
is_custom: false,
category: null,
depends_on: null,
},
{
@ -90,6 +95,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'ai',
depends_on: SERVICE_NAMES.QDRANT,
},
{
@ -113,6 +120,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
@ -138,6 +147,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
@ -162,18 +173,316 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
depends_on: null,
},
// ── Supply Depot — curated catalog (ports 84008499) ─────────────────────
{
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,
})
}
}
}
}

View File

@ -0,0 +1,540 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import StyledButton from './StyledButton'
import Alert from './Alert'
import api from '~/lib/api'
import Input from './inputs/Input'
import Select from './inputs/Select'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import { IconTrash } from '@tabler/icons-react'
interface PortMapping {
container: string
host: string
}
interface VolumeMapping {
host_path: string
container_path: string
}
interface EnvVar {
value: string
}
export interface CustomAppInitial {
service_name: string
friendly_name: string | null
image: string
category: string
icon: string
ports: Array<{ container: number; host: number }>
volumes: Array<{ host_path: string; container_path: string }>
env: string[]
memory_mb?: number
cpus?: number
}
interface CustomAppModalProps {
open: boolean
onClose: () => void
onCreated: (serviceName: string) => void
showError: (msg: string) => void
/** 'edit' reconfigures an existing custom app (prefilled from `initial`); defaults to 'create'. */
mode?: 'create' | 'edit'
initial?: CustomAppInitial | null
}
const CATEGORY_OPTIONS = [
{ value: 'custom', label: 'Custom' },
{ value: 'productivity', label: 'Productivity' },
{ value: 'media', label: 'Media' },
{ value: 'security', label: 'Security' },
{ value: 'networking', label: 'Networking' },
{ value: 'utility', label: 'Utility' },
{ value: 'ai', label: 'AI' },
{ value: 'education', label: 'Education' },
]
// Curated subset of the DynamicIcon map suitable for custom apps.
const ICON_OPTIONS = [
{ value: 'IconBrandDocker', label: 'Docker (default)' },
{ value: 'IconBox', label: 'Box' },
{ value: 'IconServer', label: 'Server' },
{ value: 'IconDatabase', label: 'Database' },
{ value: 'IconCode', label: 'Code' },
{ value: 'IconTool', label: 'Tool' },
{ value: 'IconWorld', label: 'Web' },
{ value: 'IconShieldLock', label: 'Security' },
{ value: 'IconMovie', label: 'Media' },
{ value: 'IconBook', label: 'Book' },
{ value: 'IconNotes', label: 'Notes' },
{ value: 'IconCpu', label: 'Compute' },
{ value: 'IconRobot', label: 'AI / Bot' },
{ value: 'IconWifi', label: 'Network' },
{ value: 'IconHome', label: 'Home' },
]
export default function CustomAppModal({
open,
onClose,
onCreated,
showError,
mode = 'create',
initial = null,
}: CustomAppModalProps) {
const isEdit = mode === 'edit'
const [friendlyName, setFriendlyName] = useState('')
const [image, setImage] = useState('')
const [category, setCategory] = useState('custom')
const [icon, setIcon] = useState('IconBrandDocker')
const [ports, setPorts] = useState<PortMapping[]>([{ container: '', host: '' }])
const [volumes, setVolumes] = useState<VolumeMapping[]>([])
const [envVars, setEnvVars] = useState<EnvVar[]>([])
const [memoryMb, setMemoryMb] = useState('')
const [cpus, setCpus] = useState('')
const [submitting, setSubmitting] = useState(false)
const [portConflicts, setPortConflicts] = useState<Array<{ port: number; usedBy: string }>>([])
const [resourceWarnings, setResourceWarnings] = useState<string[]>([])
const [blocked, setBlocked] = useState<string[]>([])
const [forceInstall, setForceInstall] = useState(false)
const [checkingPreflight, setCheckingPreflight] = useState(false)
const [suggestedPort, setSuggestedPort] = useState<number | null>(null)
// On open: prefill from the existing app (edit) or fetch a suggested port (create).
useEffect(() => {
if (!open) return
if (isEdit && initial) {
setFriendlyName(initial.friendly_name ?? '')
setImage(initial.image)
setCategory(initial.category)
setIcon(initial.icon || 'IconBrandDocker')
setPorts(
initial.ports.length
? initial.ports.map((p) => ({ container: String(p.container), host: String(p.host) }))
: [{ container: '', host: '' }]
)
setVolumes(initial.volumes)
setEnvVars(initial.env.map((v) => ({ value: v })))
setMemoryMb(initial.memory_mb != null ? String(initial.memory_mb) : '')
setCpus(initial.cpus != null ? String(initial.cpus) : '')
return
}
api.suggestCustomPort().then((res) => {
if (res?.port) {
setSuggestedPort(res.port)
setPorts([{ container: '', host: String(res.port) }])
}
})
}, [open, isEdit, initial])
// Live preflight: whenever ports, volumes or the image change, debounce a check for port
// conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting.
useEffect(() => {
if (!open) return
const validPorts = ports
.map((p) => parseInt(p.host, 10))
.filter((p) => !isNaN(p))
const validVolumes = volumes.filter((v) => v.host_path && v.container_path)
if (validPorts.length === 0 && validVolumes.length === 0 && !image.trim()) {
setPortConflicts([])
setResourceWarnings([])
setBlocked([])
setCheckingPreflight(false)
return
}
setCheckingPreflight(true)
const handle = setTimeout(async () => {
const res = await api.preflightCustomApp({
image: image.trim() || undefined,
ports: validPorts.length ? validPorts : undefined,
volumes: validVolumes.length ? validVolumes : undefined,
exclude_service: isEdit && initial ? initial.service_name : undefined,
})
if (res) {
setPortConflicts(res.portConflicts ?? [])
setResourceWarnings(res.resourceWarnings ?? [])
setBlocked(res.blocked ?? [])
}
setCheckingPreflight(false)
}, 400)
return () => clearTimeout(handle)
}, [open, ports, volumes, image])
function resetForm() {
setFriendlyName('')
setImage('')
setCategory('custom')
setIcon('IconBrandDocker')
setPorts([{ container: '', host: '' }])
setVolumes([])
setEnvVars([])
setMemoryMb('')
setCpus('')
setPortConflicts([])
setResourceWarnings([])
setBlocked([])
setForceInstall(false)
setSuggestedPort(null)
}
function handleClose() {
resetForm()
onClose()
}
// ── Port row helpers ──────────────────────────────────────────────────────
function updatePort(idx: number, field: keyof PortMapping, value: string) {
setPorts((prev) => prev.map((p, i) => (i === idx ? { ...p, [field]: value } : p)))
}
function addPort() {
const nextHost = suggestedPort ? suggestedPort + ports.length * 10 : 8600 + ports.length * 10
setPorts((prev) => [...prev, { container: '', host: String(nextHost) }])
}
function removePort(idx: number) {
setPorts((prev) => prev.filter((_, i) => i !== idx))
}
// ── Volume row helpers ────────────────────────────────────────────────────
function updateVolume(idx: number, field: keyof VolumeMapping, value: string) {
setVolumes((prev) => prev.map((v, i) => (i === idx ? { ...v, [field]: value } : v)))
}
function addVolume() {
setVolumes((prev) => [...prev, { host_path: '', container_path: '' }])
}
function removeVolume(idx: number) {
setVolumes((prev) => prev.filter((_, i) => i !== idx))
}
// ── Env var helpers ───────────────────────────────────────────────────────
function updateEnv(idx: number, value: string) {
setEnvVars((prev) => prev.map((e, i) => (i === idx ? { value } : e)))
}
function addEnv() {
setEnvVars((prev) => [...prev, { value: '' }])
}
function removeEnv(idx: number) {
setEnvVars((prev) => prev.filter((_, i) => i !== idx))
}
// ── Submit ────────────────────────────────────────────────────────────────
async function handleSubmit() {
if (!friendlyName.trim() || !image.trim()) {
showError('Name and image are required.')
return
}
if (blocked.length > 0) {
showError('Resolve the blocked issues before installing.')
return
}
const validPorts = ports
.filter((p) => p.container && p.host)
.map((p) => ({ container: parseInt(p.container, 10), host: parseInt(p.host, 10) }))
.filter((p) => !isNaN(p.container) && !isNaN(p.host))
const validVolumes = volumes.filter((v) => v.host_path && v.container_path)
const validEnv = envVars.map((e) => e.value).filter(Boolean)
const parsedMemory = parseInt(memoryMb, 10)
const parsedCpus = parseFloat(cpus)
setSubmitting(true)
try {
const common = {
friendly_name: friendlyName.trim(),
image: image.trim(),
ports: validPorts.length ? validPorts : undefined,
volumes: validVolumes.length ? validVolumes : undefined,
env: validEnv.length ? validEnv : undefined,
category,
icon,
memory_mb: !isNaN(parsedMemory) ? parsedMemory : undefined,
cpus: !isNaN(parsedCpus) ? parsedCpus : undefined,
// The user has already acknowledged any conflicts via the "install anyway" checkbox.
force: forceInstall,
}
const result =
isEdit && initial
? await api.updateCustomApp({ service_name: initial.service_name, ...common })
: await api.createCustomApp(common)
if (result?.success && result.service_name) {
resetForm()
onCreated(result.service_name)
} else {
// Check if it's a port conflict error — show warnings and let user force
if (result?.message?.toLowerCase().includes('port') || result?.message?.toLowerCase().includes('conflict')) {
showError(result.message)
} else {
showError(result?.message || 'Failed to create custom app.')
}
}
} catch (err: any) {
showError(err?.message || 'Unexpected error creating custom app.')
} finally {
setSubmitting(false)
}
}
const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0
const hasBlocks = blocked.length > 0
const canSubmit =
friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall)
return (
<StyledModal
title={isEdit ? 'Edit Custom App' : 'Add Custom App'}
open={open}
onCancel={handleClose}
cancelText="Cancel"
onConfirm={handleSubmit}
confirmVariant='primary'
confirmText={isEdit ? 'Save & Recreate' : 'Install'}
confirmIcon="IconBrandDocker"
confirmLoading={submitting}
confirmDisabled={!canSubmit}
large
>
<div className="space-y-6 text-sm">
{/* Image + Name */}
<div className="grid grid-cols-2 gap-4">
<Input
name='image'
label="Docker Image"
placeholder="e.g. nginx:latest"
value={image}
onChange={(e) => setImage(e.target.value)}
required
/>
<Input
name='friendlyName'
label="Display Name"
placeholder="My App"
value={friendlyName}
onChange={(e) => setFriendlyName(e.target.value)}
required
/>
</div>
{/* Category + Icon */}
<div className="grid grid-cols-2 gap-4 items-start">
<Select
name='category'
label='Category'
helpText='Select the most relevant category for this app. This helps with visual organization and filtering.'
value={category}
onChange={(newVal) => setCategory(newVal)}
options={CATEGORY_OPTIONS}
/>
<div className="flex items-end gap-2">
<Select
name='icon'
label='Icon'
helpText='Pick an icon shown on the app card.'
value={icon}
onChange={(newVal) => setIcon(newVal)}
options={ICON_OPTIONS}
className="flex-1 min-w-0"
/>
<div
className="flex-shrink-0 flex items-center justify-center h-[42px] w-[42px] rounded-md border border-border-default bg-surface-secondary"
title="Icon preview"
>
<DynamicIcon icon={icon as DynamicIconName} className="h-6 w-6 text-desert-green" />
</div>
</div>
</div>
{/* Port Mappings */}
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Port Mappings</label>
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addPort}>Add Port</StyledButton>
</div>
{ports.length === 0 && (
<p className="text-xs italic">No port mappings the app won't be accessible from a browser.</p>
)}
<div className="space-y-2">
{ports.map((p, idx) => (
<div key={idx} className="flex items-center gap-2 w-full">
<Input
name={`containerPort${idx}`}
label=''
type="number"
placeholder="Container port"
value={p.container}
onChange={(e) => updatePort(idx, 'container', e.target.value)}
className='w-full'
/>
<span className="text-xs"></span>
<Input
name={`hostPort${idx}`}
label=''
type="number"
placeholder="Host port (8600+)"
value={p.host}
onChange={(e) => updatePort(idx, 'host', e.target.value)}
className='w-full'
/>
<button
type="button"
onClick={() => removePort(idx)}
className="hover:text-desert-red transition-colors cursor-pointer"
>
<IconTrash className="w-5 h-5" />
</button>
</div>
))}
</div>
<p className="text-xs mt-2">Host ports should be in the 8600+ range. Custom apps get ports starting at {suggestedPort ?? 8600}.</p>
{checkingPreflight && (
<p className="text-xs mt-1 italic text-text-muted">Checking port availability</p>
)}
</div>
{/* Volume Mappings */}
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Volume Mounts</label>
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addVolume}>Add Volume</StyledButton>
</div>
{volumes.length === 0 && (
<p className="text-xs italic">No volumes data won't persist across restarts.</p>
)}
<div className="space-y-2">
{volumes.map((v, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
name={`hostPath${idx}`}
label=''
type="text"
placeholder="Host path (absolute)"
value={v.host_path}
onChange={(e) => updateVolume(idx, 'host_path', e.target.value)}
className='w-full'
/>
<span className="text-xs">:</span>
<Input
name={`containerPath${idx}`}
label=''
type="text"
placeholder="Container path"
value={v.container_path}
onChange={(e) => updateVolume(idx, 'container_path', e.target.value)}
className='w-full'
/>
<button
type="button"
onClick={() => removeVolume(idx)}
className="hover:text-desert-red transition-colors cursor-pointer"
>
<IconTrash className="w-5 h-5" />
</button>
</div>
))}
</div>
</div>
{/* Environment Variables */}
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Environment Variables</label>
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addEnv}>Add Variable</StyledButton>
</div>
<div className="space-y-2">
{envVars.map((e, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
name={`envVar${idx}`}
label=''
placeholder="KEY=value"
value={e.value}
onChange={(ev) => updateEnv(idx, ev.target.value)}
className='w-full font-mono'
/>
<button
type="button"
onClick={() => removeEnv(idx)}
className="hover:text-desert-red transition-colors cursor-pointer"
>
<IconTrash className="w-5 h-5" />
</button>
</div>
))}
{envVars.length === 0 && (
<p className="text-xs italic">No environment variables provided.</p>
)}
</div>
</div>
{/* Advanced: resource limits */}
<div>
<label className="text-sm font-medium">Resource Limits (optional)</label>
<div className="grid grid-cols-2 gap-4 mt-1">
<Input
name='memoryMb'
label=''
type="number"
placeholder="Memory (MB) — default 1024"
value={memoryMb}
onChange={(e) => setMemoryMb(e.target.value)}
className='w-full'
/>
<Input
name='cpus'
label=''
type="number"
placeholder="CPUs — default 1"
value={cpus}
onChange={(e) => setCpus(e.target.value)}
className='w-full'
/>
</div>
<p className="text-xs mt-1 italic">Caps prevent a runaway container from starving the host. Leave blank to use the defaults (1024 MB / 1 CPU).</p>
</div>
{/* Hard blocks — must be resolved before installing */}
{hasBlocks && (
<div className="space-y-2">
{blocked.map((b, i) => (
<Alert key={i} type="error" title="Not allowed" message={b} />
))}
</div>
)}
{/* Warnings */}
{hasWarnings && (
<div className="space-y-2">
{portConflicts.map((c) => (
<Alert
key={c.port}
type="warning"
title={`Port ${c.port} is already in use`}
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
/>
))}
{resourceWarnings.map((w, i) => (
<Alert key={i} type="warning" title="Resource warning" message={w} />
))}
<label className="flex items-center gap-2 cursor-pointer select-none mt-1">
<input
type="checkbox"
checked={forceInstall}
onChange={(e) => setForceInstall(e.target.checked)}
className="accent-desert-orange h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">I understand install anyway</span>
</label>
</div>
)}
<p className="text-sm">
Containers are created with <code className="font-mono">--restart=unless-stopped</code>. Data is not persisted unless you add volume mounts above.
</p>
</div>
</StyledModal>
)
}

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import api from '~/lib/api'
interface ServiceLogsModalProps {
serviceName: string
friendlyName: string
open: boolean
onClose: () => void
}
/** Shows the tail of a service container's logs with a manual refresh. */
export default function ServiceLogsModal({
serviceName,
friendlyName,
open,
onClose,
}: ServiceLogsModalProps) {
const [logs, setLogs] = useState('')
const [loading, setLoading] = useState(false)
async function load() {
setLoading(true)
const res = await api.getServiceLogs(serviceName, 500)
setLogs(res?.success ? res.logs || '' : 'Unable to load logs for this container.')
setLoading(false)
}
useEffect(() => {
if (open) load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, serviceName])
return (
<StyledModal
title={`Logs — ${friendlyName}`}
open={open}
onCancel={onClose}
cancelText="Close"
onConfirm={load}
confirmText="Refresh"
confirmIcon="IconRefresh"
confirmVariant="outline"
confirmLoading={loading}
large
>
<pre className="text-xs font-mono whitespace-pre-wrap break-all max-h-[60vh] overflow-auto bg-surface-secondary rounded-md p-3 text-text-primary text-left">
{logs || (loading ? 'Loading…' : 'No log output.')}
</pre>
</StyledModal>
)
}

View File

@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import { formatBytes } from '~/lib/util'
import api from '~/lib/api'
interface Stats {
cpuPercent: number
memUsageBytes: number
memLimitBytes: number
memPercent: number
}
interface ServiceStatsModalProps {
serviceName: string
friendlyName: string
open: boolean
onClose: () => void
}
function Bar({ percent, label, value }: { percent: number; label: string; value: string }) {
const clamped = Math.min(100, Math.max(0, percent))
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-medium text-text-primary">{label}</span>
<span className="text-text-muted font-mono">{value}</span>
</div>
<div className="h-2 w-full rounded-full bg-surface-secondary overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
clamped > 90 ? 'bg-desert-red' : clamped > 70 ? 'bg-desert-orange' : 'bg-desert-green'
}`}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
)
}
/** Polls and displays live CPU/memory usage for a running service container. */
export default function ServiceStatsModal({
serviceName,
friendlyName,
open,
onClose,
}: ServiceStatsModalProps) {
const [stats, setStats] = useState<Stats | null>(null)
const [running, setRunning] = useState(true)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) return
let cancelled = false
async function poll() {
const res = await api.getServiceStats(serviceName)
if (cancelled || !res) return
setRunning(res.running)
setStats(res.stats)
setLoading(false)
}
setLoading(true)
poll()
const id = setInterval(poll, 2000)
return () => {
cancelled = true
clearInterval(id)
}
}, [open, serviceName])
return (
<StyledModal
title={`Stats — ${friendlyName}`}
open={open}
onCancel={onClose}
cancelText="Close"
>
<div className="space-y-4 text-sm">
{!running ? (
<p className="text-text-muted text-center py-6">
This app is not running. Start it to see live resource usage.
</p>
) : !stats ? (
<p className="text-text-muted text-center py-6">
{loading ? 'Loading…' : 'No stats available.'}
</p>
) : (
<>
<Bar label="CPU" percent={stats.cpuPercent} value={`${stats.cpuPercent.toFixed(1)}%`} />
<Bar
label="Memory"
percent={stats.memPercent}
value={`${formatBytes(stats.memUsageBytes)} / ${formatBytes(stats.memLimitBytes)} (${stats.memPercent.toFixed(1)}%)`}
/>
<p className="text-xs text-text-muted">Updates every 2 seconds.</p>
</>
)}
</div>
</StyledModal>
)
}

View File

@ -13,6 +13,7 @@ export type StyledModalProps = {
confirmIcon?: StyledButtonProps['icon']
confirmVariant?: StyledButtonProps['variant']
confirmLoading?: boolean
confirmDisabled?: boolean
open: boolean
onCancel?: () => void
onConfirm?: () => void
@ -33,6 +34,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
confirmIcon,
confirmVariant = 'action',
confirmLoading = false,
confirmDisabled = false,
onCancel,
onConfirm,
icon,
@ -94,6 +96,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
}}
icon={confirmIcon}
loading={confirmLoading}
disabled={confirmDisabled}
>
{confirmText}
</StyledButton>

View File

@ -0,0 +1,106 @@
import classNames from "classnames";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
import { IconChevronDown } from "@tabler/icons-react";
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
export interface SelectProps<T = string> {
name: string;
label: string;
value: T;
onChange: (value: T) => void;
options: SelectOption<T>[];
helpText?: string;
placeholder?: string;
className?: string;
labelClassName?: string;
selectClassName?: string;
containerClassName?: string;
error?: boolean;
required?: boolean;
disabled?: boolean;
}
const Select = <T,>({
name,
label,
value,
onChange,
options,
helpText,
placeholder,
className,
labelClassName,
selectClassName,
containerClassName,
error,
required,
disabled,
}: SelectProps<T>) => {
const selectedOption = options.find((o) => o.value === value);
return (
<div className={classNames(className)}>
<label
htmlFor={name}
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
>
{label}{required ? "*" : ""}
</label>
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
<div className={classNames("mt-1.5", containerClassName)}>
<Listbox value={value} onChange={onChange} disabled={disabled}>
<div className="relative">
<ListboxButton
id={name}
className={classNames(
"flex items-center w-full rounded-md bg-surface-primary px-3 py-2 text-base border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 text-left",
selectedOption ? "text-text-primary" : "text-text-muted",
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : "",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
selectClassName
)}
>
<span className="flex-1 truncate">
{selectedOption ? selectedOption.label : (placeholder ?? label)}
</span>
<IconChevronDown className="w-4 h-4 text-text-muted ml-2 shrink-0 transition-transform duration-150 group-data-[open]:rotate-180 data-[open]:rotate-180 ui-open:rotate-180" />
</ListboxButton>
<ListboxOptions
transition
anchor="bottom start"
className={classNames(
"z-50 w-[var(--button-width)] rounded-md bg-surface-primary border border-border-default shadow-lg max-h-60 overflow-auto",
"transition duration-100 ease-out data-[closed]:opacity-0 data-[closed]:scale-95",
"mt-1 focus:outline-none"
)}
>
{options.map((option, index) => (
<ListboxOption
key={index}
value={option.value}
disabled={option.disabled}
className={classNames(
"px-3 py-2 text-sm text-text-primary select-none",
option.disabled
? "opacity-40 cursor-not-allowed"
: "cursor-pointer data-[focus]:bg-surface-secondary data-[selected]:font-medium data-[selected]:text-primary"
)}
>
{option.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
</div>
);
};
export default Select;

View File

@ -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 },

View File

@ -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()

View File

@ -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

View File

@ -42,10 +42,10 @@ const SYSTEM_ITEMS = [
poweredBy: null,
},
{
label: 'Install Apps',
to: '/settings/apps',
label: 'Supply Depot',
to: '/supply-depot',
target: '',
description: 'Not seeing your favorite app? Install it here!',
description: 'Browse and install curated apps, or add your own Docker container',
icon: <IconPlus size={48} />,
installed: true,
displayOrder: 51,

View File

@ -0,0 +1,812 @@
import { Head } from '@inertiajs/react'
import { useEffect, useRef, useState } from 'react'
import {
IconAlertTriangle,
IconBox,
IconBrandDocker,
IconChartBar,
IconCloudDownload,
IconFileText,
IconPackage,
IconPencil,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconSearch,
IconTrash,
} from '@tabler/icons-react'
import AppLayout from '~/layouts/AppLayout'
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
import StyledButton from '~/components/StyledButton'
import StyledModal from '~/components/StyledModal'
import InstallActivityFeed from '~/components/InstallActivityFeed'
import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert'
import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
import ServiceLogsModal from '~/components/ServiceLogsModal'
import ServiceStatsModal from '~/components/ServiceStatsModal'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import useErrorNotification from '~/hooks/useErrorNotification'
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
import { ServiceSlim } from '../../types/services'
import { getServiceLink } from '~/lib/navigation'
import api from '~/lib/api'
import { toTitleCase } from '../../app/utils/misc'
const CATEGORIES = [
{ id: 'all', label: 'All' },
{ id: 'installed', label: 'Installed' },
{ id: 'productivity', label: 'Productivity' },
{ id: 'media', label: 'Media' },
{ id: 'security', label: 'Security' },
{ id: 'networking', label: 'Networking' },
{ id: 'utility', label: 'Utility' },
{ id: 'ai', label: 'AI' },
{ id: 'education', label: 'Education' },
{ id: 'custom', label: 'Custom' },
]
const CATEGORY_COLORS: Record<string, string> = {
productivity: 'border border-desert-green-light bg-desert-green-lighter text-desert-green-dark',
media: 'border border-desert-tan-light bg-desert-tan-lighter text-desert-tan-dark',
security: 'border border-desert-red-light bg-desert-red-lighter text-desert-red-dark',
networking: 'border border-desert-stone-light bg-desert-stone-lighter text-desert-stone-dark',
utility: 'border border-desert-olive-light bg-desert-olive-lighter text-desert-olive-dark',
ai: 'border border-desert-green bg-desert-green-light text-desert-green-darker',
education: 'border border-desert-orange-light bg-desert-orange-lighter text-desert-orange-dark',
custom: 'border border-border-subtle bg-surface-secondary text-text-secondary',
}
type Modal =
| { type: 'install'; service: ServiceSlim }
| { type: 'start'; service: ServiceSlim }
| { type: 'stop'; service: ServiceSlim }
| { type: 'restart'; service: ServiceSlim }
| { type: 'reinstall'; service: ServiceSlim }
| { type: 'delete'; service: ServiceSlim }
| { type: 'logs'; service: ServiceSlim }
| { type: 'stats'; service: ServiceSlim }
| null
export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) {
const { showError } = useErrorNotification()
const installActivity = useServiceInstallationActivity()
const [activeCategory, setActiveCategory] = useState('all')
const [search, setSearch] = useState('')
const [modal, setModal] = useState<Modal>(null)
const [loading, setLoading] = useState(false)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [customAppOpen, setCustomAppOpen] = useState(false)
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
const [removeImage, setRemoveImage] = useState(false)
// Preflight state — scoped to the current install modal
const [preflight, setPreflight] = useState<{
portConflicts: Array<{ port: number; usedBy: string }>
resourceWarnings: string[]
} | null>(null)
const [preflightLoading, setPreflightLoading] = useState(false)
const [forceInstall, setForceInstall] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Auto-reload when installation completes
useEffect(() => {
if (!installActivity.length) return
if (installActivity.some((a) => a.type === 'completed' || a.type === 'update-complete')) {
setTimeout(() => window.location.reload(), 3000)
}
}, [installActivity])
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpenDropdown(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
// Run preflight when install modal opens
useEffect(() => {
if (modal?.type !== 'install') {
setPreflight(null)
setForceInstall(false)
return
}
setPreflightLoading(true)
api
.preflightCheck(modal.service.service_name)
.then((res) => {
if (res) setPreflight(res)
})
.catch(() => {}) // non-fatal; proceed without warnings
.finally(() => setPreflightLoading(false))
}, [modal])
// ── Filtering ─────────────────────────────────────────────────────────────
const filteredServices = props.system.services.filter((s) => {
if (activeCategory === 'installed' && !s.installed) return false
if (activeCategory !== 'all' && activeCategory !== 'installed') {
if (s.category !== activeCategory) return false
}
if (search.trim()) {
const q = search.toLowerCase()
return (
s.friendly_name?.toLowerCase().includes(q) ||
s.description?.toLowerCase().includes(q) ||
s.powered_by?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
}
return true
})
const installedServices = filteredServices.filter((s) => s.installed)
const availableServices = filteredServices.filter((s) => !s.installed)
// ── Actions ───────────────────────────────────────────────────────────────
async function handleInstall(service: ServiceSlim) {
const hasWarnings =
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
if (hasWarnings && !forceInstall) return
setLoading(true)
setModal(null)
const result = await api.installService(service.service_name)
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to start installation.')
}
async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') {
setModal(null)
setLoading(true)
const result = await api.affectService(service.service_name, action)
setLoading(false)
if (!result?.success) showError(result?.message || `Failed to ${action} service.`)
else setTimeout(() => window.location.reload(), 1500)
}
async function handleForceReinstall(service: ServiceSlim) {
setModal(null)
setLoading(true)
const result = await api.forceReinstallService(service.service_name)
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to start reinstall.')
}
async function handleDelete(service: ServiceSlim) {
setModal(null)
setLoading(true)
const result = await api.deleteCustomApp(service.service_name, removeImage)
setRemoveImage(false)
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to delete app.')
else setTimeout(() => window.location.reload(), 1000)
}
async function handleUpdate(service: ServiceSlim) {
setOpenDropdown(null)
setLoading(true)
const result = await api.updateCustomAppImage(service.service_name)
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to update app.')
else setTimeout(() => window.location.reload(), 1500)
}
function handleCustomAppCreated() {
setCustomAppOpen(false)
// Page will reload when installation completes via broadcast
}
async function handleEdit(service: ServiceSlim) {
setOpenDropdown(null)
setLoading(true)
const res = await api.getCustomApp(service.service_name)
setLoading(false)
if (res?.success && res.app) {
setEditApp(res.app)
} else {
showError('Could not load this app for editing.')
}
}
function handleEdited() {
setEditApp(null)
setTimeout(() => window.location.reload(), 1500)
}
// ── Install modal helpers ─────────────────────────────────────────────────
const hasPreflightWarnings =
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
return (
<AppLayout>
<Head title="Supply Depot" />
{loading && <LoadingSpinner fullscreen text="Working..." />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
{/* Green header band */}
<div className="relative bg-desert-green px-6 py-5 overflow-hidden">
{/* Diagonal line pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.1) 10px,
rgba(255, 255, 255, 0.1) 20px
)`,
}}
/>
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
</div>
<div className="relative flex items-center gap-3">
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
<div>
<h1 className="text-2xl font-bold text-white uppercase tracking-wide leading-tight">
Supply Depot
</h1>
<p className="text-sm text-white/70 mt-1 max-w-xl">
Browse and install curated apps, or add your own custom apps by providing a Docker
image.
</p>
</div>
</div>
</div>
{/* Controls body */}
<div className="p-6 space-y-4">
{/* Activity feed (shown only while installing) */}
{installActivity.length > 0 && (
<InstallActivityFeed activity={installActivity} withHeader />
)}
{/* Search + Add Custom App */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted h-4 w-4" />
<input
type="text"
placeholder="Search apps..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-md bg-surface-secondary border border-desert-stone-lighter text-text-primary text-sm focus:outline-none focus:ring-1 focus:ring-desert-green placeholder:text-text-muted/50"
/>
</div>
<StyledButton
icon="IconBrandDocker"
variant="outline"
onClick={() => setCustomAppOpen(true)}
>
Add Custom App
</StyledButton>
</div>
{/* Category filters */}
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${
activeCategory === cat.id
? 'bg-desert-green text-white border-desert-green'
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
}`}
>
{cat.label}
</button>
))}
</div>
</div>
{/* Bottom accent bar */}
<div className="h-1 bg-desert-green" />
</div>
{/* App cards */}
{filteredServices.length === 0 ? (
<div className="text-center py-16">
<IconPackage className="mx-auto mb-3 opacity-40 text-desert-stone-light" size={48} />
<p className="text-text-muted">No apps match your filter.</p>
</div>
) : (
<div className="space-y-10">
{installedServices.length > 0 && (
<section>
<StyledSectionHeader title={`Installed (${installedServices.length})`} />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{installedServices.map((service) => (
<AppCard
key={service.service_name}
service={service}
openDropdown={openDropdown}
dropdownRef={dropdownRef}
onOpenDropdown={setOpenDropdown}
onInstall={() => setModal({ type: 'install', service })}
onStart={() => setModal({ type: 'start', service })}
onStop={() => setModal({ type: 'stop', service })}
onRestart={() => setModal({ type: 'restart', service })}
onReinstall={() => setModal({ type: 'reinstall', service })}
onDelete={() => setModal({ type: 'delete', service })}
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
onUpdate={() => handleUpdate(service)}
/>
))}
</div>
</section>
)}
{availableServices.length > 0 && (
<section>
<StyledSectionHeader title={`Available (${availableServices.length})`} />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{availableServices.map((service) => (
<AppCard
key={service.service_name}
service={service}
openDropdown={openDropdown}
dropdownRef={dropdownRef}
onOpenDropdown={setOpenDropdown}
onInstall={() => setModal({ type: 'install', service })}
onStart={() => setModal({ type: 'start', service })}
onStop={() => setModal({ type: 'stop', service })}
onRestart={() => setModal({ type: 'restart', service })}
onReinstall={() => setModal({ type: 'reinstall', service })}
onDelete={() => setModal({ type: 'delete', service })}
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
onUpdate={() => handleUpdate(service)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
{/* ── Modals ─────────────────────────────────────────────────────────── */}
{/* Install modal */}
{modal?.type === 'install' && (
<StyledModal
title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => setModal(null)}
onConfirm={() => handleInstall(modal.service)}
confirmText="Install"
confirmIcon="IconDownload"
confirmVariant="primary"
confirmLoading={loading}
>
<div className="space-y-3 text-sm text-text-muted">
<p>
This will download and start <strong className="text-text-primary">{modal.service.friendly_name}</strong>
{modal.service.ui_location && (
<> on port <strong className="text-text-primary">{modal.service.ui_location}</strong></>
)}.
</p>
{modal.service.powered_by && (
<p className="text-xs">Powered by {modal.service.powered_by}</p>
)}
{preflightLoading && (
<div className="flex items-center gap-2 text-xs text-text-muted py-2">
<span className="animate-spin inline-block w-3 h-3 border border-desert-green border-t-transparent rounded-full" />
Checking for conflicts
</div>
)}
{!preflightLoading && preflight && hasPreflightWarnings && (
<div className="space-y-2 pt-1">
{preflight.portConflicts.map((c) => (
<Alert
key={c.port}
type="warning"
title={`Port ${c.port} already in use`}
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
/>
))}
{preflight.resourceWarnings.map((w, i) => (
<Alert key={i} type="warning" title="Resource warning" message={w} />
))}
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
<input
type="checkbox"
checked={forceInstall}
onChange={(e) => setForceInstall(e.target.checked)}
className="accent-desert-orange h-4 w-4 rounded"
/>
<span className="text-xs text-text-muted">I understand install anyway</span>
</label>
</div>
)}
</div>
</StyledModal>
)}
{/* Start modal */}
{modal?.type === 'start' && (
<StyledModal
title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => setModal(null)}
onConfirm={() => handleAffect(modal.service, 'start')}
confirmText="Start"
confirmIcon="IconPlayerPlay"
confirmVariant="primary"
confirmLoading={loading}
>
<p className="text-sm text-text-muted">This will start the container.</p>
</StyledModal>
)}
{/* Stop modal */}
{modal?.type === 'stop' && (
<StyledModal
title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => setModal(null)}
onConfirm={() => handleAffect(modal.service, 'stop')}
confirmText="Stop"
confirmIcon="IconPlayerStop"
confirmVariant="action"
confirmLoading={loading}
>
<p className="text-sm text-text-muted">The container will be stopped. Your data is preserved.</p>
</StyledModal>
)}
{/* Restart modal */}
{modal?.type === 'restart' && (
<StyledModal
title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => setModal(null)}
onConfirm={() => handleAffect(modal.service, 'restart')}
confirmText="Restart"
confirmIcon="IconRefresh"
confirmVariant="action"
confirmLoading={loading}
>
<p className="text-sm text-text-muted">The container will be briefly stopped and restarted.</p>
</StyledModal>
)}
{/* Force reinstall modal */}
{modal?.type === 'reinstall' && (
<StyledModal
title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => setModal(null)}
onConfirm={() => handleForceReinstall(modal.service)}
confirmText="Wipe & Reinstall"
confirmIcon="IconRefresh"
confirmVariant="danger"
confirmLoading={loading}
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
>
<div className="space-y-2 text-sm text-text-muted">
<p className="font-semibold text-desert-red">This will delete all app data and cannot be undone.</p>
<p>The container and its associated volumes will be removed, then a fresh installation will begin.</p>
</div>
</StyledModal>
)}
{/* Delete custom app modal */}
{modal?.type === 'delete' && (
<StyledModal
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
setRemoveImage(false)
setModal(null)
}}
onConfirm={() => handleDelete(modal.service)}
confirmText="Delete"
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={loading}
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
>
<div className="space-y-3 text-sm text-text-muted">
<p className="font-semibold text-desert-red">This will permanently remove this custom app.</p>
<p>The container will be stopped and removed. Host volume data will remain on disk.</p>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={removeImage}
onChange={(e) => setRemoveImage(e.target.checked)}
className="accent-desert-red h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
</label>
</div>
</StyledModal>
)}
{/* Logs modal */}
{modal?.type === 'logs' && (
<ServiceLogsModal
serviceName={modal.service.service_name}
friendlyName={modal.service.friendly_name ?? modal.service.service_name}
open
onClose={() => setModal(null)}
/>
)}
{/* Stats modal */}
{modal?.type === 'stats' && (
<ServiceStatsModal
serviceName={modal.service.service_name}
friendlyName={modal.service.friendly_name ?? modal.service.service_name}
open
onClose={() => setModal(null)}
/>
)}
{/* Custom app creation modal */}
<CustomAppModal
open={customAppOpen}
onClose={() => setCustomAppOpen(false)}
onCreated={handleCustomAppCreated}
showError={showError}
/>
{/* Custom app edit modal */}
<CustomAppModal
open={!!editApp}
mode="edit"
initial={editApp}
onClose={() => setEditApp(null)}
onCreated={handleEdited}
showError={showError}
/>
</AppLayout>
)
}
// ── App Card component ────────────────────────────────────────────────────────
interface AppCardProps {
service: ServiceSlim
openDropdown: string | null
dropdownRef: React.RefObject<HTMLDivElement | null>
onOpenDropdown: (name: string | null) => void
onInstall: () => void
onStart: () => void
onStop: () => void
onRestart: () => void
onReinstall: () => void
onDelete: () => void
onLogs: () => void
onStats: () => void
onEdit: () => void
onUpdate: () => void
}
function AppCard({
service,
openDropdown,
dropdownRef,
onOpenDropdown,
onInstall,
onStart,
onStop,
onRestart,
onReinstall,
onDelete,
onLogs,
onStats,
onEdit,
onUpdate,
}: AppCardProps) {
const isRunning = service.status === 'running'
const isStopped = service.installed && !isRunning
const catColor = service.category ? CATEGORY_COLORS[service.category] ?? CATEGORY_COLORS.custom : CATEGORY_COLORS.custom
const isDropdownOpen = openDropdown === service.service_name
function toggleDropdown(e: React.MouseEvent) {
e.stopPropagation()
onOpenDropdown(isDropdownOpen ? null : service.service_name)
}
return (
<div
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 overflow-hidden ${
service.installed
? 'border-desert-stone-light'
: 'border-desert-stone-lighter hover:border-desert-stone-light'
}`}
>
{/* Installed accent spine */}
{service.installed ? (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-desert-green" />
) : null}
{/* Top row: icon + status badge */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-desert-green-lighter border border-desert-green-light flex items-center justify-center flex-shrink-0">
{service.icon ? (
<DynamicIcon icon={service.icon as DynamicIconName} className="h-7 w-7 text-desert-green" />
) : (
<IconBrandDocker className="h-7 w-7 text-text-muted" />
)}
</div>
<div className="min-w-0">
<p className="font-semibold text-text-primary text-sm leading-tight truncate">
{service.friendly_name ?? service.service_name}
</p>
{service.powered_by && (
<p className="text-xs text-text-muted truncate">{service.powered_by}</p>
)}
</div>
</div>
{/* Status indicator */}
<div className="flex-shrink-0 ml-2">
{service.installation_status === 'installing' ? (
<span className="flex items-center gap-1 text-xs text-desert-orange">
<span className="animate-spin inline-block w-3 h-3 border border-desert-orange border-t-transparent rounded-full" />
Installing
</span>
) : isRunning ? (
<span className="flex items-center gap-1 text-xs text-desert-green">
<span className="h-2 w-2 rounded-full bg-desert-green" />
Running
</span>
) : isStopped ? (
<span className="flex items-center gap-1 text-xs text-text-muted">
<span className="h-2 w-2 rounded-full bg-text-muted" />
Stopped
</span>
) : null}
</div>
</div>
{/* Description */}
{service.description && (
<p className="text-xs text-text-muted leading-relaxed mb-3 flex-1 line-clamp-2">
{service.description}
</p>
)}
{/* Metadata row: category badge + port pill */}
<div className="flex items-center gap-2 mb-4 flex-wrap">
{service.category && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${catColor}`}>
{toTitleCase(service.category)}
</span>
)}
{service.is_custom ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-surface-secondary text-text-muted border border-surface-secondary">
custom
</span>
) : null}
{service.ui_location && !service.ui_location.startsWith('/') && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
:{service.ui_location}
</span>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{!service.installed && service.installation_status !== 'installing' && (
<StyledButton
size="sm"
variant="primary"
icon="IconDownload"
onClick={onInstall}
fullWidth
>
Install
</StyledButton>
)}
{service.installed ? (
<>
{/* Open button */}
{service.ui_location && (
<a
href={getServiceLink(service.ui_location)}
target="_blank"
rel="noopener noreferrer"
className="flex-1"
>
<StyledButton size="sm" variant="primary" icon="IconExternalLink" fullWidth>
Open
</StyledButton>
</a>
)}
{/* Manage dropdown */}
<div className="relative" ref={isDropdownOpen ? dropdownRef : null}>
<StyledButton size="sm" variant="outline" onClick={toggleDropdown} icon="IconChevronDown">
Manage
</StyledButton>
{isDropdownOpen && (
<div className="absolute right-0 bottom-full mb-1 w-44 bg-surface-primary border border-surface-secondary rounded-lg shadow-xl z-20 overflow-hidden">
{isStopped && (
<DropdownItem icon={<IconPlayerPlay className="h-4 w-4" />} label="Start" onClick={onStart} />
)}
{isRunning && (
<DropdownItem icon={<IconPlayerStop className="h-4 w-4" />} label="Stop" onClick={onStop} />
)}
<DropdownItem icon={<IconRefresh className="h-4 w-4" />} label="Restart" onClick={onRestart} />
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
{service.is_custom ? (
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
) : null}
{service.is_custom ? (
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
) : null}
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
{service.is_custom ? (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
): null}
</div>
)}
</div>
</>
) : null}
{service.installation_status === 'installing' && (
<div className="flex-1 flex items-center justify-center text-xs text-text-muted gap-1 py-1">
<span className="animate-spin inline-block w-3 h-3 border border-desert-green border-t-transparent rounded-full" />
In progress
</div>
)}
</div>
</div>
)
}
function DropdownItem({
icon,
label,
onClick,
danger = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
danger?: boolean
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${
danger
? 'text-desert-red hover:bg-desert-red/10'
: 'text-text-primary hover:bg-surface-secondary'
}`}
>
{icon}
{label}
</button>
)
}

View File

@ -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'])

View File

@ -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)
})

View File

@ -14,4 +14,6 @@ export type ServiceSlim = Pick<
| 'display_order'
| 'container_image'
| 'available_update_version'
| 'is_custom'
| 'category'
> & { status?: string }