add an existing app working

This commit is contained in:
1dabread 2026-08-11 12:46:07 -05:00
parent b6c6ff4f30
commit 0196e3c816
11 changed files with 432 additions and 15 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
*.sh text eol=lf
Dockerfile text eol=lf
.gitattributes text eol=lf

View File

@ -110,7 +110,8 @@ COPY install/calibre-empty-library/metadata.db /app/assets/calibre/metadata.db
# Copy entrypoint script and ensure it's executable
COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh \
&& chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@ -13,6 +13,7 @@ import {
checkLatestVersionValidator,
customAppValidator,
deleteCustomAppValidator,
existingAppValidator,
installServiceValidator,
preflightCustomValidator,
preflightValidator,
@ -450,6 +451,46 @@ export default class SystemController {
return response.status(400).send({ success: false, message: result.message })
}
/** Register an existing Docker container in Supply Depot as a managed app entry. */
async createExistingApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(existingAppValidator)
const existing = await Service.query().where('service_name', payload.container_name).first()
if (existing) {
return response.status(409).send({
success: false,
message: `A service named "${payload.container_name}" already exists. Choose a different container name.`,
})
}
const inspect = await this.dockerService.inspectContainerByName(payload.container_name)
if (!inspect) {
return response.status(404).send({
success: false,
message: `Docker container ${payload.container_name} not found.`,
})
}
await Service.create({
service_name: payload.container_name,
friendly_name: payload.friendly_name,
container_image: inspect.Config?.Image || '',
container_config: null,
ui_location: payload.container_name,
icon: payload.icon || 'IconBrandDocker',
installed: true,
installation_status: 'idle',
is_dependency_service: false,
is_custom: true,
category: payload.category ?? 'custom',
depends_on: null,
})
this.dockerService.invalidateServicesStatusCache()
return response.send({ success: true, message: `Existing app ${payload.friendly_name} added.`, service_name: payload.container_name })
}
/** Delete a custom app: stop + remove its container, then delete the DB record. */
async deleteCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(deleteCustomAppValidator)

View File

