mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-16 05:40:23 +08:00
"Address" was vague for a field that takes a URL. "No scheme? We'll default to http://" named a concept most people do not have a word for, and it was not needed: the live preview already shows http:// appearing the moment someone types a bare address, which demonstrates the behaviour rather than describing it. The preview stays, the sentence goes. The two link tile validation messages follow the same wording. The custom URL override message is left alone; it belongs to a different feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
931 lines
41 KiB
TypeScript
931 lines
41 KiB
TypeScript
import { DockerService } from '#services/docker_service';
|
||
import { SystemService } from '#services/system_service'
|
||
import { SystemUpdateService } from '#services/system_update_service'
|
||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||
import { AutoUpdateService } from '#services/auto_update_service'
|
||
import { AppAutoUpdateService } from '#services/app_auto_update_service'
|
||
import { ContentAutoUpdateService } from '#services/content_auto_update_service'
|
||
import { DownloadService } from '#services/download_service'
|
||
import { QueueService } from '#services/queue_service'
|
||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||
import {
|
||
affectServiceValidator,
|
||
checkLatestVersionValidator,
|
||
customAppValidator,
|
||
deleteCustomAppValidator,
|
||
installServiceValidator,
|
||
preflightCustomValidator,
|
||
preflightValidator,
|
||
serviceLogsValidator,
|
||
subscribeToReleaseNotesValidator,
|
||
uninstallServiceValidator,
|
||
updateCustomAppValidator,
|
||
updateServiceValidator,
|
||
setServiceAutoUpdateValidator,
|
||
setServiceCustomUrlValidator,
|
||
createLinkTileValidator,
|
||
updateLinkTileValidator,
|
||
deleteLinkTileValidator,
|
||
normalizeCustomUrl,
|
||
} from '#validators/system'
|
||
import {
|
||
DEFAULT_CPUS,
|
||
DEFAULT_MEMORY_MB,
|
||
evaluateCustomApp,
|
||
} from '#services/custom_app_guard'
|
||
import { DEFAULT_LINK_TILE_ICON, isLinkTileIcon } from '../../constants/link_tile_icons.js'
|
||
import { inject } from '@adonisjs/core'
|
||
import type { HttpContext } from '@adonisjs/core/http'
|
||
import logger from '@adonisjs/core/services/logger'
|
||
import Service from '#models/service'
|
||
import { DEFAULT_LINK_TILE_COLOR } from '../../constants/link_tile_colors.js'
|
||
|
||
@inject()
|
||
export default class SystemController {
|
||
constructor(
|
||
private systemService: SystemService,
|
||
private dockerService: DockerService,
|
||
private systemUpdateService: SystemUpdateService,
|
||
private containerRegistryService: ContainerRegistryService
|
||
) { }
|
||
|
||
async getInternetStatus({ }: HttpContext) {
|
||
return await this.systemService.getInternetStatus();
|
||
}
|
||
|
||
async getSystemInfo({ }: HttpContext) {
|
||
return await this.systemService.getSystemInfo();
|
||
}
|
||
|
||
async getServices({ }: HttpContext) {
|
||
return await this.systemService.getServices({ installedOnly: true });
|
||
}
|
||
|
||
async installService({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(installServiceValidator);
|
||
|
||
const result = await this.dockerService.createContainerPreflight(payload.service_name);
|
||
if (result.success) {
|
||
response.send({ success: true, message: result.message });
|
||
} else {
|
||
response.status(400).send({ success: false, message: result.message });
|
||
}
|
||
}
|
||
|
||
async affectService({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(affectServiceValidator);
|
||
const result = await this.dockerService.affectContainer(payload.service_name, payload.action);
|
||
if (!result) {
|
||
response.internalServerError({ error: 'Failed to affect service' });
|
||
return;
|
||
}
|
||
response.send({ success: result.success, message: result.message });
|
||
}
|
||
|
||
async checkLatestVersion({ request }: HttpContext) {
|
||
const payload = await request.validateUsing(checkLatestVersionValidator)
|
||
return await this.systemService.checkLatestVersion(payload.force);
|
||
}
|
||
|
||
async forceReinstallService({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(installServiceValidator);
|
||
const result = await this.dockerService.forceReinstall(payload.service_name);
|
||
if (!result) {
|
||
response.internalServerError({ error: 'Failed to force reinstall service' });
|
||
return;
|
||
}
|
||
response.send({ success: result.success, message: result.message });
|
||
}
|
||
|
||
async requestSystemUpdate({ response }: HttpContext) {
|
||
if (!this.systemUpdateService.isSidecarAvailable()) {
|
||
response.status(503).send({
|
||
success: false,
|
||
error: 'Update sidecar is not available. Ensure the updater container is running.',
|
||
});
|
||
return;
|
||
}
|
||
|
||
const result = await this.systemUpdateService.requestUpdate();
|
||
|
||
if (result.success) {
|
||
response.send({
|
||
success: true,
|
||
message: result.message,
|
||
note: 'Monitor update progress via GET /api/system/update/status. The connection may drop during container restart.',
|
||
});
|
||
} else {
|
||
response.status(409).send({
|
||
success: false,
|
||
error: result.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
async getSystemUpdateStatus({ response }: HttpContext) {
|
||
const status = this.systemUpdateService.getUpdateStatus();
|
||
|
||
if (!status) {
|
||
response.status(500).send({
|
||
error: 'Failed to retrieve update status',
|
||
});
|
||
return;
|
||
}
|
||
|
||
response.send(status);
|
||
}
|
||
|
||
async getSystemUpdateLogs({ response }: HttpContext) {
|
||
const logs = this.systemUpdateService.getUpdateLogs();
|
||
response.send({ logs });
|
||
}
|
||
|
||
async getAutoUpdateStatus({ response }: HttpContext) {
|
||
// Construct inline reusing already-injected singletons + the QueueService
|
||
// singleton (its constructor is private to prevent Redis connection leaks,
|
||
// so we must not let the container new a fresh one).
|
||
const autoUpdateService = new AutoUpdateService(
|
||
this.dockerService,
|
||
new DownloadService(QueueService.getInstance()),
|
||
this.systemService,
|
||
this.systemUpdateService,
|
||
this.containerRegistryService
|
||
)
|
||
|
||
try {
|
||
const status = await autoUpdateService.getStatus()
|
||
response.send(status)
|
||
} catch (error) {
|
||
logger.error({ err: error }, '[SystemController] Failed to get auto-update status')
|
||
response.status(500).send({ error: 'Failed to retrieve auto-update status' })
|
||
}
|
||
}
|
||
|
||
async getAppAutoUpdateStatus({ response }: HttpContext) {
|
||
// Constructed inline reusing already-injected singletons + the QueueService
|
||
// singleton (its constructor is private to prevent Redis connection leaks),
|
||
// mirroring getAutoUpdateStatus. Apps need no SystemUpdateService (no sidecar).
|
||
const appAutoUpdateService = new AppAutoUpdateService(
|
||
this.dockerService,
|
||
new DownloadService(QueueService.getInstance()),
|
||
this.systemService,
|
||
this.containerRegistryService
|
||
)
|
||
|
||
try {
|
||
const status = await appAutoUpdateService.getStatus()
|
||
response.send(status)
|
||
} catch (error) {
|
||
logger.error({ err: error }, '[SystemController] Failed to get app auto-update status')
|
||
response.status(500).send({ error: 'Failed to retrieve app auto-update status' })
|
||
}
|
||
}
|
||
|
||
async getContentAutoUpdateStatus({ response }: HttpContext) {
|
||
// Mirrors getAppAutoUpdateStatus. Content auto-update needs only the
|
||
// DownloadService (for the active-download pre-flight); the catalog and
|
||
// collection-update services default-construct inside the service.
|
||
const contentAutoUpdateService = new ContentAutoUpdateService(
|
||
new DownloadService(QueueService.getInstance())
|
||
)
|
||
|
||
try {
|
||
const status = await contentAutoUpdateService.getStatus()
|
||
response.send(status)
|
||
} catch (error) {
|
||
logger.error({ err: error }, '[SystemController] Failed to get content auto-update status')
|
||
response.status(500).send({ error: 'Failed to retrieve content auto-update status' })
|
||
}
|
||
}
|
||
|
||
async setServiceAutoUpdate({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(setServiceAutoUpdateValidator)
|
||
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` })
|
||
}
|
||
|
||
service.auto_update_enabled = payload.enabled
|
||
// Re-enabling clears any prior self-disable so the app gets a fresh start.
|
||
if (payload.enabled) {
|
||
service.auto_update_consecutive_failures = 0
|
||
service.auto_update_disabled_reason = null
|
||
}
|
||
await service.save()
|
||
|
||
return response.send({ success: true, message: 'App auto-update preference updated' })
|
||
}
|
||
|
||
|
||
async subscribeToReleaseNotes({ request }: HttpContext) {
|
||
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
|
||
return await this.systemService.subscribeToReleaseNotes(reqData.email);
|
||
}
|
||
|
||
async getDebugInfo({}: HttpContext) {
|
||
const debugInfo = await this.systemService.getDebugInfo()
|
||
return { debugInfo }
|
||
}
|
||
|
||
async checkServiceUpdates({ response }: HttpContext) {
|
||
await CheckServiceUpdatesJob.dispatch()
|
||
response.send({ success: true, message: 'Service update check dispatched' })
|
||
}
|
||
|
||
async getAvailableVersions({ params, response }: HttpContext) {
|
||
const serviceName = params.name
|
||
const service = await (await import('#models/service')).default
|
||
.query()
|
||
.where('service_name', serviceName)
|
||
.where('installed', true)
|
||
.first()
|
||
|
||
if (!service) {
|
||
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })
|
||
}
|
||
|
||
try {
|
||
const hostArch = await this.getHostArch()
|
||
const updates = await this.containerRegistryService.getAvailableUpdates(
|
||
service.container_image,
|
||
hostArch,
|
||
service.source_repo
|
||
)
|
||
response.send({ versions: updates })
|
||
} catch (error) {
|
||
logger.error({ err: error }, `[SystemController] Failed to fetch versions for ${serviceName}`)
|
||
response.status(500).send({ error: 'Failed to fetch available versions for this service.' })
|
||
}
|
||
}
|
||
|
||
async updateService({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(updateServiceValidator)
|
||
const result = await this.dockerService.updateContainer(
|
||
payload.service_name,
|
||
payload.target_version
|
||
)
|
||
|
||
if (result.success) {
|
||
response.send({ success: true, message: result.message })
|
||
} else {
|
||
response.status(400).send({ error: result.message })
|
||
}
|
||
}
|
||
|
||
private async getHostArch(): Promise<string> {
|
||
try {
|
||
const info = await this.dockerService.docker.info()
|
||
const arch = info.Architecture || ''
|
||
const archMap: Record<string, string> = {
|
||
x86_64: 'amd64',
|
||
aarch64: 'arm64',
|
||
armv7l: 'arm',
|
||
amd64: 'amd64',
|
||
arm64: 'arm64',
|
||
}
|
||
return archMap[arch] || arch.toLowerCase()
|
||
} catch {
|
||
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` })
|
||
}
|
||
|
||
/** Uninstall a curated catalog app: stop + remove its container (optionally its image) and
|
||
* return the card to the available catalog. App data under the storage path stays on disk,
|
||
* so a later reinstall picks it back up. Custom apps are removed via deleteCustomApp instead,
|
||
* which also drops their DB record. */
|
||
async uninstallService({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(uninstallServiceValidator)
|
||
|
||
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: 'Custom apps are removed via delete.' })
|
||
}
|
||
if (service.is_dependency_service) {
|
||
return response.status(403).send({ error: 'Dependency services cannot be uninstalled directly.' })
|
||
}
|
||
if (!service.installed) {
|
||
return response.status(409).send({ error: `Service ${payload.service_name} is not installed` })
|
||
}
|
||
|
||
const result = await this.dockerService.uninstallService(
|
||
payload.service_name,
|
||
payload.remove_image ?? false
|
||
)
|
||
if (!result.success) {
|
||
return response.status(500).send({ success: false, message: result.message })
|
||
}
|
||
return response.send({ success: true, message: result.message })
|
||
}
|
||
|
||
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
|
||
* metadata change — no container is touched. An empty/invalid value clears the override, after
|
||
* which the default host + port link is used again. */
|
||
async setServiceCustomUrl({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(setServiceCustomUrlValidator)
|
||
|
||
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` })
|
||
}
|
||
// Hidden dependency services (e.g. Qdrant) aren't user-launchable, so they have no link to set.
|
||
if (service.is_dependency_service) {
|
||
return response.status(403).send({ success: false, message: 'This service cannot be configured.' })
|
||
}
|
||
|
||
// Reject a non-empty value that isn't a valid http(s) URL; an empty value clears the override.
|
||
const normalized = normalizeCustomUrl(payload.custom_url)
|
||
if (payload.custom_url && payload.custom_url.trim() && !normalized) {
|
||
return response.status(422).send({
|
||
success: false,
|
||
message: 'Custom URL must be a valid http(s) address (e.g. https://jellyfin.myhomelab.net).',
|
||
})
|
||
}
|
||
|
||
service.custom_url = normalized
|
||
await service.save()
|
||
|
||
return response.send({ success: true, custom_url: service.custom_url })
|
||
}
|
||
|
||
/**
|
||
* Create a dashboard link tile: a shortcut to something the user already runs.
|
||
*
|
||
* Stored as a service row with no container behind it. `installed` is true so
|
||
* the dashboard renders it, `custom_url` carries the destination (getServiceLink
|
||
* already prefers custom_url over a computed link), and `is_link_tile` marks it
|
||
* so the UI never offers Start/Stop/Update/Uninstall for something with no
|
||
* lifecycle. `is_custom` stays false: that flag means a container NOMAD installs.
|
||
*/
|
||
async createLinkTile({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(createLinkTileValidator)
|
||
|
||
const normalized = normalizeCustomUrl(payload.url)
|
||
if (!normalized) {
|
||
return response.status(422).send({
|
||
success: false,
|
||
message: 'Enter a valid URL, for example 192.168.1.50:8080 or https://nas.local.',
|
||
})
|
||
}
|
||
|
||
if (payload.icon && !isLinkTileIcon(payload.icon)) {
|
||
return response.status(422).send({ success: false, message: 'Unknown icon.' })
|
||
}
|
||
|
||
// Namespaced so a tile can never collide with a curated catalog entry. The
|
||
// seeder only ever touches names in its own DEFAULT_SERVICES list, so a
|
||
// `nomad_link_` row is invisible to a reseed.
|
||
const slug = payload.friendly_name
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '_')
|
||
.replace(/^_+|_+$/g, '')
|
||
if (!slug) {
|
||
return response.status(422).send({ success: false, message: 'Enter a name using letters or numbers.' })
|
||
}
|
||
const serviceName = `nomad_link_${slug}`
|
||
|
||
const existing = await Service.query().where('service_name', serviceName).first()
|
||
if (existing) {
|
||
return response.status(409).send({
|
||
success: false,
|
||
message: `A link named "${payload.friendly_name}" already exists. Choose a different name.`,
|
||
})
|
||
}
|
||
|
||
const service = await Service.create({
|
||
service_name: serviceName,
|
||
container_image: '',
|
||
container_config: null,
|
||
friendly_name: payload.friendly_name,
|
||
description: payload.description ?? null,
|
||
icon: payload.icon || DEFAULT_LINK_TILE_ICON,
|
||
display_order: payload.display_order ?? 90,
|
||
installed: true,
|
||
installation_status: 'idle',
|
||
is_dependency_service: false,
|
||
ui_location: null,
|
||
custom_url: normalized,
|
||
is_custom: false,
|
||
is_user_modified: true,
|
||
is_link_tile: true,
|
||
link_color: payload.link_color ?? DEFAULT_LINK_TILE_COLOR,
|
||
category: 'Links',
|
||
})
|
||
|
||
return response.status(201).send({ success: true, service_name: service.service_name })
|
||
}
|
||
|
||
/** Reconfigure an existing link tile. service_name is immutable so the tile keeps its identity. */
|
||
async updateLinkTile({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(updateLinkTileValidator)
|
||
|
||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||
if (!service) {
|
||
return response.status(404).send({ success: false, message: 'Link not found' })
|
||
}
|
||
if (!service.is_link_tile) {
|
||
return response.status(403).send({ success: false, message: 'That app is not a link.' })
|
||
}
|
||
|
||
const normalized = normalizeCustomUrl(payload.url)
|
||
if (!normalized) {
|
||
return response.status(422).send({
|
||
success: false,
|
||
message: 'Enter a valid URL, for example 192.168.1.50:8080 or https://nas.local.',
|
||
})
|
||
}
|
||
|
||
if (payload.icon && !isLinkTileIcon(payload.icon)) {
|
||
return response.status(422).send({ success: false, message: 'Unknown icon.' })
|
||
}
|
||
|
||
service.friendly_name = payload.friendly_name
|
||
service.description = payload.description ?? null
|
||
service.icon = payload.icon || DEFAULT_LINK_TILE_ICON
|
||
service.display_order = payload.display_order ?? service.display_order ?? 90
|
||
service.custom_url = normalized
|
||
service.link_color = payload.link_color ?? service.link_color ?? DEFAULT_LINK_TILE_COLOR
|
||
await service.save()
|
||
|
||
return response.send({ success: true })
|
||
}
|
||
|
||
/** Remove a link tile. Nothing to uninstall: there is no container, image or volume. */
|
||
async deleteLinkTile({ request, response }: HttpContext) {
|
||
const payload = await request.validateUsing(deleteLinkTileValidator)
|
||
|
||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||
if (!service) {
|
||
return response.status(404).send({ success: false, message: 'Link not found' })
|
||
}
|
||
if (!service.is_link_tile) {
|
||
return response.status(403).send({ success: false, message: 'That app is not a link.' })
|
||
}
|
||
|
||
await service.delete()
|
||
|
||
return response.send({ success: true })
|
||
}
|
||
|
||
/** 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 an 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` })
|
||
}
|
||
// Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not.
|
||
if (service.is_dependency_service) {
|
||
return response.status(403).send({ error: 'This service cannot be edited.' })
|
||
}
|
||
return response.send({ success: true, app: this.parseCustomContainerConfig(service) })
|
||
}
|
||
|
||
/** Reconfigure an app: validate + guard, persist the new config, then recreate the container.
|
||
* Works for both custom apps and curated (pre-configured) apps. Editing a curated app marks it
|
||
* user-modified so the seeder stops overwriting the user's changes. */
|
||
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` })
|
||
}
|
||
// Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not.
|
||
if (service.is_dependency_service) {
|
||
return response.status(403).send({ success: false, message: 'This service cannot 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,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Merge the form fields into the app's existing config rather than rebuilding from scratch,
|
||
// so advanced settings a curated app ships with (GPU device requests, special env, etc.) are
|
||
// preserved across an edit.
|
||
// Preserve an explicit scheme (e.g. ui_location "https:8480") across an edit — otherwise a
|
||
// TLS-serving app's Open link would silently revert to http after any reconfigure.
|
||
const prevScheme = (service.ui_location || '').match(/^(https?):\d+$/)?.[1]
|
||
const { containerConfig, uiLocation } = this.mergeCustomContainerConfig(
|
||
service.container_config,
|
||
payload
|
||
)
|
||
service.friendly_name = payload.friendly_name
|
||
service.container_image = payload.image
|
||
service.container_config = JSON.stringify(containerConfig)
|
||
service.ui_location = prevScheme && uiLocation && /^\d+$/.test(uiLocation)
|
||
? `${prevScheme}:${uiLocation}`
|
||
: uiLocation
|
||
service.category = payload.category ?? service.category ?? 'custom'
|
||
if (payload.icon) service.icon = payload.icon
|
||
// Flag as user-modified so the seeder stops overwriting this app's config on future runs.
|
||
service.is_user_modified = true
|
||
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 }
|
||
}
|
||
|
||
/**
|
||
* Merge custom-app form input into an app's *existing* container config. Used by the edit path so
|
||
* editing a curated app only changes the fields exposed in the form (image/ports/volumes/env and,
|
||
* if supplied, resource caps) while preserving everything else it ships with (GPU DeviceRequests,
|
||
* User, custom HostConfig keys, etc.). Unlike buildCustomContainerConfig, resource caps are NOT
|
||
* defaulted here — a curated app intentionally left uncapped stays uncapped unless the user sets one.
|
||
*/
|
||
private mergeCustomContainerConfig(
|
||
existingRaw: string | null,
|
||
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 parsed = existingRaw
|
||
? typeof existingRaw === 'object'
|
||
? existingRaw
|
||
: JSON.parse(existingRaw as string)
|
||
: {}
|
||
// Deep clone so we never mutate the parsed source.
|
||
const containerConfig: Record<string, any> = JSON.parse(JSON.stringify(parsed ?? {}))
|
||
containerConfig.HostConfig = containerConfig.HostConfig ?? {}
|
||
// Keep a restart policy if the existing config lacked one.
|
||
containerConfig.HostConfig.RestartPolicy =
|
||
containerConfig.HostConfig.RestartPolicy ?? { Name: 'unless-stopped' }
|
||
|
||
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`] = {}
|
||
}
|
||
containerConfig.HostConfig.PortBindings = portBindings
|
||
containerConfig.ExposedPorts = exposedPorts
|
||
|
||
const binds = (payload.volumes ?? []).map(
|
||
({ host_path, container_path }) => `${host_path}:${container_path}`
|
||
)
|
||
if (binds.length) containerConfig.HostConfig.Binds = binds
|
||
else delete containerConfig.HostConfig.Binds
|
||
|
||
if (payload.env?.length) containerConfig.Env = payload.env
|
||
else delete containerConfig.Env
|
||
|
||
// Only touch resource caps when the user explicitly set them — preserve existing/uncapped otherwise.
|
||
if (payload.memory_mb != null) {
|
||
containerConfig.HostConfig.Memory = payload.memory_mb * 1024 * 1024
|
||
}
|
||
if (payload.cpus != null) {
|
||
containerConfig.HostConfig.NanoCpus = Math.round(payload.cpus * 1e9)
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
} |