Deployments, auto deploy, service routes, live logs, metrics and a terminal

Deployments: deploy, restart and git sync now run in the background, one at
a time per app (a newer request replaces a queued one). Each run is recorded
in SQLite with its log, streamed to the UI while it runs, and can be
cancelled. Any earlier deployment can be deployed again, which rolls back to
its commit, or to its saved compose file for compose apps.

Auto deploy: POST /hooks/<app>, verified with the app's secret (Forgejo,
Gitea and GitHub HMAC signatures, or the secret as a token for CI). With a
Forgejo token stored, the panel adds the webhook to the repository itself.
The NixOS module routes /hooks/* past Authelia. Caddy matches the cleaned
path but forwards the original, so the panel refuses dot segments and only
accepts webhook deliveries from that route (tagged with X-Panel-Hook).

Domains: a route can point at a compose service's container port
("web:8080"). The panel picks a free 127.0.0.1 port and panelctl publishes
it through a generated .panel-ports.yaml override, so compose files need no
ports: section. Existing host:port upstreams keep working.

Logs stream live over server-sent events, with service and text filters.
A sampler keeps an hour of CPU and memory per container for the new
Monitoring tab. The Terminal tab opens `podman exec` in a container over a
WebSocket, using xterm.js bundled by the Nix package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
agent 2026-09-27 19:05:19 +00:00
parent e11fc00840
commit 71fed39f17
8 changed files with 2997 additions and 313 deletions

View file

@ -15,6 +15,7 @@ PANEL_GROUP="${PANEL_GROUP:-panelroutes}"
# Set by load_app: compose file arguments, and the app's environment variables
# as KEY=VALUE words (passed to compose via env(1), never sourced).
CURRENT_APP=""
COMPOSE_ARGS=()
APP_ENV_ARGS=()
APP_ENV_KEYS=()
@ -27,8 +28,11 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
validate_route_entry() {
local entry="$1"
# Format: domain|upstream[/path] or domain|upstream (path is optional)
IFS='|' read -r domain upstream path <<< "${entry}"
# Format: domain|upstream[|path[|service:port]]. The optional last field names
# the compose service and container port that upstream (a host port the panel
# picked) is published from; see prepare_ports_override.
local domain upstream path target
IFS='|' read -r domain upstream path target <<< "${entry}"
[[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'"
[[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'"
validate_single_domain "${domain}"
@ -39,6 +43,11 @@ validate_route_entry() {
if [[ -n "${path}" ]]; then
[[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'"
fi
if [[ -n "${target}" ]]; then
[[ "${target}" =~ ^([A-Za-z0-9._-]*):([0-9]+)$ ]] || fail "target '${target}' must look like service:port in route entry '${entry}'"
(( BASH_REMATCH[2] >= 1 && BASH_REMATCH[2] <= 65535 )) || fail "container port in '${target}' must be between 1 and 65535"
[[ "${upstream}" =~ ^127\.0\.0\.1:[0-9]+$ ]] || fail "a route to a service must use a 127.0.0.1 upstream in route entry '${entry}'"
fi
}
validate_routes() {
@ -63,7 +72,11 @@ Usage:
panelctl restart <name>
panelctl stop <name>
panelctl status <name>
panelctl logs <name> [--tail N]
panelctl logs <name> [--tail N] [--follow] [--service S]
panelctl services <name>
panelctl containers
panelctl stats
panelctl exec <name> <container>
panelctl remove <name> [--keep-volumes]
panelctl backup <name>
panelctl list-backups <name>
@ -73,7 +86,9 @@ Usage:
panelctl list
panelctl show <name>
Each route is a domain|upstream pair. Upstream is host:port.
Each route is a domain|upstream pair. Upstream is host:port. An optional path
and service:port target follow: domain|127.0.0.1:18090||web:8080 publishes
container port 8080 of service "web" on 127.0.0.1:18090 when the app deploys.
Multiple routes are comma-separated:
panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true
@ -103,6 +118,11 @@ log() {
echo "${msg}" | systemd-cat -t panelctl -p "${level}" 2>/dev/null || true
}
# Forward stdin to the journal (or drop it where there is none).
journal_copy() {
systemd-cat -t panelctl -p info 2>/dev/null || cat >/dev/null
}
ensure_base_dirs() {
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
}
@ -174,6 +194,7 @@ routes_unlock() {
load_app() {
local name="$1"
CURRENT_APP="${name}"
local manifest
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
@ -209,6 +230,22 @@ app_env_override() {
echo "${STACKS_DIR}/$1/.panel-env.yaml"
}
# Generated compose override that publishes the ports routes point at.
app_ports_override() {
echo "${STACKS_DIR}/$1/.panel-ports.yaml"
}
# The app's compose file followed by whichever generated overrides exist.
refresh_compose_args() {
local override
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
for override in "$(app_ports_override "${CURRENT_APP}")" "$(app_env_override "${CURRENT_APP}")"; do
if [[ -f "${override}" ]]; then
COMPOSE_ARGS+=(-f "${override}")
fi
done
}
load_app_env() {
local name="$1"
local file line key override
@ -225,11 +262,7 @@ load_app_env() {
done <"${file}"
fi
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
override="$(app_env_override "${name}")"
if [[ -f "${override}" ]]; then
COMPOSE_ARGS+=(-f "${override}")
fi
refresh_compose_args
}
# (Re)generate the env override before containers are created. Variables are
@ -243,7 +276,7 @@ prepare_env_override() {
if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then
rm -f "${override}"
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
refresh_compose_args
return
fi
@ -264,7 +297,83 @@ prepare_env_override() {
} >"${tmp}"
install -m 0640 "${tmp}" "${override}"
rm -f "${tmp}"
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}")
refresh_compose_args
}
# (Re)generate the ports override from routes that target a service. Each one
# publishes the service's container port on the 127.0.0.1 port Caddy proxies
# to, so compose files don't need a ports: section for the panel's routes.
# A route with an empty service name uses the compose file's only service.
prepare_ports_override() {
local name="$1"
local override services="" entry domain upstream path target svc cport hport tmp
local -a lines=() seen=()
override="$(app_ports_override "${name}")"
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
for entry in "${route_entries[@]}"; do
entry="$(echo "${entry}" | xargs)"
IFS='|' read -r domain upstream path target <<< "${entry}"
[[ -n "${target}" ]] || continue
if [[ -z "${services}" ]]; then
services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \
|| fail "could not list compose services to publish route ports (is the compose file valid?)"
fi
svc="${target%:*}"
cport="${target##*:}"
hport="${upstream##*:}"
if [[ -z "${svc}" ]]; then
[[ "$(grep -c . <<<"${services}")" -eq 1 ]] \
|| fail "route ${domain} targets port ${cport} but the compose file has several services; pick one in the app's domains"
svc="${services}"
fi
grep -qxF -- "${svc}" <<<"${services}" \
|| fail "route ${domain} targets service '${svc}', which isn't in the compose file"
[[ " ${seen[*]:-} " == *" ${svc}:${cport}:${hport} "* ]] && continue
seen+=("${svc}:${cport}:${hport}")
lines+=("${svc}|127.0.0.1:${hport}:${cport}")
done
if [[ ${#lines[@]} -eq 0 ]]; then
rm -f "${override}"
refresh_compose_args
return
fi
tmp="$(mktemp)"
{
echo "# Generated by panelctl from the app's routes. Do not edit."
echo "services:"
local current="" line
while IFS= read -r line; do
svc="${line%%|*}"
if [[ "${svc}" != "${current}" ]]; then
printf ' "%s":\n ports:\n' "${svc}"
current="${svc}"
fi
printf ' - "%s"\n' "${line#*|}"
done < <(printf '%s\n' "${lines[@]}" | sort)
} >"${tmp}"
install -m 0640 "${tmp}" "${override}"
rm -f "${tmp}"
refresh_compose_args
}
podman_command() {
if command -v podman >/dev/null 2>&1; then
command -v podman
elif [[ -x /run/current-system/sw/bin/podman ]]; then
echo /run/current-system/sw/bin/podman
else
fail "podman is not installed"
fi
}
run_podman() {
local podman_bin
podman_bin="$(podman_command)"
ensure_podman_runtime_env
"${podman_bin}" "$@"
}
compose_command() {
@ -346,18 +455,31 @@ write_default_compose() {
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
# Use first route's upstream port for the default compose mapping
local first_route="${routes%%,*}"
local first_upstream="${first_route#*|}"
local container_port="${first_upstream##*:}"
local domain upstream path target
IFS='|' read -r domain upstream path target <<< "${first_route}"
if [[ -n "${target}" ]]; then
# The route publishes the port (see prepare_ports_override).
cat >"${stack_dir}/compose.yaml" <<EOF
services:
app:
image: docker.io/traefik/whoami:latest
restart: unless-stopped
volumes:
- ${volume_dir}/data:/data
EOF
return
fi
# Older style: publish the first route's upstream port directly.
cat >"${stack_dir}/compose.yaml" <<EOF
services:
app:
image: docker.io/traefik/whoami:latest
restart: unless-stopped
ports:
- "127.0.0.1:${container_port}:80"
- "127.0.0.1:${upstream##*:}:80"
volumes:
- ${volume_dir}/data:/data
EOF
@ -446,7 +568,7 @@ cmd_render_route() {
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
for entry in "${route_entries[@]}"; do
entry="$(echo "${entry}" | xargs)"
IFS='|' read -r domain upstream path <<< "${entry}"
IFS='|' read -r domain upstream path target <<< "${entry}"
# If upstream is empty (no second pipe), this is the old format
if [[ -z "${upstream}" ]]; then
upstream="${path}"
@ -483,19 +605,15 @@ cmd_deploy() {
log info "Starting deployment for app '${name}'"
cmd_render_route "${name}"
prepare_ports_override "${name}"
prepare_env_override "${name}"
# Capture compose output so callers (the web UI) can show why a deploy failed,
# and still forward it to the journal.
local output
if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then
printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true
printf '%s\n' "${output}" >&2
# Stream compose output as it happens (the panel shows it live in the
# deployment log) and keep a copy in the journal.
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1 | tee >(journal_copy); then
log err "Deployment failed for app '${name}'"
fail "compose up failed"
fi
printf '%s\n' "${output}" | systemd-cat -t panelctl -p info 2>/dev/null || true
printf '%s\n' "${output}"
log info "Successfully deployed app '${name}'"
}
@ -508,6 +626,7 @@ cmd_restart() {
log info "Restarting app '${name}'"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
prepare_ports_override "${name}"
prepare_env_override "${name}"
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
@ -543,19 +662,64 @@ cmd_logs() {
load_app "${name}"
local tail_lines="100"
local -a extra=() services=()
while [[ $# -gt 0 ]]; do
case "$1" in
--tail)
[[ "${2:-}" =~ ^[0-9]+$ ]] || fail "--tail needs a number"
tail_lines="$2"
shift 2
;;
--follow|-f)
extra+=(--follow)
shift
;;
--service)
[[ "${2:-}" =~ ^[A-Za-z0-9._-]+$ ]] || fail "invalid service name"
services+=("$2")
shift 2
;;
*)
shift
;;
esac
done
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" "${extra[@]}" "${services[@]}" 2>&1 || log info "no logs available"
}
cmd_services() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" config --services
}
# Every container of the user, with labels (the panel maps them to apps).
cmd_containers() {
run_podman ps --all --format json
}
# One sample of resource usage for all running containers.
cmd_stats() {
run_podman stats --no-stream --format json
}
# Interactive shell in one of the app's containers (used by the web terminal).
cmd_exec() {
local name="$1"
local container="$2"
validate_name "${name}"
load_app "${name}"
[[ "${container}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || fail "invalid container name"
local labels
labels="$(run_podman inspect --format '{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "io.podman.compose.project"}}' "${container}" 2>/dev/null)" \
|| fail "container '${container}' does not exist"
[[ "|${labels}|" == *"|${name}|"* ]] || fail "container '${container}' does not belong to app '${name}'"
exec "$(podman_command)" exec -it -e TERM="${TERM:-xterm-256color}" "${container}" \
sh -c 'if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi'
}
cmd_validate_compose() {
@ -768,19 +932,12 @@ cmd_inspect_volumes() {
load_app "${name}"
echo "default|${APP_VOLUME_DIR}/data"
local podman_bin=""
if command -v podman >/dev/null 2>&1; then
podman_bin="$(command -v podman)"
elif [[ -x /run/current-system/sw/bin/podman ]]; then
podman_bin="/run/current-system/sw/bin/podman"
fi
if [[ -n "${podman_bin}" ]]; then
"${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
"${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
{
run_podman volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
run_podman volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
# grep exits 1 when there are no named volumes; that is not an error.
fi | sort -u | grep -v '^$' || true
} | sort -u | grep -v '^$' || true
}
cmd_list() {
@ -865,9 +1022,25 @@ main() {
cmd_status "$2"
;;
logs)
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N]"
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N] [--follow] [--service S]"
cmd_logs "$2" "${@:3}"
;;
services)
[[ $# -eq 2 ]] || fail "usage: panelctl services <name>"
cmd_services "$2"
;;
containers)
[[ $# -eq 1 ]] || fail "usage: panelctl containers"
cmd_containers
;;
stats)
[[ $# -eq 1 ]] || fail "usage: panelctl stats"
cmd_stats
;;
exec)
[[ $# -eq 3 ]] || fail "usage: panelctl exec <name> <container>"
cmd_exec "$2" "$3"
;;
validate-compose)
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
cmd_validate_compose "$2"