@ -165,9 +165,10 @@ export class DockerService {
}
/**
* Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_')
* Results are cached for 5 seconds and concurrent callers share a single in-flight request,
* preventing Docker socket congestion during rapid page navigation.
* Fetches the status of all Docker containers on the host and stores their
* names so the system can detect existing app containers by name.
* Results are cached for 5 seconds and concurrent callers share a single
* in-flight request, preventing Docker socket congestion during rapid page navigation.
*/
async getServicesStatus(): Promise<{ service_name: string; status: string }[]> {
const now = Date.now()
@ -201,9 +202,11 @@ export class DockerService {
const containers = await this.docker.listContainers({ all: true })
const containerMap = new Map<string, Docker.ContainerInfo>()
containers.forEach((container) => {
const name = container.Names[0]?.replace('/', '')
if (name && name.startsWith('nomad_')) {
containerMap.set(name, container)
for (const rawName of container.Names ?? []) {
const name = rawName?.replace(/^\//, '')
if (name) {
containerMap.set(name, container)
}
}
})
@ -2026,6 +2029,17 @@ export class DockerService {
return containers.find((c) => c.Names.includes(`/${serviceName}`)) ?? null
}
async findContainerByName(serviceName: string) {
return this._findContainerByName(serviceName)
}
async inspectContainerByName(serviceName: string) {
const info = await this._findContainerByName(serviceName)
if (!info) return null
const container = this.docker.getContainer(info.Id)
return container.inspect()
}
/**
* 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.

View File

@ -95,6 +95,22 @@ export const customAppValidator = vine.compile(
})
)
export const existingAppValidator = vine.compile(
vine.object({
container_name: vine
.string()
.trim()
.regex(/^[A-Za-z0-9_.-]+$/)
.minLength(1)
.maxLength(100),
friendly_name: vine.string().trim().minLength(1).maxLength(100),
category: vine
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
.optional(),
icon: vine.string().trim().optional(),
})
)
// Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty
// value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller.
export const setServiceCustomUrlValidator = vine.compile(

View File

@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import StyledButton from './StyledButton'
import api from '~/lib/api'
import Input from './inputs/Input'
import Select from './inputs/Select'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
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' },
]
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' },
]
interface ExistingAppModalProps {
open: boolean
onClose: () => void
onCreated: (serviceName: string) => void
showError: (msg: string) => void
}
export default function ExistingAppModal({
open,
onClose,
onCreated,
showError,
}: ExistingAppModalProps) {
const [containerName, setContainerName] = useState('')
const [friendlyName, setFriendlyName] = useState('')
const [category, setCategory] = useState('custom')
const [icon, setIcon] = useState('IconBrandDocker')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!open) return
setContainerName('')
setFriendlyName('')
setCategory('custom')
setIcon('IconBrandDocker')
setSubmitting(false)
}, [open])
async function handleSubmit() {
if (!containerName.trim() || !friendlyName.trim()) {
showError('Container name and display name are required.')
return
}
setSubmitting(true)
try {
const result = await api.createExistingApp({
container_name: containerName.trim(),
friendly_name: friendlyName.trim(),
category,
icon,
})
if (result?.success && result.service_name) {
onCreated(result.service_name)
} else {
showError(result?.message || 'Failed to add existing app.')
}
} catch (err: any) {
showError(err?.message || 'Unexpected error adding existing app.')
} finally {
setSubmitting(false)
}
}
return (
<StyledModal
title="Add Existing App"
open={open}
onCancel={onClose}
cancelText="Cancel"
onConfirm={handleSubmit}
confirmVariant="primary"
confirmText="Add"
confirmIcon="IconBrandDocker"
confirmLoading={submitting}
confirmDisabled={!containerName.trim() || !friendlyName.trim()}
large
>
<div className="space-y-6 text-sm">
<div className="grid grid-cols-2 gap-4">
<Input
name="containerName"
label="Container Name"
placeholder="e.g. myapp"
value={containerName}
onChange={(e) => setContainerName(e.target.value)}
required
/>
<Input
name="friendlyName"
label="Display Name"
placeholder="My App"
value={friendlyName}
onChange={(e) => setFriendlyName(e.target.value)}
required
/>
</div>
<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>
<p className="text-xs text-text-muted">
Add an existing Docker container by its name so it appears in the Supply Depot and
on the home dashboard.
</p>
</div>
</StyledModal>
)
}

View File

@ -1175,6 +1175,22 @@ class API {
})()
}
async createExistingApp(payload: {
container_name: string
friendly_name: string
category?: string
icon?: string
}) {
return catchInternal(async () => {
const response = await this.client.post<{
success: boolean
message: string
service_name: string
}>('/system/services/existing', payload)
return response.data
})()
}
async setServiceCustomUrl(service_name: string, custom_url: string | null) {
return catchInternal(async () => {
const response = await this.client.put<{ success: boolean; custom_url: string | null }>(

View File

@ -28,6 +28,7 @@ import InstallActivityFeed from '~/components/InstallActivityFeed'
import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert'
import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
import ExistingAppModal from '~/components/ExistingAppModal'
import AppUrlModal from '~/components/AppUrlModal'
import ServiceLogsModal from '~/components/ServiceLogsModal'
import ServiceStatsModal from '~/components/ServiceStatsModal'
@ -108,6 +109,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
const [checkingUpdates, setCheckingUpdates] = useState(false)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [customAppOpen, setCustomAppOpen] = useState(false)
const [existingAppOpen, setExistingAppOpen] = useState(false)
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
// App whose custom launch URL is being configured (null while the modal is closed).
const [urlApp, setUrlApp] = useState<ServiceSlim | null>(null)
@ -324,6 +326,11 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
// Page will reload when installation completes via broadcast
}
function handleExistingAppCreated() {
setExistingAppOpen(false)
window.location.reload()
}
async function handleEdit(service: ServiceSlim) {
setOpenDropdown(null)
setLoading(true)
@ -442,6 +449,13 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
>
Add Custom App
</StyledButton>
<StyledButton
icon="IconBrandDocker"
variant="outline"
onClick={() => setExistingAppOpen(true)}
>
Add Existing App
</StyledButton>
</div>
{/* Category filters */}
@ -799,6 +813,13 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
showError={showError}
/>
<ExistingAppModal
open={existingAppOpen}
onClose={() => setExistingAppOpen(false)}
onCreated={handleExistingAppCreated}
showError={showError}
/>
{/* Custom app edit modal */}
<CustomAppModal
open={!!editApp}

View File

@ -64,6 +64,7 @@ import {
setServiceAutoUpdateValidator,
preflightCustomValidator,
customAppValidator,
existingAppValidator,
setServiceCustomUrlValidator,
deleteCustomAppValidator,
uninstallServiceValidator,
@ -576,6 +577,11 @@ router
tags: ['system'],
request: customAppValidator,
})
documented(router.post('/services/existing', [SystemController, 'createExistingApp']), {
summary: 'Add an existing Docker container as an app',
tags: ['system'],
request: existingAppValidator,
})
documented(router.put('/services/custom', [SystemController, 'updateCustomApp']), {
summary: 'Update a custom app',
tags: ['system'],

View File

@ -29,7 +29,7 @@ services:
# 3. Set it EXACTLY the same in NOMAD_STORAGE_PATH and the disk-collector volume (both below).
# Paths are case-sensitive (/mnt/Data != /mnt/data); a mismatch makes Docker create a new
# empty folder, which is the usual cause of "my content disappeared after moving it".
- /opt/project-nomad/storage:/app/storage
- C:/opt/project-nomad/data/storage:/app/storage
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
@ -37,12 +37,12 @@ services:
# NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin
# normally auto-detects that mount, so this is a fallback used only if the container can't be
# inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place.
- NOMAD_STORAGE_PATH=/opt/project-nomad/storage
- NOMAD_STORAGE_PATH=C:/opt/project-nomad/data/storage
# PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host)
- PORT=8080
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=replaceme
- APP_KEY=1q2w3e4r5t6y7u8i9o0p
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)
@ -93,7 +93,7 @@ services:
# Needs to match DB_PASSWORD in the admin service!
- MYSQL_PASSWORD=replaceme
volumes:
- /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
- C:/opt/project-nomad/data/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
@ -104,7 +104,7 @@ services:
container_name: nomad_redis
restart: unless-stopped
volumes:
- /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
- C:/opt/project-nomad/data/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
@ -128,10 +128,10 @@ services:
container_name: nomad_disk_collector
restart: unless-stopped
volumes:
- /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
- /:/host:ro # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
# If you relocated storage (see the admin service above), set this host path to match EXACTLY,
# or the host disk-usage figures shown in the UI will point at the wrong location.
- /opt/project-nomad/storage:/storage
- C:/opt/project-nomad/data/storage:/storage
volumes:
nomad-update-shared:

View File

@ -0,0 +1,137 @@
# Project NOMAD management services Docker Compose configuration
#
# This compose file defines the admin server, database, and other supporting services required to run Project NOMAD
# You can use this with `docker-compose up -d` to start all the necessary services with a single command after installation.
#
# Note: we recommend leaving all of the environment variables as-is except for any "replaceme" values,
# which must be updated for the admin server to start successfully. The default values are optimized for ease of installation and use,
# but you can customize them as needed (e.g. changing ports, database connection details, log level, etc.)
name: project-nomad
services:
admin:
image: nomad:1.0
container_name: nomad_admin
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway" # Enables host.docker.internal on Linux
ports:
- "8080:8080"
volumes:
# RELOCATING STORAGE: the host path on the LEFT of the colon below is the single source of
# truth for where all NOMAD data lives (ZIMs, AI models, notes, etc.). At runtime the admin
# inspects this mount and points every child app (Kiwix, Ollama, etc.) at the same host
# location automatically, so you do NOT edit each service. To move storage:
# 1. Stop NOMAD (`docker compose down`) and MOVE the existing data to the new location,
# keeping the subfolders intact (e.g. <new-path>/zim, <new-path>/models). Pointing at an
# empty folder gives an empty Information Library, not your existing content.
# 2. Change the host path on the left of the colon below to the new location.
# 3. Set it EXACTLY the same in NOMAD_STORAGE_PATH and the disk-collector volume (both below).
# Paths are case-sensitive (/mnt/Data != /mnt/data); a mismatch makes Docker create a new
# empty folder, which is the usual cause of "my content disappeared after moving it".
- C:/opt/project-nomad/data/storage:/app/storage
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
# NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin
# normally auto-detects that mount, so this is a fallback used only if the container can't be
# inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place.
- NOMAD_STORAGE_PATH=C:/opt/project-nomad/data/storage
# PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host)
- PORT=8080
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=1q2w3e4r5t6y7u8i9o0p
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)
- URL=http://localhost:8080
- DB_HOST=mysql
# If you change the MySQL port, make sure to update this accordingly
- DB_PORT=3306
- DB_DATABASE=nomad
- DB_USER=nomad_user
# Needs to match the MYSQL_PASSWORD in the mysql service!
- DB_PASSWORD=replaceme
- DB_NAME=nomad
- DB_SSL=false
- REDIS_HOST=redis
# If you change the Redis port, make sure to update this accordingly
- REDIS_PORT=6379
- DISABLE_COMPRESSION=false # Most reverse proxies (Nginx, Caddy, etc.) will skip compression if the response is already compressed so this is usally a win all around, but if this causes issues with your setup you can set it to "true" to disable gzip in the admin server
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
interval: 30s
timeout: 10s
retries: 3
dozzle:
# Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the "Service Logs & Metrics" link in Settings that launches Dozzle will not work.
image: amir20/dozzle:v10.0
container_name: nomad_dozzle
restart: unless-stopped
ports:
- "9999:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon
environment:
- DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN
- DOZZLE_ENABLE_SHELL=false # Disabled — shell access + Docker socket = privilege escalation
mysql:
image: mysql:8.0
container_name: nomad_mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=replaceme
- MYSQL_DATABASE=nomad
- MYSQL_USER=nomad_user
# Needs to match DB_PASSWORD in the admin service!
- MYSQL_PASSWORD=replaceme
volumes:
- C:/opt/project-nomad/data/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
timeout: 10s
retries: 10
redis:
image: redis:7-alpine
container_name: nomad_redis
restart: unless-stopped
volumes:
- C:/opt/project-nomad/data/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
updater:
# Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI
image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest
pull_policy: always
container_name: nomad_updater
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon
- C:/opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly
- nomad-update-shared:/shared # Shared volume for communication with admin container
disk-collector:
# Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI.
# It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality.
image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest
pull_policy: always
container_name: nomad_disk_collector
restart: unless-stopped
volumes:
- /:/host:ro # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
# If you relocated storage (see the admin service above), set this host path to match EXACTLY,
# or the host disk-usage figures shown in the UI will point at the wrong location.
- C:/opt/project-nomad/data/storage:/storage
volumes:
nomad-update-shared:
driver: local