From 71fed39f171b424357a84f9e862972ff25a478f6 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 19:05:19 +0000 Subject: [PATCH] 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/, 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 Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf --- .gitignore | 2 + API.md | 107 ++- README.md | 15 +- frontend/index.html | 1122 ++++++++++++++++++++++++--- nix/module.nix | 37 +- nix/package.nix | 24 +- panel-api.py | 1756 +++++++++++++++++++++++++++++++++++++++---- panelctl.sh | 247 +++++- 8 files changed, 2997 insertions(+), 313 deletions(-) diff --git a/.gitignore b/.gitignore index 750baeb..d8ca39c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ result result-* +frontend/vendor/ +__pycache__/ diff --git a/API.md b/API.md index 62912a6..4235fbe 100644 --- a/API.md +++ b/API.md @@ -44,9 +44,9 @@ Default bind: `127.0.0.1:9911` | Method | Path | Description | |--------|------|-------------| | POST | `/apps/init` | Create a new app | -| POST | `/apps//routes` | Update routes (hot — Caddy reloads automatically) | -| POST | `/apps//deploy` | Deploy (compose up + caddy reload) | -| POST | `/apps//restart` | Restart (compose down + up) | +| POST | `/apps//routes` | Update routes (hot — Caddy reloads automatically); answers `needs_deploy` when a newly published port needs a deploy | +| POST | `/apps//deploy` | Queue a deployment (compose up + caddy reload) — see *Deployments* | +| POST | `/apps//restart` | Queue a restart (compose down + up) | | POST | `/apps//stop` | Stop (compose down) | | POST | `/apps//render-route` | Re-render Caddy route | | POST | `/apps//compose` | Save compose.yaml content | @@ -54,20 +54,90 @@ Default bind: `127.0.0.1:9911` | POST | `/apps//backup` | Create volume backup (zip) | | POST | `/apps//restore` | Restore from backup | | POST | `/apps//remove` | Remove app | -| POST | `/apps//repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | -| POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` | +| POST | `/apps//repo-pull` | Git apps: queue a sync (fetch branch, hard-reset checkout to it, deploy) | +| POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` (`deploy` queues a deployment) | | POST | `/apps//volume-clear` | Stop the app and empty its default data folder | +| POST | `/apps//autodeploy` | Auto deploy on push: `{"enabled": true, "register": true, "regenerate": false}` | +| POST | `/compose/inspect` | `{"content": "..."}` → services of a compose file and the container ports they mention | Write operations are serialised per app. While one runs, another write to the same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. -`deploy` returns the compose output in `stdout` (or `stderr` on failure). +Deploys, restarts and syncs are the exception: they are queued (see below). + +### Deployments + +`deploy`, `restart` and `repo-pull` run in the background, one at a time per +app, and answer `202` at once with the queued deployment: + +```json +{"ok": true, "deployment": {"id": 12, "app": "blog", "kind": "sync", "trigger": "manual", "title": "Sync from git", "status": "queued", ...}} +``` + +A newer request replaces one that is still queued (that one ends as +`cancelled`, "superseded by #13"). Send `{"wait": true}` to block until it +ends; the answer then has `ok`, the `deployment` and its log in `stdout` / +`stderr`, like the old synchronous API. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/apps//deployments?limit=N` | History, newest first (the last 50 are kept, with logs) | +| GET | `/deployments/` | One deployment | +| GET | `/deployments//log` | Its full log | +| GET | `/deployments//stream?offset=N` | Server-sent events: `status`, `log` (`{text, offset}`) while it runs, then `done` | +| POST | `/deployments//cancel` | Cancel a queued or running deployment | +| POST | `/deployments//redeploy` | Deploy that deployment again: its commit for git apps, its saved compose file otherwise (a rollback) | + +A deployment has `status` (`queued`, `running`, `success`, `failed`, +`cancelled`), `kind` (`deploy`, `sync`, `restart`), `trigger` (`manual`, +`webhook`, `rollback`), `commit_sha` / `commit_subject` / `commit_author` +for git apps, `error`, and `created` / `started` / `finished` / `duration`. + +### Webhooks (auto deploy) + +`POST /hooks/` deploys an app whose auto deploy is switched on. It is +routed past Authelia by the NixOS module and authenticated with the app's +secret instead: an `X-Forgejo-Signature` / `X-Gitea-Signature` / +`X-Hub-Signature-256` HMAC of the body, or the secret itself as +`X-Gitlab-Token`, `X-Panel-Token` or `?token=`. For git apps a push to another +branch is ignored; everything else queues a sync. + +`GET /apps//autodeploy` returns `enabled`, the webhook `url` and +`secret`, the `branch`, whether the panel registered the webhook on Forgejo +(`forgejo.hook_id`) and the `last` delivery. Enabling it with a Forgejo token +stored adds the webhook to the repository (the token needs write access to it). + +### Logs, metrics and terminal + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/apps//logs/stream?tail=N&service=S` | Server-sent events: `lines` (`{lines: [...]}`) as the containers write them, `end` when they stop | +| GET | `/apps//stats` | Per container: current CPU / memory / network / block IO and an hour of `[time, cpu %, memory bytes]` history | +| GET | `/apps//services` | Compose services with the container ports they mention | +| GET (WebSocket) | `/apps//terminal?container=C` | Shell in a container (`podman exec`). Send `{"type": "input", "data": "..."}` and `{"type": "resize", "cols", "rows"}`; output arrives as binary frames | + +### Route targets + +A route points at the container port it serves instead of an upstream: + +```json +{"domain": "blog.reudy.net", "target": "web:2368"} +``` + +`target` is a port (`2368`, for a compose file with one service), +`service:port`, or `host:port` for something outside the app +(`127.0.0.1:8081`, same as the older `upstream` field). For service targets +the panel picks a free port in 18000–19999 and `panelctl` publishes the +container port on `127.0.0.1:` through a generated +`.panel-ports.yaml` compose override, so compose files need no `ports:`. +Routes keep their port when they are saved again. In `/status` such routes +have `service` and `port` next to the `upstream` Caddy uses. ### Create app (git repository, with environment variables) ```json { "name": "blog", - "routes": [{"domain": "blog.reudy.net", "upstream": "127.0.0.1:18090"}], + "routes": [{"domain": "blog.reudy.net", "target": "web:2368"}], "auth": true, "source_type": "git", "repo_url": "https://git.reudy.net/reudy-net/blog.git", @@ -87,18 +157,6 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. - The older `source_type: "github"` with `github_url` / `github_branch` / `github_pat` is still accepted. -### Sync response (`repo-pull`) - -```json -{ - "ok": true, - "stdout": "HEAD is now at d7df557 Bump image tag\n...compose output...", - "before": {"sha": "4fb7976...", "short": "4fb7976", "subject": "Initial compose", "author": "reudy", "time": 1790460618}, - "after": {"sha": "d7df557...", "short": "d7df557", "subject": "Bump image tag", "author": "reudy", "time": 1790460643}, - "changed": true -} -``` - ### Status response (`/status`) ```json @@ -113,7 +171,10 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", "repo_url": "", "repo_branch": "", + "autodeploy": false, "busy": null, + "queued": false, + "last_deployment": {"id": 7, "status": "success", "title": "Deploy", "finished": 1790460640, "...": "..."}, "status": { "state": "running", "running": true, @@ -136,20 +197,20 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. { "name": "whoami", "routes": [ - {"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"} + {"domain": "whoami.reudy.net", "target": "80"} ], "auth": true } ``` -### Create app (multiple routes, different ports) +### Create app (multiple routes, different services) ```json { "name": "myapp", "routes": [ - {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, - {"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"} + {"domain": "app.reudy.net", "target": "web:3000"}, + {"domain": "api.app.reudy.net", "target": "api:8080"} ], "auth": true } diff --git a/README.md b/README.md index e65d818..bb3529b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ Minimal container management panel for rootless Podman + Caddy. +- **Deployments** run in the background with a live log, a history per app + (the last 50, with their output), cancel, redeploy and one-click rollback to + an earlier commit or compose file. +- **Auto deploy**: a push to the app's branch deploys it. The panel adds the + webhook to Forgejo itself; GitHub and CI use the shown URL and secret. +- **Domains** point at a service's container port (`web:8080`); the panel + picks a free local port and publishes it, so compose files need no `ports:`. +- **Live logs**, **CPU / memory graphs** per container and a **web terminal** + (`podman exec`) in the browser. + ## Installing on NixOS This repository is a flake that provides the panel as a package @@ -56,10 +66,13 @@ Checks (package build and a module evaluation) run with `nix flake check`. ``` /var/lib/containers/ ├── stacks//compose.yaml # Compose file per app +├── stacks//.panel-*.yaml # Generated overrides: published route ports, env vars ├── volumes//data # Persistent volumes ├── routes/routes.caddy # Single aggregate Caddy routes file ├── backups/-.zip # Volume backups -└── state/apps/.env # App manifest +├── state/apps/.env # App manifest +└── state/panel/ # Panel state: panel.db (deployment history), + # deployments//.log, webhook secrets, keys ``` All app routes are written to a single `routes/routes.caddy` file that Caddy imports. diff --git a/frontend/index.html b/frontend/index.html index 298ffb1..f147204 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -489,6 +489,68 @@ .missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } .chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); } + /* ── Deployments ── */ + .dep-list { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } + .dep { border-top: 1px solid var(--border); } + .dep:first-child { border-top: 0; } + .dep-row { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 12px; cursor: pointer; } + .dep-row:hover { background: var(--surface-2); } + .dep.open .dep-row { background: var(--surface-2); } + .dep-icon { display: grid; place-items: center; } + .dep-icon.success { color: var(--ok); } + .dep-icon.failed { color: var(--danger); } + .dep-icon.cancelled, .dep-icon.queued { color: var(--muted); } + .dep-icon.running { color: var(--accent); } + .dep-title { font-weight: 600; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dep-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12px; color: var(--muted); margin-top: 1px; } + .dep-meta code { font-size: 11.5px; } + .dep-error { color: var(--danger); font-size: 12.5px; margin-top: 2px; word-break: break-word; } + .dep-actions { display: flex; gap: 4px; align-items: center; } + .dep-body { padding: 0 12px 12px; } + .dep-toggle svg { transition: transform .15s; } + .dep.open .dep-toggle svg { transform: rotate(90deg); } + .badge.current { background: var(--ok-soft); color: var(--ok); } + .badge.trigger-webhook { background: var(--accent-soft); color: var(--accent); } + .badge.trigger-rollback { background: var(--warn-soft); color: var(--warn); } + .logs .step { color: #7cc7ff; font-weight: 600; } + .logs .err { color: #ff8f87; } + .logs .ok { color: #7ee2a8; font-weight: 600; } + .logs mark { background: #6b5a00; color: inherit; border-radius: 2px; } + .last-dep { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; white-space: nowrap; } + .last-dep .icon svg { width: 13px; height: 13px; } + .last-dep.failed { color: var(--danger); } + .last-dep.success { color: var(--muted); } + + /* ── Monitoring ── */ + .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } + .stat-card { border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; } + .stat-card h4 { font: 600 13px var(--mono); margin-bottom: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .stat-nums { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; } + .stat-num .v { font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; } + .stat-num .l { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } + .chart { display: block; width: 100%; height: 70px; } + .chart-label { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--muted); margin: 8px 0 2px; } + .chart .area-cpu { fill: color-mix(in srgb, var(--accent) 18%, transparent); } + .chart .line-cpu { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; } + .chart .area-mem { fill: color-mix(in srgb, #8b5cf6 18%, transparent); } + .chart .line-mem { fill: none; stroke: #8b5cf6; stroke-width: 1.5; vector-effect: non-scaling-stroke; } + .chart .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; } + + /* ── Terminal ── */ + .term-wrap { background: #0d1117; border-radius: 8px; padding: 8px; height: 440px; overflow: hidden; } + .term-wrap .xterm-viewport { background: #0d1117 !important; } + .term-wrap .xterm { height: 100%; } + .term-status { font-size: 12.5px; color: var(--muted); } + .term-status.ok { color: var(--ok); } + .term-status.err { color: var(--danger); } + + /* ── Auto deploy ── */ + .hook-box { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 14px; } + .hook-box h4 { font-size: 13.5px; margin-bottom: 4px; } + .copy-field { display: flex; gap: 6px; align-items: center; margin-top: 4px; } + .copy-field .input { font: 12.5px var(--mono); height: 30px; } + .route-port { font-size: 11.5px; color: var(--muted); font-family: var(--mono); white-space: nowrap; } + /* ── Responsive ── */ @media (max-width: 760px) { .hide-sm { display: none !important; } @@ -513,6 +575,10 @@ .file-row { grid-template-columns: 20px minmax(0, 1fr) auto; } .file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; } .logs { height: 320px; } + .dep-row { grid-template-columns: 22px minmax(0, 1fr); } + .dep-actions { grid-column: 2; } + .stat-grid { grid-template-columns: 1fr; } + .term-wrap { height: 360px; } } @@ -646,10 +712,11 @@
- Routes + Domains
-

Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. /api/*. Wildcard domains need a DNS challenge.

+

Domain → the container port it serves, e.g. 8080, or web:8080 when the compose file has several services. The panel publishes that port on a free local port for Caddy, so the compose file needs no ports:. A host:port such as 127.0.0.1:8081 points at something outside the app. Path is optional, e.g. /api/*.

+
@@ -685,7 +752,8 @@ -

Create one in Forgejo under Settings → Applications with read access to repositories (and your user). +

Create one in Forgejo under Settings → Applications with read access to your user and repositories — + or read and write on repositories so the panel can add auto-deploy webhooks for you. It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.

@@ -773,6 +841,13 @@ const ICONS = { key: '', external: '', braces: '', + rocket: '', + terminal: '', + chart: '', + zap: '', + clock: '', + pause: '', + globe: '', }; function icon(name, cls = "") { @@ -881,6 +956,16 @@ const api = { restart: (n) => api.post(`/apps/${enc(n)}/restart`), stop: (n) => api.post(`/apps/${enc(n)}/stop`), repoPull: (n) => api.post(`/apps/${enc(n)}/repo-pull`), + deployments: (n, limit = 30) => api.request(`/apps/${enc(n)}/deployments?limit=${limit}`), + deployment: (id) => api.request(`/deployments/${id}`), + deploymentLog: (id) => api.request(`/deployments/${id}/log`), + cancelDeployment: (id) => api.post(`/deployments/${id}/cancel`), + redeploy: (id) => api.post(`/deployments/${id}/redeploy`), + services: (n) => api.request(`/apps/${enc(n)}/services`), + inspectCompose: (content) => api.post("/compose/inspect", { content }), + stats: (n) => api.request(`/apps/${enc(n)}/stats`), + autodeploy: (n) => api.request(`/apps/${enc(n)}/autodeploy`), + setAutodeploy: (n, body) => api.post(`/apps/${enc(n)}/autodeploy`, body), getRepo: (n, fetchRemote) => api.request(`/apps/${enc(n)}/repo${fetchRemote ? "?fetch=1" : ""}`), remove: (n, keepVolumes) => api.post(`/apps/${enc(n)}/remove`, { keepVolumes }), getLogs: (n, tail) => api.request(`/apps/${enc(n)}/logs?tail=${enc(tail)}`), @@ -965,7 +1050,8 @@ const BUSY_LABELS = { compose: "Saving…", routes: "Updating routes…", env: "Saving variables…", "volume-clear": "Clearing…", "render-route": "Rendering…", }; const busyLabel = (b) => BUSY_LABELS[b] || "Working…"; -const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null; +const busyOf = (app) => state.localBusy.get(app.name) || app.busy || (app.queued ? "queued" : null); +BUSY_LABELS.queued = "Queued…"; function appState(app) { const busy = busyOf(app); @@ -1050,7 +1136,20 @@ function renderSync() { } function applyOverview(data) { + const before = state.apps; state.apps = new Map((data.apps || []).map((a) => [a.name, a])); + // Deployments this browser didn't start (webhooks, other tabs): tell the user when they end. + if (state.loaded) { + for (const app of state.apps.values()) { + const dep = app.last_deployment, prev = before.get(app.name)?.last_deployment; + if (!dep || deployments.watched.has(dep.id)) continue; + const changed = !prev || prev.id !== dep.id || prev.status !== dep.status; + if (changed) cards.get(app.name)?.panels?.deployments?.refresh(); + if (changed && FINAL.has(dep.status) && (!prev || prev.id !== dep.id || !FINAL.has(prev.status))) { + deployments.notify(app.name, dep); + } + } + } state.loaded = true; if (state.expanded && !state.apps.has(state.expanded)) { state.expanded = null; @@ -1077,7 +1176,7 @@ function matchesFilter(app) { const q = state.query.trim().toLowerCase(); if (!q) return true; return app.name.includes(q) - || app.routes.some((r) => r.domain.toLowerCase().includes(q) || r.upstream.includes(q)) + || app.routes.some((r) => r.domain.toLowerCase().includes(q) || r.upstream.includes(q) || routeTarget(r).toLowerCase().includes(q)) || (app.repo_url || "").toLowerCase().includes(q); } @@ -1200,10 +1299,25 @@ window.addEventListener("hashchange", applyHash); // ─── App card ────────────────────────────────────────────────────────────── const TABS = [ - ["overview", "Overview"], ["compose", "Compose"], ["env", "Environment"], ["logs", "Logs"], ["routes", "Routes"], - ["files", "Files"], ["backups", "Backups"], ["source", "Source"], + ["overview", "Overview"], ["deployments", "Deployments"], ["logs", "Logs"], ["monitoring", "Monitoring"], + ["terminal", "Terminal"], ["routes", "Domains"], ["env", "Environment"], ["compose", "Compose"], + ["files", "Files"], ["backups", "Backups"], ["source", "Git"], ]; +function lastDeployBadge(dep, onclick) { + if (!dep) return null; + const when = ago(1000 * (dep.finished || dep.started || dep.created)); + const [cls, ic, text] = { + success: ["success", "check", `Deployed ${when}`], + failed: ["failed", "alert", `Deploy failed ${when}`], + cancelled: ["success", "x", `Deploy cancelled ${when}`], + running: ["", null, "Deploying…"], + queued: ["", "clock", "Deploy queued"], + }[dep.status] || ["", null, dep.status]; + return h("button", { type: "button", class: `link last-dep ${cls} hide-sm`, title: `${dep.title} — open deployments`, onclick }, + ic ? icon(ic) : h("span", { class: "spinner" }), text); +} + class AppCard { constructor(name) { this.name = name; @@ -1245,7 +1359,9 @@ class AppCard { this.pill.className = `pill pill-${st.key}`; fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label); } - const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count]); + const dep = app.last_deployment; + const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count, app.autodeploy, + dep && [dep.id, dep.status, Math.floor((Date.now() / 1000 - (dep.finished || dep.created)) / 60)]]); if (subSig !== this.subSig) { this.subSig = subSig; const provider = app.repo_provider === "forgejo" || app.repo_provider === "github" ? `${PROVIDER_NAMES[app.repo_provider]} · ` : ""; @@ -1253,7 +1369,9 @@ class AppCard { ...domainLinks(app.routes, 2), app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null, app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_web_url || app.repo_url) }, icon("git"), `${provider}${app.repo_branch || "default"}`) : null, - app.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null); + app.autodeploy ? h("span", { class: "badge hide-sm", title: "Deploys automatically on push" }, icon("zap"), "Auto") : null, + app.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null, + lastDeployBadge(dep, () => toggleExpand(name, "deployments"))); } this.deployBtn.disabled = st.key === "busy"; if (this.panels) { @@ -1265,10 +1383,13 @@ class AppCard { build() { this.panels = { overview: new OverviewPanel(this, "overview"), - compose: new ComposePanel(this, "compose"), - env: new EnvPanel(this, "env"), + deployments: new DeploymentsPanel(this, "deployments"), logs: new LogsPanel(this, "logs"), + monitoring: new MonitoringPanel(this, "monitoring"), + terminal: new TerminalPanel(this, "terminal"), routes: new RoutesPanel(this, "routes"), + env: new EnvPanel(this, "env"), + compose: new ComposePanel(this, "compose"), files: new FilesPanel(this, "files"), backups: new BackupsPanel(this, "backups"), source: new SourcePanel(this, "source"), @@ -1331,6 +1452,7 @@ class AppCard { destroy() { this.collapse(); + if (this.panels) for (const p of Object.values(this.panels)) p.destroy?.(); this.root.remove(); for (const key of ["compose", "routes", "env"]) state.dirty.delete(`${this.name}:${key}`); } @@ -1353,7 +1475,9 @@ class OverviewPanel extends Panel { update(app) { const s = app.status || {}; const containers = s.containers || []; - const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state, app.env_count, app.env_inject]); + const dep = app.last_deployment; + const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state, app.env_count, app.env_inject, + app.autodeploy, dep && [dep.id, dep.status]]); if (sig === this.sig) return; this.sig = sig; @@ -1373,14 +1497,20 @@ class OverviewPanel extends Panel { h("ul", { class: "route-list" }, app.routes.map((r) => h("li", null, domainLinks([r])[0], h("span", { class: "arrow" }, "→"), - h("span", { class: "mono" }, r.upstream), + h("span", { class: "mono", title: r.port ? `Published on ${r.upstream}` : null }, routeTarget(r)), r.path ? h("span", { class: "mono muted" }, r.path) : null))), h("dl", { class: "facts" }, + h("dt", null, "Last deploy"), h("dd", null, dep + ? h("button", { type: "button", class: "link", onclick: () => this.card.showTab("deployments") }, + `${DEP_STATUS[dep.status] || dep.status} · ${ago(1000 * (dep.finished || dep.created))}`, + dep.commit_short ? ` · ${dep.commit_short}` : "") + : h("span", { class: "muted" }, "Never")), h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"), h("dt", null, "Source"), h("dd", null, app.repo_url ? [`${PROVIDER_NAMES[app.repo_provider] || "Git"}: `, app.repo_web_url ? h("a", { href: app.repo_web_url, target: "_blank", rel: "noopener" }, stripUrl(app.repo_web_url)) : stripUrl(app.repo_url), - ` @ ${app.repo_branch || "default"}`] + ` @ ${app.repo_branch || "default"}`, + app.autodeploy ? h("span", { class: "muted" }, " · deploys on push") : null] : "Compose file"), h("dt", null, "Environment"), h("dd", null, h("button", { type: "button", class: "link", onclick: () => this.card.showTab("env") }, @@ -1488,64 +1618,136 @@ class ComposePanel extends Panel { class LogsPanel extends Panel { constructor(card, key) { super(card, key); - this.tail = h("select", { class: "select sm", onchange: () => this.load(true) }, - [100, 300, 1000, 3000].map((n) => h("option", { value: String(n) }, `${n} lines`))); + this.lines = []; + this.max = 5000; + this.tail = h("select", { class: "select sm", onchange: () => this.start(), "aria-label": "Lines" }, + [100, 300, 1000, 3000].map((n) => h("option", { value: String(n) }, `Last ${n}`))); this.tail.value = "300"; - this.follow = h("input", { type: "checkbox", checked: true, onchange: () => this.schedule() }); + this.service = h("select", { class: "select sm", onchange: () => this.start(), "aria-label": "Service" }, + h("option", { value: "" }, "All services")); + this.follow = h("input", { type: "checkbox", checked: true, onchange: () => this.start() }); + this.filter = h("input", { + class: "input", type: "search", placeholder: "Filter…", style: "height:28px;width:160px;font-size:12.5px", + oninput: () => this.render(), "aria-label": "Filter lines", + }); this.meta = h("span", { class: "muted small" }); this.out = h("pre", { class: "logs", tabindex: "0" }, "Loading…"); this.el.append( h("div", { class: "row-actions" }, - h("label", { class: "inline" }, "Show", this.tail), - h("label", { class: "toggle", title: "Refresh every few seconds while this tab is open" }, this.follow, "Follow"), - btn("Refresh", "rotate", () => this.load(), "ghost"), - btn("Copy", "copy", () => this.copy(), "ghost"), + this.service, this.tail, + h("label", { class: "toggle", title: "Stream new lines as they are written" }, this.follow, "Live"), + this.filter, h("span", { class: "spacer" }), - this.meta), + this.meta, + btn(null, "rotate", () => this.start(), "ghost icon-only", "Reload"), + btn(null, "copy", () => this.copy(), "ghost icon-only", "Copy"), + btn(null, "download", () => this.download(), "ghost icon-only", "Download")), this.out); } - show() { this.load(); } - resume() { if (this.visible) this.load(); } - hide() { clearTimeout(this.timer); this.timer = null; } - schedule() { - clearTimeout(this.timer); - if (this.follow.checked && this.visible) this.timer = setTimeout(() => this.load(), 4000); + show() { this.loadServices(); this.start(); } + resume() { if (this.visible) this.start(); } + hide() { this.stop(); } + destroy() { this.stop(); } + stop() { + clearTimeout(this.retry); + this.es?.close(); + this.es = null; } - async load(scrollToEnd = false) { - clearTimeout(this.timer); - if (this.loading) return; - this.loading = true; - const out = this.out; - const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40; + async loadServices() { try { - const d = await api.getLogs(this.name, this.tail.value); - const text = d.logs || "No log output yet."; - if (text !== out.textContent) { - out.textContent = text; - if (atBottom || scrollToEnd || !this.hasLoaded) out.scrollTop = out.scrollHeight; - } - this.hasLoaded = true; - this.meta.textContent = `Updated ${new Date().toLocaleTimeString()}`; - } catch (e) { - this.meta.textContent = `Couldn't load logs: ${e.message}`; - } finally { - this.loading = false; - this.schedule(); + const { services } = await api.services(this.name); + const current = this.service.value; + fill(this.service, h("option", { value: "" }, "All services"), services.map((s) => h("option", { value: s.name }, s.name))); + this.service.value = services.some((s) => s.name === current) ? current : ""; + this.service.hidden = services.length < 2; + } catch { + // Keep "All services". } } - copy() { - navigator.clipboard?.writeText(this.out.textContent) - .then(() => toast("success", "Logs copied to clipboard"), () => toast("error", "Couldn't copy logs")); + start() { + this.stop(); + this.lines = []; + this.stuck = false; + this.out.textContent = "Loading…"; + if (this.follow.checked) this.stream(); else this.loadOnce(); + } + stream() { + const q = new URLSearchParams({ tail: this.tail.value }); + if (this.service.value) q.set("service", this.service.value); + const es = new EventSource(`/apps/${enc(this.name)}/logs/stream?${q}`); + this.es = es; + this.setMeta("Connecting…"); + es.onopen = () => this.setMeta("● Live", "ok"); + es.addEventListener("lines", (e) => { + if (this.es !== es) return; + this.setMeta("● Live", "ok"); + this.add(JSON.parse(e.data).lines); + }); + es.addEventListener("end", () => { + if (this.es !== es) return; + es.close(); + this.setMeta("Stream ended (containers stopped?) — retrying…"); + this.retry = setTimeout(() => this.visible && this.start(), 5000); + }); + es.onerror = () => { + if (this.es !== es) return; + es.close(); + this.setMeta("Disconnected — reconnecting…"); + this.retry = setTimeout(() => this.visible && this.start(), 3000); + }; + } + async loadOnce() { + try { + const d = await api.getLogs(this.name, this.tail.value); + this.lines = []; + this.add((d.logs || "").split("\n")); + this.setMeta(`Loaded ${new Date().toLocaleTimeString()}`); + } catch (e) { + this.setMeta(`Couldn't load logs: ${e.message}`); + } + } + setMeta(text, cls = "") { + this.meta.textContent = text; + this.meta.style.color = cls === "ok" ? "var(--ok)" : ""; + } + add(lines) { + this.lines.push(...lines); + if (this.lines.length > this.max) this.lines.splice(0, this.lines.length - this.max); + if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = null; this.render(); }); + } + render() { + const out = this.out; + const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40; + const q = this.filter.value.trim().toLowerCase(); + const shown = q ? this.lines.filter((l) => l.toLowerCase().includes(q)) : this.lines; + out.textContent = shown.length ? shown.join("\n") : (q ? "No lines match the filter." : "No log output yet."); + if (atBottom || !this.stuck) out.scrollTop = out.scrollHeight; + this.stuck = !atBottom && this.lines.length > 0; + } + copy() { copyText(this.out.textContent, "Logs copied to clipboard"); } + download() { + const url = URL.createObjectURL(new Blob([this.lines.join("\n") + "\n"], { type: "text/plain" })); + const a = h("a", { href: url, download: `${this.name}-${new Date().toISOString().slice(0, 19).replace(/:/g, "")}.log` }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); } } -// ── Routes ── +// ── Domains (routes) ── + +// What a route points at, as the user writes it: 8080, web:8080 or host:port. +function routeTarget(r) { + if (r.port) return r.service ? `${r.service}:${r.port}` : String(r.port); + return r.upstream; +} function normRoutes(routes) { return routes - .map((r) => ({ domain: (r.domain || "").trim(), upstream: (r.upstream || "").trim(), path: (r.path || "").trim() })) - .filter((r) => r.domain || r.upstream || r.path) - .map((r) => (r.path ? r : { domain: r.domain, upstream: r.upstream })); + .map((r) => ({ domain: (r.domain || "").trim(), target: String(r.target ?? routeTarget(r) ?? "").trim(), path: (r.path || "").trim() })) + .filter((r) => r.domain || r.target || r.path) + .map((r) => (r.path ? r : { domain: r.domain, target: r.target })); } function validateDomain(v) { @@ -1555,19 +1757,29 @@ function validateDomain(v) { return null; } -function validateUpstream(v) { - if (!v) return "Every route needs an upstream (host:port)."; - const m = /^[^\s:|,]+:(\d+)$/.exec(v); - if (!m) return `Upstream "${v}" should look like 127.0.0.1:18080.`; - const port = Number(m[1]); - if (port < 1024 || port > 65535) return `Port ${port} must be between 1024 and 65535.`; - return null; +function validateTarget(v) { + const portError = (p) => (p < 1 || p > 65535 ? `Port ${p} must be between 1 and 65535.` : null); + if (!v) return "Every route needs a target: the container port (8080) or service:port (web:8080)."; + if (/^\d+$/.test(v)) return portError(Number(v)); + const m = /^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9._-]+):(\d+)$/.exec(v); + if (!m) return `"${v}" should be a port (8080), service:port (web:8080) or host:port (127.0.0.1:8081).`; + return portError(Number(m[2])); } -function routeEditor({ onChange = () => {} } = {}) { +// Suggestions for the target field from a compose file's services. +function targetOptions(services) { + const out = []; + for (const s of services || []) { + for (const p of s.ports) out.push(services.length === 1 ? String(p) : `${s.name}:${p}`); + if (!s.ports.length && services.length > 1) out.push(`${s.name}:`); + } + return out; +} + +function routeEditor({ onChange = () => {}, datalist = null } = {}) { const rows = h("div"); const el = h("div", null, - h("div", { class: "route-row route-head" }, h("span", null, "Domain"), h("span"), h("span", null, "Upstream"), h("span", null, "Path"), h("span")), + h("div", { class: "route-row route-head" }, h("span", null, "Domain"), h("span"), h("span", null, "Target"), h("span", null, "Path"), h("span")), rows); function changed() { @@ -1576,7 +1788,11 @@ function routeEditor({ onChange = () => {} } = {}) { } function addRow(r = {}, focus = false) { const d = h("input", { class: "input", placeholder: "app.example.com", value: r.domain || "", oninput: changed, "aria-label": "Domain", spellcheck: "false" }); - const u = h("input", { class: "input", placeholder: "127.0.0.1:18080", value: r.upstream || "", oninput: changed, "aria-label": "Upstream", spellcheck: "false" }); + const u = h("input", { + class: "input", placeholder: "8080 or web:8080", value: r.target ?? routeTarget(r) ?? "", oninput: changed, + "aria-label": "Target (container port, service:port or host:port)", spellcheck: "false", list: datalist, + title: r.port ? `Published on ${r.upstream} for Caddy` : "Container port, service:port, or host:port for something outside the app", + }); const p = h("input", { class: "input route-path", placeholder: "/path/* (optional)", value: r.path || "", oninput: changed, "aria-label": "Path (optional)", spellcheck: "false" }); const row = h("div", { class: "route-row" }, d, h("span", { class: "arrow" }, "→"), u, p, h("button", { @@ -1591,8 +1807,8 @@ function routeEditor({ onChange = () => {} } = {}) { return { el, add() { addRow({}, true); changed(); }, - set(routes) { fill(rows, ); (routes.length ? routes : [{}]).forEach((r) => addRow(r)); }, - get() { return [...rows.children].map((r) => ({ domain: r._inputs.d.value, upstream: r._inputs.u.value, path: r._inputs.p.value })); }, + set(routes) { fill(rows); (routes.length ? routes : [{}]).forEach((r) => addRow(r)); }, + get() { return [...rows.children].map((r) => ({ domain: r._inputs.d.value, target: r._inputs.u.value, path: r._inputs.p.value })); }, first() { return rows.children[0]?._inputs; }, validate() { let first = null, count = 0; @@ -1603,7 +1819,7 @@ function routeEditor({ onChange = () => {} } = {}) { count++; const de = validateDomain(dv); if (de) { d.classList.add("invalid"); first ??= de; } - const ue = validateUpstream(uv); + const ue = validateTarget(uv); if (ue) { u.classList.add("invalid"); first ??= ue; } if (pv && (!pv.startsWith("/") || /[\s|,]/.test(pv))) { p.classList.add("invalid"); first ??= `Path "${pv}" must start with / and contain no spaces, commas or pipes.`; } } @@ -1617,21 +1833,42 @@ class RoutesPanel extends Panel { super(card, key); this.original = ""; this.dirty = false; - this.editor = routeEditor({ onChange: () => this.onChange() }); + this.listId = `targets-${this.name}`; + this.datalist = h("datalist", { id: this.listId }); + this.editor = routeEditor({ onChange: () => this.onChange(), datalist: this.listId }); this.err = h("p", { class: "form-error", hidden: true }); + this.pending = h("div", { class: "notice warn", hidden: true }, icon("alert"), + h("span", null, "A route now points at a port that isn't published yet. ", + h("button", { type: "button", class: "link", onclick: () => actions.deploy(this.name) }, "Deploy"), + " to publish it.")); this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes"); this.el.append( - h("div", { class: "notice" }, icon("layers"), - h("span", null, "Changes apply immediately: Caddy reloads on its own and containers keep running. If you route to a new port, publish it in the compose file too.")), + h("div", { class: "notice" }, icon("globe"), + h("span", null, "Point each domain at the container port it serves, e.g. ", + h("code", null, "8080"), " — or ", h("code", null, "web:8080"), + " when there are several services. The panel publishes it on a free local port for Caddy, so the compose file needs no ", + h("code", null, "ports:"), ". Domain changes apply immediately; a new port takes effect on the next deploy.")), + this.pending, this.editor.el, + this.datalist, this.err, h("div", { class: "row-actions" }, - btn("Add route", "plus", () => this.editor.add(), "ghost"), - btn("Save routes", "save", () => this.save(), "primary"), + btn("Add domain", "plus", () => this.editor.add(), "ghost"), + btn("Save", "save", () => this.save(), "primary"), + btn("Save & deploy", "play", () => this.save(true)), btn("Revert", null, () => this.revert(), "ghost"), h("span", { class: "spacer" }), this.dirtyLabel)); } + show() { this.loadServices(); } + async loadServices() { + try { + const { services } = await api.services(this.name); + fill(this.datalist, targetOptions(services).map((v) => h("option", { value: v }))); + } catch { + // Suggestions only. + } + } update(app) { const sig = JSON.stringify(normRoutes(app.routes)); if (!this.dirty && sig !== this.original) { @@ -1651,17 +1888,26 @@ class RoutesPanel extends Panel { this.update(this.app); this.onChange(); } - async save() { + async save(deploy = false) { const error = this.editor.validate(); if (error) { this.err.textContent = error; this.err.hidden = false; return; } const routes = normRoutes(this.editor.get()); - const res = await runOp(this.name, "routes", `Update routes · ${this.name}`, () => api.setRoutes(this.name, routes), - { success: "Routes updated", successDetail: () => "Caddy is reloading with the new routes." }); - if (!res) return; - this.original = JSON.stringify(routes); - const app = this.app; - if (app) app.routes = routes; - this.onChange(); + if (this.dirty) { + const res = await runOp(this.name, "routes", `Update domains · ${this.name}`, () => api.setRoutes(this.name, routes), + { success: deploy ? null : "Domains updated", successDetail: (r) => (r.needs_deploy ? "Deploy to publish the new port." : "Caddy is reloading with the new routes.") }); + if (!res) return; + const app = this.app; + if (app && res.routes) app.routes = res.routes; + this.dirty = false; + this.original = ""; + if (app) this.update(app); + this.onChange(); + this.pending.hidden = !res.needs_deploy || deploy; + } + if (deploy) { + this.pending.hidden = true; + actions.deploy(this.name); + } } } @@ -2088,6 +2334,533 @@ class BackupsPanel extends Panel { } } +// ── Deployments ── + +const FINAL = new Set(["success", "failed", "cancelled"]); +const DEP_STATUS = { queued: "Queued", running: "Running", success: "Deployed", failed: "Failed", cancelled: "Cancelled" }; +const DEP_TRIGGER = { manual: "Manual", webhook: "Push", rollback: "Redeploy" }; +const DEP_KIND = { sync: "Git sync", restart: "Restart" }; + +function fmtDuration(s) { + if (s == null) return ""; + if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`; + return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Append log text with steps, errors and the final summary highlighted. +function appendLog(pre, text) { + const frag = document.createDocumentFragment(); + for (const line of text.split(/(?<=\n)/)) { + let cls = null; + if (line.startsWith("==> ")) cls = /failed|cancelled/i.test(line) ? "step err" : /finished/.test(line) ? "ok" : "step"; + else if (/^(error|fatal)\b|\berror:|\bfailed\b/i.test(line)) cls = "err"; + frag.append(cls ? h("span", { class: cls }, line) : line); + } + const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40; + pre.append(frag); + if (atBottom) pre.scrollTop = pre.scrollHeight; +} + +// The log of one deployment: loaded once when it's over, streamed while it runs. +class LogView { + constructor(dep, onStatus) { + this.id = dep.id; + this.onStatus = onStatus; + this.pre = h("pre", { class: "logs", tabindex: "0", style: "height:auto;min-height:90px;max-height:460px" }); + this.el = h("div", { class: "dep-body" }, this.pre); + } + open(dep) { + this.close(); + this.pre.textContent = ""; + if (FINAL.has(dep.status)) { + this.pre.textContent = "Loading…"; + api.deploymentLog(this.id).then((d) => { + this.pre.textContent = ""; + if (d.truncated) appendLog(this.pre, "[earlier output trimmed]\n"); + appendLog(this.pre, d.log || "No output."); + this.pre.scrollTop = this.pre.scrollHeight; + }, (e) => { this.pre.textContent = `Couldn't load the log: ${e.message}`; }); + return; + } + const es = new EventSource(`/deployments/${this.id}/stream`); + this.es = es; + if (dep.status === "queued") this.pre.textContent = "Waiting for the app's current operation to finish…\n"; + es.addEventListener("log", (e) => { + if (this.pre.textContent.startsWith("Waiting for")) this.pre.textContent = ""; + appendLog(this.pre, JSON.parse(e.data).text); + }); + es.addEventListener("status", (e) => this.onStatus(JSON.parse(e.data).deployment)); + es.addEventListener("done", (e) => { es.close(); this.onStatus(JSON.parse(e.data).deployment); }); + es.onerror = () => { + es.close(); + if (this.es !== es) return; + // Reconnect from scratch after a hiccup (the server replays the log). + setTimeout(() => { if (this.es === es) api.deployment(this.id).then((d) => this.open(d.deployment), () => {}); }, 2000); + }; + } + close() { this.es?.close(); this.es = null; } +} + +class DeploymentsPanel extends Panel { + constructor(card, key) { + super(card, key); + this.rows = new Map(); // id -> {el, sig, view} + this.expanded = new Set(); + this.syncBtn = btn("Sync & deploy", "git", () => actions.sync(this.name), "", "Fetch the branch, reset to it and deploy"); + this.hint = h("span", { class: "muted small" }); + this.list = h("div", { class: "dep-list" }, h("div", { class: "empty-sm" }, "Loading…")); + this.el.append( + h("div", { class: "row-actions" }, + btn("Deploy", "rocket", () => actions.deploy(this.name), "primary", "Pull images, (re)create containers and reload routes"), + this.syncBtn, + btn("Restart", "rotate", () => actions.restart(this.name), "ghost", "compose down, then up"), + h("span", { class: "spacer" }), + this.hint), + this.list); + } + update(app) { + this.syncBtn.hidden = !app.repo_url; + fill(this.hint, app.repo_url + ? (app.autodeploy + ? [icon("zap"), ` Deploys automatically on push to ${app.repo_branch || "the branch"}`] + : h("button", { type: "button", class: "link", onclick: () => this.card.showTab("source") }, "Deploy on every push…")) + : null); + } + show() { this.refresh(); } + hide() { + clearTimeout(this.timer); + for (const r of this.rows.values()) r.view?.close(); + } + destroy() { this.hide(); } + async refresh() { + clearTimeout(this.timer); + if (!this.visible && !this.loadedOnce) return; + try { + const d = await api.deployments(this.name); + this.loadedOnce = true; + this.render(d.deployments || []); + this.active = (d.deployments || []).some((x) => !FINAL.has(x.status)); + } catch (e) { + if (!this.rows.size) fill(this.list, h("div", { class: "empty-sm error" }, `Couldn't load deployments: ${e.message}`)); + } + if (this.visible) this.timer = setTimeout(() => this.refresh(), this.active ? 2000 : 15000); + } + render(deps) { + if (!deps.length) { + this.rows.clear(); + fill(this.list, h("div", { class: "empty-sm" }, "No deployments yet. Deploy the app to see its history here — with the full output of every run.")); + return; + } + const current = deps.find((d) => d.status === "success")?.id; + const webUrl = this.app?.repo_web_url || ""; + const els = deps.map((dep) => { + let row = this.rows.get(dep.id); + const sig = JSON.stringify([dep.status, dep.error, dep.commit_sha, dep.duration != null && FINAL.has(dep.status), dep.id === current, + Math.floor((Date.now() / 1000 - dep.created) / 60)]); + if (!row) { + row = { el: h("div", { class: "dep" }), sig: null, view: null }; + this.rows.set(dep.id, row); + } + if (row.sig !== sig) { + row.sig = sig; + this.renderRow(row, dep, dep.id === current, webUrl); + } + return row.el; + }); + for (const [id, row] of this.rows) if (!deps.some((d) => d.id === id)) { row.view?.close(); this.rows.delete(id); } + const empty = this.list.querySelector(".empty-sm"); + if (empty) empty.remove(); + els.forEach((el, i) => { if (this.list.children[i] !== el) this.list.insertBefore(el, this.list.children[i] || null); }); + while (this.list.children.length > els.length) this.list.lastChild.remove(); + } + renderRow(row, dep, isCurrent, webUrl) { + const open = this.expanded.has(dep.id); + const statusIcon = dep.status === "running" ? h("span", { class: "spinner" }) + : icon({ success: "check", failed: "alert", cancelled: "x", queued: "clock" }[dep.status] || "activity"); + const commit = dep.commit_short + ? (webUrl ? h("a", { href: `${webUrl}/commit/${dep.commit_sha}`, target: "_blank", rel: "noopener", onclick: (e) => e.stopPropagation() }, h("code", null, dep.commit_short)) : h("code", null, dep.commit_short)) + : null; + const canRedeploy = FINAL.has(dep.status) && (dep.commit_sha || dep.snapshot); + const head = h("div", { class: "dep-row", onclick: (e) => { if (!e.target.closest("button, a")) this.toggle(dep.id); } }, + h("span", { class: `dep-icon ${dep.status}`, title: DEP_STATUS[dep.status] }, statusIcon), + h("div", { style: "min-width:0" }, + h("div", { class: "dep-title", title: dep.title }, dep.title || DEP_KIND[dep.kind] || "Deploy"), + h("div", { class: "dep-meta" }, + h("span", null, `#${dep.id}`), + h("span", { class: `badge trigger-${dep.trigger}` }, DEP_TRIGGER[dep.trigger] || dep.trigger), + commit, + h("span", { title: fmtDate(dep.created * 1000) }, ago(dep.created * 1000)), + dep.duration != null ? h("span", null, dep.status === "running" ? "running…" : fmtDuration(dep.duration)) : null, + isCurrent ? h("span", { class: "badge current" }, "Current") : null), + dep.status === "failed" && dep.error ? h("div", { class: "dep-error" }, dep.error) : null), + h("div", { class: "dep-actions" }, + !FINAL.has(dep.status) ? btn("Cancel", "x", () => this.cancel(dep), "ghost danger") : null, + canRedeploy ? btn(isCurrent ? "Redeploy" : "Roll back", "rotate", () => this.redeploy(dep, isCurrent), "ghost", + dep.commit_sha ? `Deploy commit ${dep.commit_short} again` : "Deploy this deployment's compose file again") : null, + btn(null, "chevron", () => this.toggle(dep.id), "ghost icon-only dep-toggle", open ? "Hide log" : "Show log"))); + row.el.classList.toggle("open", open); + if (open) { + if (!row.view) row.view = new LogView(dep, (d) => this.onStatus(d)); + if (!row.view.es && (row.view.status !== dep.status)) row.view.open(dep); + row.view.status = dep.status; + fill(row.el, head, row.view.el); + } else { + fill(row.el, head); + } + } + onStatus(dep) { + const row = this.rows.get(dep.id); + if (row && FINAL.has(dep.status)) this.refresh(); + else if (row) { row.sig = null; this.refresh(); } + } + toggle(id) { + const row = this.rows.get(id); + if (this.expanded.has(id)) { + this.expanded.delete(id); + row?.view?.close(); + if (row?.view) row.view.status = null; + } else { + this.expanded.add(id); + } + if (row) row.sig = null; + this.refresh(); + } + openDeployment(id) { + this.expanded.add(id); + const row = this.rows.get(id); + if (row) row.sig = null; + this.refresh().then(() => this.rows.get(id)?.el.scrollIntoView({ block: "nearest", behavior: "smooth" })); + } + async cancel(dep) { + const ok = await confirmDialog({ + title: `Cancel deployment #${dep.id}?`, + body: [h("p", null, dep.status === "running" + ? "The running command is stopped. Containers may be left half-updated until the next deploy." + : "It is removed from the queue.")], + confirmLabel: "Cancel deployment", danger: dep.status === "running", + }); + if (!ok) return; + try { + await api.cancelDeployment(dep.id); + toast("info", `Deployment #${dep.id} cancelled`); + } catch (e) { + toast("error", "Couldn't cancel", { detail: e.message }); + } + this.refresh(); + } + async redeploy(dep, isCurrent) { + if (!isCurrent) { + const ok = await confirmDialog({ + title: `Roll back ${this.name}?`, + body: [h("p", null, dep.commit_sha + ? ["The app is deployed again at commit ", h("code", null, dep.commit_short), ". The next sync or push moves it forward again."] + : ["The compose file of deployment #", String(dep.id), " replaces the current one and the app is deployed with it."])], + confirmLabel: "Roll back", + }); + if (!ok) return; + } + const label = dep.commit_short ? `${isCurrent ? "Redeploy" : "Roll back to"} ${dep.commit_short}` : `Redeploy #${dep.id}`; + deployments.start(this.name, "deploy", `${label} · ${this.name}`, () => api.redeploy(dep.id)); + } +} + +// Follows deployments started from this browser to the end, then reports. +const deployments = { + mine: new Set(), + get watched() { return this.mine; }, + + async start(name, kind, label, fn, { after } = {}) { + if (state.localBusy.has(name)) { + toast("info", `${name} is busy`, { detail: `Wait for "${busyLabel(state.localBusy.get(name))}" to finish.` }); + return null; + } + const entry = activity.start(label); + state.localBusy.set(name, kind); + refreshCard(name); + let dep; + try { + dep = (await fn())?.deployment; + if (!dep) throw new ApiError("The panel didn't start a deployment."); + } catch (e) { + entry.finish(false, e.detail || e.message); + if (e.status !== 401) toast("error", `${label} failed`, { detail: e.message, timeout: 12000 }); + state.localBusy.delete(name); + refreshCard(name); + return null; + } + this.mine.add(dep.id); + entry.deployment = { name, id: dep.id }; + const panel = cards.get(name)?.panels?.deployments; + panel?.refresh(); + poller.now(); + + const final = await this.waitFor(dep.id, () => { + // The server now reports the app as busy or queued; stop showing our own guess. + if (state.localBusy.get(name) === kind) { state.localBusy.delete(name); refreshCard(name); } + }); + state.localBusy.delete(name); + refreshCard(name); + let log = ""; + try { log = (await api.deploymentLog(dep.id)).log || ""; } catch { /* the toast still says what happened */ } + const ok = final?.status === "success"; + entry.finish(ok, log.slice(-20000)); + this.notify(name, final || dep, { mine: true }); + panel?.refresh(); + poller.now(); + if (ok) after?.(final); + return ok ? final : null; + }, + + async waitFor(id, onSeen) { + let delay = 700; + for (;;) { + await sleep(delay); + delay = Math.min(delay + 300, 2000); + try { + const d = (await api.deployment(id)).deployment; + onSeen?.(d); + if (FINAL.has(d.status)) return d; + } catch (e) { + if (e.status === 401 || e.status === 404) return null; + } + } + }, + + notify(name, dep, { mine = false } = {}) { + const show = { label: "Show log", run: () => this.show(name, dep.id) }; + const what = dep.commit_short ? `${dep.commit_short}${dep.commit_subject ? `: ${dep.commit_subject}` : ""}` : dep.title; + if (dep.status === "success") { + toast("success", mine ? `${name} deployed` : `${name} deployed · ${DEP_TRIGGER[dep.trigger] || dep.trigger}`, + { detail: `${what} — took ${fmtDuration(dep.duration)}`, action: show }); + } else if (dep.status === "failed") { + toast("error", `${name}: deployment failed`, { detail: dep.error || dep.title, action: show, timeout: 15000 }); + } else if (dep.status === "cancelled" && mine) { + toast("info", `${name}: deployment cancelled`, { detail: dep.error && dep.error !== "cancelled" ? dep.error : null }); + } + }, + + show(name, id) { + if (!cards.has(name)) return; + toggleExpand(name, "deployments"); + cards.get(name).panels.deployments.openDeployment(id); + }, +}; + +// ── Monitoring ── + +function sparkline(points, { cls, max }) { + const W = 300, H = 70; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + svg.setAttribute("preserveAspectRatio", "none"); + svg.setAttribute("class", "chart"); + const add = (tag, attrs) => { + const el = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + svg.append(el); + }; + add("line", { x1: 0, x2: W, y1: H / 2, y2: H / 2, class: "grid" }); + add("line", { x1: 0, x2: W, y1: H - 0.5, y2: H - 0.5, class: "grid" }); + const pts = points.filter((p) => p[1] != null); + if (pts.length < 2) return svg; + const t0 = pts[0][0], t1 = pts[pts.length - 1][0]; + const span = Math.max(t1 - t0, 1); + const xy = pts.map(([t, v]) => [((t - t0) / span) * W, H - Math.min(v / max, 1) * (H - 4) - 1]); + const line = xy.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`).join(""); + add("path", { d: `${line}L${W},${H}L0,${H}Z`, class: `area-${cls}` }); + add("path", { d: line, class: `line-${cls}` }); + return svg; +} + +const fmtSpan = (s) => (s >= 3000 ? "last hour" : s >= 90 ? `last ${Math.round(s / 60)} min` : "last minute"); + +class MonitoringPanel extends Panel { + constructor(card, key) { + super(card, key); + this.meta = h("span", { class: "muted small" }); + this.body = h("div", null, h("div", { class: "empty-sm" }, "Loading…")); + this.el.append( + h("div", { class: "row-actions" }, + h("span", { class: "muted small" }, "CPU and memory of the app's containers. Sampled every few seconds while this tab is open, once a minute otherwise; the last hour is kept."), + h("span", { class: "spacer" }), this.meta), + this.body); + } + show() { this.load(); } + resume() { if (this.visible) this.load(); } + hide() { clearTimeout(this.timer); } + destroy() { this.hide(); } + async load() { + clearTimeout(this.timer); + try { + this.render(await api.stats(this.name)); + } catch (e) { + fill(this.body, h("div", { class: "empty-sm error" }, `Couldn't load metrics: ${e.message}`)); + } + if (this.visible) this.timer = setTimeout(() => this.load(), 5000); + } + render(d) { + this.meta.textContent = d.sampled ? `Updated ${new Date(d.sampled * 1000).toLocaleTimeString()}` : ""; + const running = d.containers.filter((c) => c.current); + if (!running.length) { + fill(this.body, d.error + ? h("div", { class: "notice warn" }, icon("alert"), h("span", null, `Metrics aren't available: ${d.error}`)) + : h("div", { class: "empty-sm" }, d.sampled || d.containers.length + ? "No running containers. Deploy the app to see its metrics." + : "Collecting the first sample…")); + return; + } + const cards = running.map((c) => { + const cur = c.current; + const cpuMax = Math.max(5, ...c.history.map((p) => p[1] || 0)) * 1.15; + const memMax = Math.max(1, ...c.history.map((p) => p[2] || 0)) * 1.15; + const span = c.history.length > 1 ? c.history[c.history.length - 1][0] - c.history[0][0] : 0; + const peakCpu = Math.max(0, ...c.history.map((p) => p[1] || 0)); + const peakMem = Math.max(0, ...c.history.map((p) => p[2] || 0)); + return h("div", { class: "stat-card" }, + h("h4", { title: c.name }, c.name), + h("div", { class: "stat-nums" }, + h("div", { class: "stat-num" }, h("div", { class: "v" }, cur.cpu == null ? "—" : `${cur.cpu.toFixed(1)}%`), h("div", { class: "l" }, "CPU")), + h("div", { class: "stat-num", title: cur.mem_limit ? `of ${fmtBytes(cur.mem_limit)}` : "" }, + h("div", { class: "v" }, cur.mem == null ? "—" : fmtBytes(cur.mem)), h("div", { class: "l" }, "Memory")), + h("div", { class: "stat-num", title: "Network received / sent since the container started" }, + h("div", { class: "v", style: "font-size:12.5px;line-height:1.35" }, + cur.net_in == null ? "—" : [`↓ ${fmtBytes(cur.net_in)}`, h("br"), `↑ ${fmtBytes(cur.net_out)}`]), + h("div", { class: "l" }, "Network"))), + h("div", { class: "chart-label" }, h("span", null, `CPU · peak ${peakCpu.toFixed(1)}%`), h("span", null, span ? fmtSpan(span) : "")), + sparkline(c.history.map((p) => [p[0], p[1]]), { cls: "cpu", max: cpuMax }), + h("div", { class: "chart-label" }, h("span", null, `Memory · peak ${fmtBytes(peakMem)}`), h("span", null, cur.mem_limit ? `limit ${fmtBytes(cur.mem_limit)}` : "")), + sparkline(c.history.map((p) => [p[0], p[2]]), { cls: "mem", max: memMax })); + }); + fill(this.body, d.error ? h("div", { class: "notice warn" }, icon("alert"), h("span", null, d.error)) : null, + h("div", { class: "stat-grid" }, cards)); + } +} + +// ── Terminal ── + +const XTERM_CDN = "https://cdn.jsdelivr.net/npm/@xterm"; +const XTERM_FILES = [ + ["/vendor/xterm.css", `${XTERM_CDN}/xterm@6.0.0/css/xterm.css`], + ["/vendor/xterm.js", `${XTERM_CDN}/xterm@6.0.0/lib/xterm.js`], + ["/vendor/addon-fit.js", `${XTERM_CDN}/addon-fit@0.11.0/lib/addon-fit.js`], +]; +let xtermLoading = null; + +function loadAsset(src) { + return new Promise((resolve, reject) => { + const el = src.endsWith(".css") + ? h("link", { rel: "stylesheet", href: src }) + : h("script", { src }); + el.onload = resolve; + el.onerror = () => { el.remove(); reject(new Error(`couldn't load ${src}`)); }; + document.head.append(el); + }); +} + +// xterm.js ships with the panel package; fall back to the CDN when running from a checkout. +function loadXterm() { + xtermLoading ??= (async () => { + for (const [local, cdn] of XTERM_FILES) { + try { await loadAsset(local); } catch { await loadAsset(cdn); } + } + if (!window.Terminal || !window.FitAddon) throw new Error("xterm.js didn't load"); + })().catch((e) => { xtermLoading = null; throw e; }); + return xtermLoading; +} + +class TerminalPanel extends Panel { + constructor(card, key) { + super(card, key); + this.select = h("select", { class: "select sm", "aria-label": "Container" }); + this.connectBtn = btn("Connect", "terminal", () => this.connect(), "primary"); + this.disconnectBtn = btn("Disconnect", "x", () => this.ws?.close(), "ghost"); + this.disconnectBtn.hidden = true; + this.status = h("span", { class: "term-status" }); + this.wrap = h("div", { class: "term-wrap", hidden: true }); + this.el.append( + h("div", { class: "row-actions" }, this.select, this.connectBtn, this.disconnectBtn, h("span", { class: "spacer" }), this.status), + h("p", { class: "muted small", style: "margin-bottom:10px" }, + "A shell (bash, or sh) inside the container through ", h("code", null, "podman exec"), + ". It runs as the container's user; the session ends when you disconnect or close the page."), + this.wrap); + this.resizer = new ResizeObserver(() => this.fitSoon()); + this.resizer.observe(this.wrap); + } + update(app) { + const running = (app.status?.containers || []).filter((c) => c.running).map((c) => c.name); + const sig = running.join(","); + if (sig === this.sig) return; + this.sig = sig; + const current = this.select.value; + fill(this.select, running.length ? running.map((n) => h("option", { value: n }, n)) : h("option", { value: "" }, "No running containers")); + this.select.value = running.includes(current) ? current : (running[0] || ""); + this.select.disabled = !running.length || !!this.ws; + this.connectBtn.disabled = !running.length || !!this.ws; + } + show() { this.fitSoon(); if (this.term && this.ws) this.term.focus(); } + destroy() { this.ws?.close(); this.term?.dispose(); this.resizer.disconnect(); } + setStatus(text, cls = "") { this.status.textContent = text; this.status.className = `term-status ${cls}`; } + fitSoon() { + if (!this.fit || this.wrap.hidden || !this.visible) return; + cancelAnimationFrame(this.fitFrame); + this.fitFrame = requestAnimationFrame(() => { try { this.fit.fit(); } catch { /* not laid out yet */ } }); + } + send(msg) { if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg)); } + async connect() { + const container = this.select.value; + if (!container || this.ws) return; + this.setStatus("Loading terminal…"); + try { + await loadXterm(); + } catch (e) { + this.setStatus(`Couldn't load the terminal: ${e.message}`, "err"); + return; + } + this.wrap.hidden = false; + if (!this.term) { + this.term = new window.Terminal({ + cursorBlink: true, fontSize: 13, scrollback: 5000, + fontFamily: getComputedStyle(document.body).getPropertyValue("--mono") || "monospace", + theme: { background: "#0d1117", foreground: "#d1d7e0" }, + }); + this.fit = new window.FitAddon.FitAddon(); + this.term.loadAddon(this.fit); + this.term.open(this.wrap); + this.term.onData((data) => this.send({ type: "input", data })); + this.term.onResize(({ cols, rows }) => this.send({ type: "resize", cols, rows })); + } + this.term.reset(); + this.fitSoon(); + const proto = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${proto}://${location.host}/apps/${enc(this.name)}/terminal?container=${enc(container)}`); + ws.binaryType = "arraybuffer"; + this.ws = ws; + this.select.disabled = this.connectBtn.disabled = true; + this.disconnectBtn.hidden = false; + this.setStatus(`Connecting to ${container}…`); + let opened = false; + ws.onopen = () => { + opened = true; + this.setStatus(`● Connected to ${container}`, "ok"); + try { this.fit.fit(); } catch { /* ignore */ } + this.send({ type: "resize", cols: this.term.cols, rows: this.term.rows }); + this.term.focus(); + }; + ws.onmessage = (e) => this.term.write(typeof e.data === "string" ? e.data : new Uint8Array(e.data)); + ws.onclose = (e) => { + if (this.ws !== ws) return; + this.ws = null; + this.term.write(`\r\n\x1b[2m[${opened ? "session ended" : "couldn't connect"}]\x1b[0m\r\n`); + this.setStatus(opened ? `Disconnected${e.reason ? ` — ${e.reason}` : ""}` : "Couldn't connect — is the container running?", opened ? "" : "err"); + this.disconnectBtn.hidden = true; + this.sig = null; + if (this.app) this.update(this.app); + }; + } +} + // ── Git source ── function commitView(c, webUrl = "") { @@ -2098,19 +2871,28 @@ function commitView(c, webUrl = "") { h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`)); } +function copyField(value, { secret = false, label = "Copy" } = {}) { + const input = h("input", { class: "input", value, readonly: true, type: secret ? "password" : "text", onfocus: (e) => e.target.select() }); + return h("div", { class: "copy-field" }, input, + secret ? btn(null, "search", () => { input.type = input.type === "password" ? "text" : "password"; }, "ghost icon-only", "Show / hide") : null, + btn(null, "copy", () => copyText(value, `${label} copied`), "ghost icon-only", label)); +} + class SourcePanel extends Panel { constructor(card, key) { super(card, key); this.info = h("div", null, h("p", { class: "muted" }, "Loading…")); this.check = h("div", { class: "update-box", hidden: true }); + this.hook = h("div", { class: "hook-box" }, h("p", { class: "muted small" }, "Loading…")); this.el.append( this.info, this.check, h("div", { class: "row-actions", style: "margin-top:14px" }, btn("Check for updates", "rotate", () => this.load(true)), - btn("Sync & deploy", "git", () => actions.sync(this.name), "primary", "Fetch the branch, reset to it and redeploy"))); + btn("Sync & deploy", "git", () => actions.sync(this.name), "primary", "Fetch the branch, reset to it and deploy")), + this.hook); } - show() { this.load(false); } + show() { this.load(false); this.loadHook(); } async load(fetchRemote) { if (fetchRemote) { this.check.hidden = false; @@ -2155,6 +2937,88 @@ class SourcePanel extends Panel { web && d.commit && d.remote ? h("a", { href: `${web}/compare/${d.commit.sha}...${d.remote.sha}`, target: "_blank", rel: "noopener" }, icon("external"), " Compare") : null); } } + + // Auto deploy: a webhook from the repository host triggers a sync on every push. + async loadHook() { + try { + this.renderHook(await api.autodeploy(this.name)); + } catch (e) { + fill(this.hook, h("span", { class: "error" }, `Couldn't load auto deploy settings: ${e.message}`)); + } + } + async setHook(body, success) { + this.hookBusy = true; + try { + const d = await api.setAutodeploy(this.name, body); + this.renderHook(d); + if (success && !d.warning) toast("success", success); + poller.now(); + } catch (e) { + toast("error", "Couldn't change auto deploy", { detail: e.message }); + this.loadHook(); + } finally { + this.hookBusy = false; + } + } + renderHook(d) { + const branch = d.branch || "the branch"; + const toggle = h("input", { + type: "checkbox", checked: d.enabled, + onchange: () => this.setHook({ enabled: toggle.checked }, toggle.checked ? `Auto deploy on for ${this.name}` : "Auto deploy off"), + }); + const parts = [ + h("label", { class: "check", style: "margin:0" }, toggle, + h("span", null, h("strong", null, "Deploy on every push"), h("br"), + h("span", { class: "muted small" }, `A push to `, h("code", null, branch), ` syncs the app and deploys it, like pressing “Sync & deploy”.`))), + ]; + if (d.warning) parts.push(h("div", { class: "notice warn", style: "margin-top:10px" }, icon("alert"), h("span", null, d.warning))); + if (d.enabled) { + const f = d.forgejo || {}; + if (f.hook_id) { + parts.push(h("div", { class: "notice", style: "margin-top:10px" }, icon("check"), + h("span", null, "Webhook added to ", h("code", null, f.repo), " on Forgejo. ", + f.hooks_url ? h("a", { href: f.hooks_url, target: "_blank", rel: "noopener" }, "Webhook settings") : null))); + } else { + parts.push(h("p", { class: "small", style: "margin-top:12px" }, + "Add a webhook to the repository that sends ", h("strong", null, "push"), " events as ", h("code", null, "application/json"), " to:")); + parts.push(copyField(d.url, { label: "Webhook URL" })); + parts.push(h("p", { class: "small muted", style: "margin-top:8px" }, "with this secret (Forgejo, Gitea and GitHub sign each delivery with it):")); + parts.push(copyField(d.secret, { secret: true, label: "Secret" })); + if (f.can_register) { + parts.push(h("div", { class: "row-actions" }, + btn(`Add to ${f.repo} on Forgejo`, "zap", () => this.setHook({ enabled: true, register: true }, "Webhook added"), "primary"))); + } else if (f.repo) { + parts.push(h("p", { class: "hint" }, "Or ", + h("button", { type: "button", class: "link", onclick: () => settings.open() }, "connect a Forgejo token"), + " (with write access to the repository) and the panel adds it for you.")); + } + } + parts.push(h("details", { class: "env-details", style: "margin-top:10px" }, + h("summary", null, h("span", { class: "small" }, "Trigger from CI or a script")), + h("p", { class: "hint" }, "A POST with the secret as token deploys the latest commit:"), + copyField(`curl -X POST '${d.url}?token=${d.secret}'`, { secret: true, label: "Command" }))); + const last = d.last; + parts.push(h("p", { class: "small muted", style: "margin-top:10px" }, + last + ? [`Last delivery ${ago(last.time * 1000)}: `, h("strong", null, last.result), + last.commit ? [" · ", h("code", null, last.commit)] : null, + last.pusher ? ` by ${last.pusher}` : "", + last.deployment ? [" · ", h("button", { type: "button", class: "link", onclick: () => deployments.show(this.name, last.deployment) }, `deployment #${last.deployment}`)] : null] + : "No deliveries yet — push a commit to try it.")); + parts.push(h("div", { class: "row-actions" }, + btn("New secret", "key", async () => { + const ok = await confirmDialog({ + title: "Generate a new webhook secret?", + body: [h("p", null, f.hook_id + ? "The Forgejo webhook is recreated with the new secret." + : "Deliveries signed with the old secret are rejected until you update the webhook.")], + confirmLabel: "New secret", + }); + if (ok) this.setHook({ enabled: true, regenerate: true }, "New secret generated"); + }, "ghost"))); + } + fill(this.hook, h("h4", null, icon("zap"), " Auto deploy"), parts); + } } // ─── Operations ──────────────────────────────────────────────────────────── @@ -2191,10 +3055,10 @@ async function runOp(name, kind, label, fn, opts = {}) { const actions = { deploy(name) { - return runOp(name, "deploy", `Deploy · ${name}`, () => api.deploy(name), { success: `${name} deployed` }); + return deployments.start(name, "deploy", `Deploy · ${name}`, () => api.deploy(name)); }, restart(name) { - return runOp(name, "restart", `Restart · ${name}`, () => api.restart(name), { success: `${name} restarted` }); + return deployments.start(name, "restart", `Restart · ${name}`, () => api.restart(name)); }, async stop(name) { const ok = await confirmDialog({ @@ -2204,19 +3068,15 @@ const actions = { }); if (ok) return runOp(name, "stop", `Stop · ${name}`, () => api.stop(name), { success: `${name} stopped` }); }, - async sync(name) { - const res = await runOp(name, "repo-pull", `Sync from git · ${name}`, () => api.repoPull(name), { - success: `${name} synced`, - successDetail: (r) => r.after - ? (r.changed ? `Now at ${r.after.short}: ${r.after.subject}` : `Already at ${r.after.short} — redeployed.`) - : "Redeployed.", + sync(name) { + return deployments.start(name, "repo-pull", `Sync from git · ${name}`, () => api.repoPull(name), { + after: () => { + const card = cards.get(name); + if (!card?.panels) return; + card.panels.compose.reloadIfClean(); + if (card.activeTab === "source") card.panels.source.load(false); + }, }); - const card = cards.get(name); - if (res && card?.panels) { - card.panels.compose.reloadIfClean(); - if (card.activeTab === "source") card.panels.source.load(false); - } - return res; }, async remove(name) { const choice = await confirmDialog({ @@ -2273,7 +3133,11 @@ function openAppMenu(anchor, name) { { label: "Restart", icon: "rotate", run: () => actions.restart(name), disabled: busy }, { label: "Stop", icon: "stop", run: () => actions.stop(name), disabled: busy }, app.repo_url ? { label: "Sync from git", icon: "git", run: () => actions.sync(name), disabled: busy } : null, - { label: "View logs", icon: "logs", run: () => toggleExpand(name, "logs") }, + "-", + { label: "Deployments", icon: "rocket", run: () => toggleExpand(name, "deployments") }, + { label: "Logs", icon: "logs", run: () => toggleExpand(name, "logs") }, + { label: "Monitoring", icon: "chart", run: () => toggleExpand(name, "monitoring") }, + { label: "Terminal", icon: "terminal", run: () => toggleExpand(name, "terminal") }, { label: "Edit compose", icon: "file", run: () => toggleExpand(name, "compose") }, "-", { label: "Remove…", icon: "trash", run: () => actions.remove(name), variant: "danger", disabled: busy }, @@ -2405,17 +3269,6 @@ function confirmDialog({ title, body = [], confirmLabel = "Confirm", danger = fa // ─── New app dialog ──────────────────────────────────────────────────────── -function suggestPort() { - let max = 18079; - for (const app of state.apps.values()) { - for (const r of app.routes) { - const port = Number(String(r.upstream).split(":").pop()); - if (port >= 18000 && port < 20000 && port > max) max = port; - } - } - return max + 1; -} - function suggestBaseDomain() { const counts = new Map(); for (const app of state.apps.values()) { @@ -2434,19 +3287,17 @@ function suggestBaseDomain() { return host.length > 2 ? host.slice(1).join(".") : (location.hostname || "example.com"); } -const composeTemplate = (port) => `services: +const COMPOSE_TEMPLATE = `services: app: image: docker.io/library/nginx:alpine restart: unless-stopped - ports: - # Publish on localhost only; Caddy routes the domain to this port. - - "127.0.0.1:${port}:80" + # No ports: needed — the panel publishes the port your domain points at. `; const SOURCE_HINTS = { - default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.", - raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.", - git: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.", + default: "Starts a tiny traefik/whoami container (port 80) so you can check the domain works. Edit the compose file afterwards.", + raw: "Paste a compose file. Point the domain at the container port the app listens on; no ports: section needed.", + git: "Clones a repository and deploys its compose file. Pushes can deploy automatically (Git tab), or use “Sync & deploy”.", }; const slugify = (s) => String(s || "").toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, ""); @@ -2461,11 +3312,11 @@ const newApp = { init() { this.dlg = $("#newAppDlg"); - this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } }); + this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; }, datalist: "naTargets" }); $("#naRoutes").append(this.routes.el); this.env = envEditor({ onChange: () => this.updateEnvCount() }); $("#naEnv").append(this.env.el); - $("#naAddRoute").append(icon("plus"), "Add route"); + $("#naAddRoute").append(icon("plus"), "Add domain"); $("#naAddRoute").onclick = () => this.routes.add(); $("#naSource").onclick = (e) => { const b = e.target.closest("button[data-v]"); @@ -2483,6 +3334,11 @@ const newApp = { }); this.routes.el.addEventListener("input", (e) => { if (e.target === this.routes.first()?.d) this.domainTouched = true; + if (e.target === this.routes.first()?.u) this.targetTouched = true; + }); + $("#naCompose").addEventListener("input", () => { + clearTimeout(this.inspectTimer); + this.inspectTimer = setTimeout(() => this.inspectCompose(), 400); }); $("#naRepoSearch").addEventListener("input", () => { clearTimeout(this.searchTimer); @@ -2513,12 +3369,13 @@ const newApp = { this.env.set([]); this.updateEnvCount(); this.domainTouched = false; + this.targetTouched = false; this.selectedRepo = null; this.reposLoaded = false; - this.port = suggestPort(); this.base = suggestBaseDomain(); - this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]); + this.routes.set([{ domain: "", target: "80" }]); this.routes.first().d.placeholder = `app.${this.base}`; + fill($("#naTargets")); this.setSource("default"); this.dlg.showModal(); $("#naName").focus(); @@ -2542,13 +3399,34 @@ const newApp = { $("#naRaw").hidden = v !== "raw"; $("#naGit").hidden = v !== "git"; $("#naSourceHint").textContent = SOURCE_HINTS[v]; - if (v === "raw" && !$("#naCompose").value.trim()) { - const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port; - $("#naCompose").value = composeTemplate(port); + if (v === "raw" && !$("#naCompose").value.trim()) $("#naCompose").value = COMPOSE_TEMPLATE; + const first = this.routes.first(); + if (first && !this.targetTouched) { + // The starter and the template listen on 80; a repository's port isn't known yet. + first.u.value = v === "git" ? "" : "80"; + first.u.placeholder = v === "git" ? "port, or service:port" : "8080 or web:8080"; } + fill($("#naTargets")); + if (v === "raw") this.inspectCompose(); if (v === "git") this.initGit(); }, + // Suggest targets from the pasted compose file (and fill in an obvious one). + async inspectCompose() { + if (this.source !== "raw") return; + let services; + try { + ({ services } = await api.inspectCompose($("#naCompose").value)); + } catch { + return; + } + const options = targetOptions(services); + fill($("#naTargets"), options.map((v) => h("option", { value: v }))); + const first = this.routes.first(); + const guess = options.find((o) => !o.endsWith(":")); + if (first && !this.targetTouched && guess) first.u.value = guess; + }, + async initGit() { let d = null; try { d = await integrations.load(); } catch { /* fall back to URL mode */ } diff --git a/nix/module.nix b/nix/module.nix index 47d976b..3ad08fb 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -72,6 +72,17 @@ in ''; }; + webhooks = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Let `/hooks/` on the panel's domain through without Authelia, so + Forgejo, GitHub or CI can trigger deployments. Every delivery must be + signed with (or carry) the app's webhook secret, and the endpoint only + answers for apps with auto deploy switched on. + ''; + }; + forgejo.enable = lib.mkOption { type = lib.types.bool; default = config.services.forgejo.enable; @@ -131,6 +142,10 @@ in PANEL_GROUP = cfg.group; PANELCTL_PATH = lib.getExe' cfg.package "panelctl"; } + // lib.optionalAttrs (cfg.domain != null) { + # Used for the webhook URLs handed to Forgejo / GitHub. + PANEL_PUBLIC_URL = "https://${cfg.domain}"; + } // lib.optionalAttrs cfg.forgejo.enable { # The API is reached on localhost; clones use the public URLs. PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL; @@ -150,14 +165,32 @@ in virtualHosts = lib.mkIf (cfg.domain != null) { ${cfg.domain}.extraConfig = - lib.optionalString (cfg.autheliaAddress != null) '' + let + upstream = "${cfg.listenAddress}:${toString cfg.port}"; + in + lib.optionalString cfg.webhooks '' + # Push webhooks authenticate with the app's secret, not a login. + # The header tells the panel the request skipped forward_auth, so it + # accepts nothing but a webhook delivery from here (Caddy matches the + # cleaned path but forwards the original one). + handle /hooks/* { + reverse_proxy ${upstream} { + header_up X-Panel-Hook 1 + } + } + '' + + '' + handle { + '' + + lib.optionalString (cfg.autheliaAddress != null) '' forward_auth ${cfg.autheliaAddress} { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Email Remote-Name } '' + '' - reverse_proxy ${cfg.listenAddress}:${toString cfg.port} + reverse_proxy ${upstream} + } ''; }; }; diff --git a/nix/package.nix b/nix/package.nix index 4557c49..a3833f5 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,6 +1,7 @@ { lib, stdenvNoCC, + fetchurl, makeWrapper, bash, python3, @@ -37,10 +38,23 @@ let util-linux # flock, used to serialise routes file writes openssh # cloning repositories over ssh with the panel's deploy key ]; + + # PyYAML lets the panel suggest services and ports from compose files. + python = python3.withPackages (ps: [ ps.pyyaml ]); + + # xterm.js for the web terminal, served by the panel itself (no CDN at runtime). + xterm = fetchurl { + url = "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz"; + hash = "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="; + }; + xtermFit = fetchurl { + url = "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz"; + hash = "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="; + }; in stdenvNoCC.mkDerivation { pname = "reudy-panel"; - version = "0.1.0"; + version = "0.2.0"; src = lib.fileset.toSource { root = ../.; @@ -63,12 +77,18 @@ stdenvNoCC.mkDerivation { install -Dm644 panel-api.py $out/share/panel/panel-api.py cp -r frontend $out/share/panel/frontend + mkdir -p xterm fit $out/share/panel/frontend/vendor + tar -xzf ${xterm} -C xterm + tar -xzf ${xtermFit} -C fit + install -m644 xterm/package/lib/xterm.js xterm/package/css/xterm.css fit/package/lib/addon-fit.js \ + $out/share/panel/frontend/vendor/ + install -Dm755 panelctl.sh $out/bin/panelctl patchShebangs --host $out/bin/panelctl wrapProgram $out/bin/panelctl \ --suffix PATH : ${lib.makeBinPath runtimeDeps} - makeWrapper ${python3.interpreter} $out/bin/panel-api \ + makeWrapper ${python.interpreter} $out/bin/panel-api \ --add-flags $out/share/panel/panel-api.py \ --suffix PATH : ${lib.makeBinPath runtimeDeps} \ --set-default PANELCTL_PATH $out/bin/panelctl \ diff --git a/panel-api.py b/panel-api.py index d6b029d..ddbe164 100644 --- a/panel-api.py +++ b/panel-api.py @@ -1,21 +1,39 @@ #!/usr/bin/env python3 """panel-api — HTTP wrapper around panelctl with a web UI.""" +import base64 +import collections +import fcntl +import hashlib +import hmac import json import os +import pty import re +import secrets +import select import shlex import shutil +import signal import socket +import sqlite3 +import struct import subprocess +import termios import threading import time +import traceback import urllib.error import urllib.request from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager + +try: + import yaml # optional: lets the panel suggest services and ports from compose files +except ImportError: # pragma: no cover + yaml = None from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlparse, parse_qs, quote +from urllib.parse import urlparse, parse_qs, quote, unquote PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") @@ -33,11 +51,26 @@ FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).r FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/") FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or "" +# Public address of the panel (e.g. https://panel.example.com), used for webhook +# URLs. Without it the address the browser used is taken from the request. +PUBLIC_URL = os.environ.get("PANEL_PUBLIC_URL", "").rstrip("/") + PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel") FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token") SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh") SSH_KEY = os.path.join(SSH_DIR, "id_ed25519") ENV_DIR = os.path.join(BASE_DIR, "state", "env") +DB_PATH = os.path.join(PANEL_STATE_DIR, "panel.db") +DEPLOY_DIR = os.path.join(PANEL_STATE_DIR, "deployments") +HOOK_DIR = os.path.join(PANEL_STATE_DIR, "hooks") + +# Host ports the panel hands out for routes that point at a compose service. +PORT_RANGE = (18000, 19999) +# Deployments (and their logs) kept per app. +DEPLOY_KEEP = 50 +# How long a single deployment command may run (image pulls and builds can be slow). +DEPLOY_TIMEOUT = int(os.environ.get("PANEL_DEPLOY_TIMEOUT", "3600")) +WEBHOOK_MAX_BODY = 5 * 1024 * 1024 COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"] GIT_TIMEOUT = 300 @@ -91,6 +124,20 @@ def app_operation(name, action): _busy.pop(name, None) +def try_acquire_app(name, action): + """Non-blocking version of app_operation for background jobs.""" + with _busy_lock: + if name in _busy: + return False + _busy[name] = action + return True + + +def release_app(name): + with _busy_lock: + _busy.pop(name, None) + + def busy_snapshot(): with _busy_lock: return dict(_busy) @@ -238,19 +285,25 @@ def forgejo_token(): return "" -def forgejo_api(path, token=None, timeout=10): +def forgejo_api(path, token=None, timeout=10, method="GET", body=None): if not FORGEJO_API_URL: raise ForgejoError("no Forgejo instance is configured") token = forgejo_token() if token is None else token - req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"}) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(FORGEJO_API_URL + path, data=data, method=method, + headers={"Accept": "application/json"}) + if data is not None: + req.add_header("Content-Type", "application/json") if token: req.add_header("Authorization", f"token {token}") try: with urllib.request.urlopen(req, timeout=timeout) as res: return json.loads(res.read().decode("utf-8") or "null") except urllib.error.HTTPError as exc: - if exc.code in (401, 403): + if exc.code == 401: raise ForgejoError("Forgejo rejected the token") from exc + if exc.code == 403: + raise ForgejoError("the Forgejo token isn't allowed to do this") from exc if exc.code == 404: raise ForgejoError("not found on Forgejo (or no access)") from exc raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc @@ -356,11 +409,30 @@ def find_compose_file(repo_dir): # ── Manifest helpers ── +_manifest_lock = threading.Lock() + + def update_manifest(name, values): """Set KEY="value" lines in an app manifest, replacing existing keys.""" for key, value in values.items(): if re.search(r'["`$\\\n]', value): raise ValueError(f"unsafe characters in {key}") + with _manifest_lock: + _update_manifest(name, values) + + +def read_manifest(name): + """The app's manifest as a dict, or None when the app doesn't exist.""" + if not is_safe_name(name): + return None + try: + with open(os.path.join(BASE_DIR, "state", "apps", f"{name}.env"), "r", encoding="utf-8") as fh: + return parse_env_blob(fh.read()) + except OSError: + return None + + +def _update_manifest(name, values): manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") with open(manifest_path, "r", encoding="utf-8") as fh: lines = fh.readlines() @@ -390,16 +462,209 @@ def manifest_routes(env): entry = entry.strip() if not entry: continue - fields = entry.split("|", 2) + fields = entry.split("|", 3) if len(fields) < 2: continue route = {"domain": fields[0].strip(), "upstream": fields[1].strip()} if len(fields) > 2 and fields[2].strip(): route["path"] = fields[2].strip() + if len(fields) > 3 and fields[3].strip(): + svc, _, port = fields[3].strip().rpartition(":") + route["service"] = svc + route["port"] = int(port) if port.isdigit() else None routes.append(route) return routes +# ── Routes to compose services ── +# A route either names an upstream directly (host:port, e.g. a service on the +# host) or targets a compose service's container port. For the latter the panel +# picks a free 127.0.0.1 port and panelctl publishes the container port on it. + +_port_lock = threading.Lock() +_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") + + +def parse_route_target(value): + """'web:8080' / '8080' -> ('service', 'web' or '', 8080); '127.0.0.1:9000' -> ('upstream', ...).""" + value = str(value or "").strip() + if re.fullmatch(r"\d+", value): + port = int(value) + if not 1 <= port <= 65535: + raise ValueError(f"port {port} must be between 1 and 65535") + return ("service", "", port) + m = re.fullmatch(r"(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9._-]+):(\d+)", value) + if not m: + raise ValueError(f"'{value}' should be a port, service:port or host:port") + host, port = m.group(1), int(m.group(2)) + if not 1 <= port <= 65535: + raise ValueError(f"port {port} must be between 1 and 65535") + if host == "localhost" or host.startswith("[") or _IPV4_RE.match(host) or "." in host: + return ("upstream", f"{host}:{port}", None) + return ("service", host, port) + + +def used_route_ports(exclude_app=None): + used = set() + for app in load_app_summaries(): + if app["name"] == exclude_app: + continue + for r in app["routes"]: + port = r["upstream"].rsplit(":", 1)[-1] + if port.isdigit(): + used.add(int(port)) + return used + + +def port_is_free(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def compose_services(name): + """Service names of an app's compose file, or None when they can't be read.""" + result = run_panelctl(["services", name]) + if not result["ok"]: + return None + return [line.strip() for line in result["stdout"].splitlines() if re.fullmatch(r"[A-Za-z0-9._-]+", line.strip())] + + +def resolve_routes(name, routes, services=None): + """Validate route dicts from the API and turn them into manifest entries. + + Each route has a domain, an optional path and either a target (port, + service:port or host:port) or, as before, an upstream. Routes to a service + keep the host port they already had, so saving routes doesn't move them. + """ + if not isinstance(routes, list) or not routes: + raise ValueError("at least one route is required") + current = {} + manifest = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") + if os.path.isfile(manifest): + with open(manifest, "r", encoding="utf-8") as fh: + for r in manifest_routes(parse_env_blob(fh.read())): + if r.get("port"): + current[(r.get("service", ""), r["port"])] = r["upstream"] + + with _port_lock: + taken = used_route_ports(exclude_app=name) + assigned = {} + entries = [] + for r in routes: + if not isinstance(r, dict): + raise ValueError("each route must be an object") + domain = str(r.get("domain", "")).strip() + path = str(r.get("path", "") or "").strip() + if not domain: + raise ValueError("every route needs a domain") + if any(c in domain + path for c in "|,\n\"'`$\\ "): + raise ValueError(f"invalid characters in route {domain}") + target = r.get("target") + if target in (None, "") and r.get("port"): + target = f"{r.get('service', '')}:{r['port']}" if r.get("service") else str(r["port"]) + if target in (None, ""): + upstream = str(r.get("upstream", "")).strip() + if not upstream: + raise ValueError(f"route {domain} needs a target (port or service:port)") + entries.append(f"{domain}|{upstream}|{path}" if path else f"{domain}|{upstream}") + continue + kind, svc_or_upstream, cport = parse_route_target(target) + if kind == "upstream": + entries.append(f"{domain}|{svc_or_upstream}|{path}" if path else f"{domain}|{svc_or_upstream}") + continue + svc = svc_or_upstream + if services is not None: + if not svc and len(services) == 1: + svc = services[0] + elif not svc and len(services) > 1: + raise ValueError(f"route {domain}: the compose file has several services ({', '.join(services)}), say which one, e.g. {services[0]}:{cport}") + elif svc and services and svc not in services: + raise ValueError(f"route {domain}: there is no service '{svc}' in the compose file ({', '.join(services)})") + key = (svc, cport) + if key not in assigned: + # Keep the port this target already had (also from before its service was named). + upstream = current.get(key) or current.get(("", cport)) + in_use = taken | {int(u.rsplit(":", 1)[-1]) for u in assigned.values()} + if upstream and int(upstream.rsplit(":", 1)[-1]) in in_use: + upstream = None + if not upstream: + port = next((p for p in range(PORT_RANGE[0], PORT_RANGE[1] + 1) + if p not in in_use and port_is_free(p)), None) + if port is None: + raise ValueError("no free port left for the route") + upstream = f"127.0.0.1:{port}" + assigned[key] = upstream + entries.append(f"{domain}|{assigned[key]}|{path}|{svc}:{cport}") + return ",".join(entries) + + +def finalize_route_services(name): + """Once an app's compose file exists: name the service of routes that only + gave a port, and check that the services routes name exist. + Returns an error message, or None.""" + routes = manifest_routes(read_manifest(name) or {}) + if not any(r.get("port") for r in routes): + return None + services = compose_services(name) + if not services: + return None # compose file unreadable for now; the deploy reports it + changed = False + entries = [] + for r in routes: + fields = [r["domain"], r["upstream"], r.get("path", "")] + if r.get("port"): + svc = r.get("service") + if not svc: + if len(services) != 1: + return (f"route {r['domain']}: the compose file has several services ({', '.join(services)}), " + f"say which one, e.g. {services[0]}:{r['port']}") + svc, changed = services[0], True + elif svc not in services: + return f"route {r['domain']}: there is no service '{svc}' in the compose file ({', '.join(services)})" + fields.append(f"{svc}:{r['port']}") + entries.append("|".join(fields) if len(fields) == 4 else "|".join(fields).rstrip("|")) + if changed: + result = run_panelctl(["set-routes", name, ",".join(entries)]) + if not result["ok"]: + return last_line(result["stderr"]) or "failed to update routes" + return None + + +def compose_service_ports(compose_text): + """Services and the container ports they mention (ports/expose), for suggestions.""" + if yaml is None: + return None + try: + doc = yaml.safe_load(compose_text) or {} + except yaml.YAMLError: + return None + services = doc.get("services") if isinstance(doc, dict) else None + if not isinstance(services, dict): + return None + out = [] + for svc, spec in services.items(): + ports = [] + spec = spec if isinstance(spec, dict) else {} + for item in list(spec.get("ports") or []) + list(spec.get("expose") or []): + if isinstance(item, dict): + p = item.get("target") + else: + p = str(item).split("/")[0].rsplit(":", 1)[-1] + try: + p = int(str(p).split("-")[0]) + except ValueError: + continue + if 0 < p < 65536 and p not in ports: + ports.append(p) + out.append({"name": str(svc), "ports": ports, "image": str(spec.get("image") or "")}) + return out + + def load_app_summaries(): """Read every app manifest directly (much faster than shelling out per app).""" apps_dir = os.path.join(BASE_DIR, "state", "apps") @@ -431,6 +696,7 @@ def load_app_summaries(): "repo_web_url": repo_web_url(repo_url) if repo_url else "", "env_count": len(read_app_env(name)), "env_inject": env.get("APP_ENV_INJECT", "true") != "false", + "autodeploy": env.get("APP_AUTODEPLOY", "false") == "true", }) return apps @@ -603,8 +869,787 @@ def parse_backups_output(stdout): return backups -# Actions that only read state and may run alongside anything else. -LOCK_FREE_ACTIONS = {"validate-compose"} +# ── Deployments ── +# Deploys, git syncs, restarts and rollbacks run in the background, one at a +# time per app, queued behind whatever else the app is doing. Each one is a row +# in SQLite plus a log file that the UI streams while it runs. + +FINAL_STATES = ("success", "failed", "cancelled") +# What /status reports as the app's running operation, per job kind. +JOB_BUSY = {"deploy": "deploy", "sync": "repo-pull", "restart": "restart"} +ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[()][A-Za-z0-9]|\x1b[=>]") + + +def deploy_dir(app): + return os.path.join(DEPLOY_DIR, app) + + +def deploy_log_path(app, dep_id): + return os.path.join(deploy_dir(app), f"{dep_id}.log") + + +def deploy_snapshot_path(app, dep_id): + return os.path.join(deploy_dir(app), f"{dep_id}.compose.yaml") + + +def clean_line(text): + """Drop terminal escapes and carriage-return progress redraws from command output.""" + text = ANSI_RE.sub("", text) + if "\r" in text: + text = "\n".join(part.rsplit("\r", 1)[-1] for part in text.split("\n")) + return text + + +class DeployStore: + def __init__(self, path): + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + self.lock = threading.Lock() + self.db = sqlite3.connect(path, check_same_thread=False, isolation_level=None, timeout=10) + self.db.row_factory = sqlite3.Row + self.db.execute("""CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app TEXT NOT NULL, + kind TEXT NOT NULL, + trigger TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + created REAL NOT NULL, + started REAL, + finished REAL, + commit_sha TEXT, + commit_subject TEXT, + commit_author TEXT, + target TEXT, + error TEXT, + snapshot INTEGER NOT NULL DEFAULT 0)""") + self.db.execute("CREATE INDEX IF NOT EXISTS deployments_app ON deployments (app, id)") + # Whatever was still running or queued belonged to a previous panel process. + now = time.time() + self.db.execute("UPDATE deployments SET status='failed', finished=?, error='interrupted because the panel restarted' " + "WHERE status='running'", (now,)) + self.db.execute("UPDATE deployments SET status='cancelled', finished=?, error='the panel restarted before it started' " + "WHERE status='queued'", (now,)) + + @staticmethod + def _dict(row): + if row is None: + return None + d = dict(row) + d["snapshot"] = bool(d["snapshot"]) + d["commit_short"] = (d["commit_sha"] or "")[:7] or None + d["duration"] = round((d["finished"] or time.time()) - d["started"], 1) if d["started"] else None + return d + + def _all(self, sql, args=()): + with self.lock: + return [self._dict(r) for r in self.db.execute(sql, args).fetchall()] + + def create(self, app, kind, trigger, title, target=None): + with self.lock: + cur = self.db.execute( + "INSERT INTO deployments (app, kind, trigger, title, status, created, target) VALUES (?, ?, ?, ?, 'queued', ?, ?)", + (app, kind, trigger, title, time.time(), target)) + dep_id = cur.lastrowid + return self.get(dep_id) + + def update(self, dep_id, **fields): + if not fields: + return + cols = ", ".join(f"{k} = ?" for k in fields) + with self.lock: + self.db.execute(f"UPDATE deployments SET {cols} WHERE id = ?", (*fields.values(), dep_id)) + + def get(self, dep_id): + rows = self._all("SELECT * FROM deployments WHERE id = ?", (dep_id,)) + return rows[0] if rows else None + + def list(self, app, limit=30): + return self._all("SELECT * FROM deployments WHERE app = ? ORDER BY id DESC LIMIT ?", (app, limit)) + + def latest(self): + rows = self._all("SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments GROUP BY app)") + return {r["app"]: r for r in rows} + + def delete_app(self, app): + with self.lock: + self.db.execute("DELETE FROM deployments WHERE app = ?", (app,)) + shutil.rmtree(deploy_dir(app), ignore_errors=True) + + def prune(self, app): + old = self._all("SELECT * FROM deployments WHERE app = ? AND status IN ('success', 'failed', 'cancelled') " + "ORDER BY id DESC LIMIT -1 OFFSET ?", (app, DEPLOY_KEEP)) + for d in old: + for path in (deploy_log_path(app, d["id"]), deploy_snapshot_path(app, d["id"])): + try: + os.remove(path) + except FileNotFoundError: + pass + if old: + with self.lock: + self.db.execute(f"DELETE FROM deployments WHERE id IN ({','.join('?' * len(old))})", [d["id"] for d in old]) + + +class DeployCancelled(Exception): + pass + + +class DeployFailed(Exception): + pass + + +class Job: + def __init__(self, dep_id, app, kind, params): + self.id = dep_id + self.app = app + self.kind = kind + self.params = params + self.cancelled = False + self.timed_out = False + self.proc = None + self.log = None + self.tail = collections.deque(maxlen=60) + + def write(self, text): + self.log.write(text) + self.log.flush() + + def step(self, text): + self.write(f"==> {text}\n") + + def run(self, cmd, env=None, cwd=None, timeout=DEPLOY_TIMEOUT, redact=False): + """Run a command, streaming its output into the deployment log. Returns the exit code.""" + if self.cancelled: + raise DeployCancelled() + self.tail.clear() + self.timed_out = False + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, + env=env, cwd=cwd, start_new_session=True) + self.proc = proc + timer = threading.Timer(timeout, self._timeout, args=(proc,)) + timer.daemon = True + timer.start() + try: + for raw in iter(proc.stdout.readline, b""): + line = clean_line(raw.decode("utf-8", "replace")) + if redact: + line = redact_credentials(line) + self.write(line) + if line.strip(): + self.tail.append(line.strip()) + code = proc.wait() + finally: + timer.cancel() + proc.stdout.close() + self.proc = None + if self.cancelled: + raise DeployCancelled() + if self.timed_out: + raise DeployFailed(f"timed out after {timeout}s") + return code + + def _timeout(self, proc): + self.timed_out = True + self._kill(proc) + + @staticmethod + def _kill(proc): + try: + os.killpg(proc.pid, signal.SIGTERM) + except OSError: + return + + def escalate(): + time.sleep(10) + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGKILL) + except OSError: + pass + threading.Thread(target=escalate, daemon=True).start() + + def cancel(self): + self.cancelled = True + proc = self.proc + if proc: + self._kill(proc) + + def error_line(self, fallback): + """The most telling error line of the last command.""" + generic = ("compose up failed", "compose down failed") + for line in reversed(self.tail): + m = re.match(r"^(?:error|fatal)\b:?\s*(.*)$", line, re.I) + if m and not any(g in line for g in generic): + return m.group(1) or line + return fallback + + +class DeployRunner: + def __init__(self, store): + self.store = store + self.lock = threading.Lock() + self.pending = {} # app -> Job waiting to run (a newer request replaces it) + self.running = {} # app -> Job + self.workers = set() + + def submit(self, app, kind, trigger, title, **params): + dep = self.store.create(app, kind, trigger, title, target=params.get("commit") or params.get("snapshot_from")) + job = Job(dep["id"], app, kind, params) + with self.lock: + replaced = self.pending.get(app) + self.pending[app] = job + start = app not in self.workers + self.workers.add(app) + if replaced: + self.store.update(replaced.id, status="cancelled", finished=time.time(), error=f"superseded by #{dep['id']}") + if start: + threading.Thread(target=self._worker, args=(app,), daemon=True, name=f"deploy-{app}").start() + return dep + + def queued(self, app): + with self.lock: + return app in self.pending + + def cancel(self, dep_id): + with self.lock: + for app, job in list(self.pending.items()): + if job.id == dep_id: + del self.pending[app] + self.store.update(dep_id, status="cancelled", finished=time.time(), error="cancelled before it started") + return True + for job in self.running.values(): + if job.id == dep_id: + job.cancel() + return True + return False + + def cancel_app(self, app): + with self.lock: + jobs = [j for j in (self.pending.get(app), self.running.get(app)) if j] + for job in jobs: + self.cancel(job.id) + + def wait(self, dep_id, timeout=DEPLOY_TIMEOUT + 60): + deadline = time.time() + timeout + while time.time() < deadline: + dep = self.store.get(dep_id) + if dep is None or dep["status"] in FINAL_STATES: + return dep + time.sleep(0.3) + return self.store.get(dep_id) + + def _worker(self, app): + while True: + with self.lock: + job = self.pending.get(app) + if job is None: + self.workers.discard(app) + return + # Wait for the app's other operations (backup, restore, …) to finish. + if not try_acquire_app(app, JOB_BUSY.get(job.kind, "deploy")): + time.sleep(0.5) + continue + with self.lock: + if self.pending.get(app) is not job: # cancelled or superseded meanwhile + release_app(app) + continue + del self.pending[app] + self.running[app] = job + try: + self._run(job) + finally: + with self.lock: + self.running.pop(app, None) + release_app(app) + + def _run(self, job): + os.makedirs(deploy_dir(job.app), mode=0o750, exist_ok=True) + started = time.time() + self.store.update(job.id, status="running", started=started) + status, error = "success", None + with open(deploy_log_path(job.app, job.id), "w", encoding="utf-8") as log: + job.log = log + try: + JOB_KINDS[job.kind](job, self.store) + except DeployCancelled: + status, error = "cancelled", "cancelled" + except DeployFailed as exc: + status, error = "failed", str(exc) + except Exception as exc: # keep the worker alive, but show what happened + status, error = "failed", f"internal error: {exc}" + job.write(traceback.format_exc()) + summary = {"success": "Deployment finished", "failed": f"Deployment failed: {error}", + "cancelled": "Deployment cancelled"}[status] + job.write(f"\n==> {summary} after {time.time() - started:.1f}s\n") + # The log is complete before the status turns final (log streams rely on it). + self.store.update(job.id, status=status, error=error, finished=time.time()) + self.store.prune(job.app) + + +def job_app_info(job): + app, err = read_app_info(job.app) + if err is not None or app is None: + raise DeployFailed((err or {}).get("error") or (err or {}).get("stderr") or "app not found") + return app + + +def job_record_commit(job, store, repo_dir): + commit = repo_commit(repo_dir) + if commit: + store.update(job.id, commit_sha=commit["sha"], commit_subject=commit["subject"], commit_author=commit["author"]) + job.write(f"Commit {commit['short']}: {commit['subject']} ({commit['author']})\n") + return commit + + +def job_compose_up(job): + job.step("Starting containers (podman compose up)") + if job.run([PANELCTL, "deploy", job.app]) != 0: + raise DeployFailed(job.error_line("compose up failed")) + + +def job_deploy(job, store): + app = job_app_info(job) + repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") + snapshot_from = job.params.get("snapshot_from") + if snapshot_from: + src = deploy_snapshot_path(job.app, snapshot_from) + if not os.path.isfile(src): + raise DeployFailed(f"deployment #{snapshot_from} has no saved compose file") + job.step(f"Restoring the compose file of deployment #{snapshot_from}") + shutil.copyfile(src, app["APP_COMPOSE_FILE"]) + if app.get("APP_REPO_URL") and os.path.isdir(os.path.join(repo_dir, ".git")): + job_record_commit(job, store, repo_dir) + elif os.path.isfile(app["APP_COMPOSE_FILE"]): + # Keep the compose file so this deployment can be redeployed later. + shutil.copyfile(app["APP_COMPOSE_FILE"], deploy_snapshot_path(job.app, job.id)) + store.update(job.id, snapshot=1) + job_compose_up(job) + + +def job_sync(job, store): + app = job_app_info(job) + repo_url = app.get("APP_REPO_URL", "").strip() + if not repo_url: + raise DeployFailed("the app is not linked to a git repository") + repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") + branch = app.get("APP_REPO_BRANCH", "").strip() + commit = job.params.get("commit") + git = shutil.which("git") or "git" + env = git_env() + + if os.path.isdir(os.path.join(repo_dir, ".git")): + ref = branch or repo_current_branch(repo_dir) + if not ref: + raise DeployFailed("cannot determine which branch to sync") + job.step(f"Fetching {ref} from {redact_credentials(repo_url, '')}") + if job.run([git, "-C", repo_dir, "fetch", "origin", ref], env=env, timeout=GIT_TIMEOUT, redact=True) != 0: + raise DeployFailed("git fetch failed: " + git_error("\n".join(job.tail))) + else: + job.step(f"Cloning {redact_credentials(repo_url, '')}") + if os.path.exists(repo_dir): + shutil.rmtree(repo_dir) + ssh_public_key() + args = [git, "clone"] + (["--branch", branch] if branch else []) + ["--", repo_url, repo_dir] + if job.run(args, env=env, timeout=GIT_TIMEOUT, redact=True) != 0: + raise DeployFailed("git clone failed: " + git_error("\n".join(job.tail))) + + target = commit or "FETCH_HEAD" + if commit or os.path.exists(os.path.join(repo_dir, ".git", "FETCH_HEAD")): + if commit and not re.fullmatch(r"[0-9a-f]{7,40}", commit): + raise DeployFailed("invalid commit") + job.step(f"Checking out {commit[:7] if commit else 'the fetched commit'}") + if job.run([git, "-C", repo_dir, "reset", "--hard", target], env=env, timeout=120) != 0: + raise DeployFailed("git reset failed: " + git_error("\n".join(job.tail))) + job_record_commit(job, store, repo_dir) + + compose_path = find_compose_file(repo_dir) + if not compose_path: + raise DeployFailed("no compose file (compose.yaml or docker-compose.yml) in the repository root") + update_manifest(job.app, {"APP_COMPOSE_FILE": compose_path}) + job_compose_up(job) + + +def job_restart(job, store): + app = job_app_info(job) + repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") + if app.get("APP_REPO_URL") and os.path.isdir(os.path.join(repo_dir, ".git")): + job_record_commit(job, store, repo_dir) + job.step("Restarting (podman compose down, then up)") + if job.run([PANELCTL, "restart", job.app]) != 0: + raise DeployFailed(job.error_line("restart failed")) + + +JOB_KINDS = {"deploy": job_deploy, "sync": job_sync, "restart": job_restart} + +deploys = None # DeployStore, set up in main() +runner = None # DeployRunner +sampler = None # StatsSampler + + +def deployment_result(dep): + """Old synchronous response shape (for `wait: true` callers such as scripts).""" + text = "" + try: + with open(deploy_log_path(dep["app"], dep["id"]), "r", encoding="utf-8", errors="replace") as fh: + text = fh.read()[-65536:] + except OSError: + pass + ok = dep["status"] == "success" + return {"ok": ok, "deployment": dep, "stdout": text if ok else "", "stderr": "" if ok else text, + "error": None if ok else dep.get("error")} + + +# ── Webhooks (auto deploy) ── +# POST /hooks/ is reachable without Authelia (the NixOS module routes it +# past forward_auth) and authenticated with a per-app secret instead: a +# Forgejo/Gitea/GitHub HMAC signature, or the secret itself as a token. + +def hook_secret_path(name): + return os.path.join(HOOK_DIR, f"{name}.secret") + + +def hook_last_path(name): + return os.path.join(HOOK_DIR, f"{name}.last.json") + + +def read_hook_secret(name): + try: + with open(hook_secret_path(name), "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return "" + + +def ensure_hook_secret(name, regenerate=False): + secret = "" if regenerate else read_hook_secret(name) + if not secret: + secret = secrets.token_hex(24) + write_private_file(hook_secret_path(name), secret + "\n") + return secret + + +def read_hook_last(name): + try: + with open(hook_last_path(name), "r", encoding="utf-8") as fh: + return json.load(fh) + except (OSError, ValueError): + return None + + +def write_hook_last(name, info): + try: + write_private_file(hook_last_path(name), json.dumps(info)) + except OSError: + pass + + +def verify_hook(secret, body, headers, token=""): + if not secret: + return False + expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + for header in ("X-Forgejo-Signature", "X-Gitea-Signature", "X-Gogs-Signature"): + value = (headers.get(header) or "").strip().lower() + if value and hmac.compare_digest(value, expected): + return True + value = (headers.get("X-Hub-Signature-256") or "").strip().lower() + if value and hmac.compare_digest(value, "sha256=" + expected): + return True + token = headers.get("X-Gitlab-Token") or headers.get("X-Panel-Token") or token + return bool(token) and hmac.compare_digest(token.strip(), secret) + + +def forgejo_repo_path(url): + """owner/repo of a repository on the configured Forgejo, else None.""" + if not url or repo_provider(url) != "forgejo": + return None + _, path = repo_host_and_path(url) + return path if path and FORGEJO_REPO_RE.match(path) else None + + +def register_forgejo_hook(repo, url, secret, branch): + config = {"url": url, "content_type": "json", "secret": secret} + last = None + for hook_type in ("forgejo", "gitea"): + body = {"type": hook_type, "active": True, "events": ["push"], "config": config} + if branch: + body["branch_filter"] = branch + try: + return (forgejo_api(f"/api/v1/repos/{repo}/hooks", method="POST", body=body) or {}).get("id") + except ForgejoError as exc: + last = exc + if "HTTP 422" not in str(exc) and "HTTP 400" not in str(exc): + break + raise last + + +def delete_forgejo_hook(repo, hook_id): + try: + forgejo_api(f"/api/v1/repos/{repo}/hooks/{int(hook_id)}", method="DELETE") + except (ForgejoError, ValueError): + pass + + +# ── Container metrics ── +# A background thread samples `podman stats` for all containers (every few +# seconds while someone looks at the metrics, otherwise once a minute) and +# keeps an hour of CPU and memory history per container. + +_SIZE_UNITS = {"b": 1, "kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12, + "kib": 1024, "mib": 1024 ** 2, "gib": 1024 ** 3, "tib": 1024 ** 4} + + +def parse_size(text): + m = re.match(r"^\s*([\d.]+)\s*([kmgt]?i?b)?\s*$", str(text or ""), re.I) + if not m: + return None + return float(m.group(1)) * _SIZE_UNITS.get((m.group(2) or "b").lower(), 1) + + +def parse_size_pair(text): + parts = str(text or "").split("/") + if len(parts) != 2: + return None, None + return parse_size(parts[0]), parse_size(parts[1]) + + +def parse_percent(value): + try: + return float(str(value).strip().rstrip("%")) + except ValueError: + return None + + +def pick(d, *keys): + for key in keys: + if d.get(key) not in (None, ""): + return d[key] + return None + + +class StatsSampler: + WINDOW = 3600 + + def __init__(self): + self.lock = threading.Lock() + self.series = {} # container -> deque of [t, cpu %, memory bytes] + self.current = {} # container -> latest sample + self.projects = {} # container -> app + self.error = None + self.last_view = 0.0 + self.last_sample = 0.0 + self.wake = threading.Event() + + def start(self): + threading.Thread(target=self._loop, daemon=True, name="stats").start() + return self + + def viewed(self): + self.last_view = time.time() + if time.time() - self.last_sample > 5: + self.wake.set() + + def _loop(self): + while True: + try: + if load_app_summaries(): + self.sample() + except Exception as exc: # never let the sampler die + self.error = f"metrics sampling failed: {exc}" + self.wake.wait(5 if time.time() - self.last_view < 120 else 60) + self.wake.clear() + + def sample(self): + ps = run_panelctl(["containers"]) + if not ps["ok"]: + self.error = last_line(ps["stderr"]) or "podman ps failed" + return + projects = {} + for c in _decode_containers(ps["stdout"]) or []: + labels = c.get("Labels") or {} + project = labels.get("com.docker.compose.project") or labels.get("io.podman.compose.project") + if project: + projects[_container_name(c)] = project + st = run_panelctl(["stats"]) + if not st["ok"]: + with self.lock: + self.projects = projects + self.error = last_line(st["stderr"]) or "podman stats failed" + return + now = time.time() + with self.lock: + self.projects = projects + for s in _decode_containers(st["stdout"]) or []: + name = pick(s, "name", "Name") or _container_name(s) + if name not in projects: + continue + mem_used, mem_limit = parse_size_pair(pick(s, "mem_usage", "MemUsage")) + net_in, net_out = parse_size_pair(pick(s, "net_io", "NetIO")) + blk_in, blk_out = parse_size_pair(pick(s, "block_io", "BlockIO")) + pids = pick(s, "pids", "PIDs", "PIDS") + sample = { + "cpu": parse_percent(pick(s, "cpu_percent", "CPUPerc", "CPU")), + "mem": mem_used, + "mem_limit": mem_limit, + "mem_percent": parse_percent(pick(s, "mem_percent", "MemPerc")), + "net_in": net_in, "net_out": net_out, + "block_in": blk_in, "block_out": blk_out, + "pids": int(pids) if str(pids or "").isdigit() else None, + "time": now, + } + self.current[name] = sample + series = self.series.setdefault(name, collections.deque(maxlen=1000)) + series.append([round(now), sample["cpu"], sample["mem"]]) + while series and series[0][0] < now - self.WINDOW: + series.popleft() + for name in list(self.series): + if name not in projects: + self.series.pop(name, None) + self.current.pop(name, None) + self.error = None + self.last_sample = now + + def for_app(self, app): + with self.lock: + containers = [{ + "name": name, + "current": self.current.get(name), + "history": list(self.series.get(name, [])), + } for name, project in sorted(self.projects.items()) if project == app] + return {"containers": containers, "error": self.error, "sampled": self.last_sample or None} + + +# ── WebSocket (web terminal) ── + +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +class WebSocketClosed(Exception): + pass + + +class WebSocket: + """Just enough of RFC 6455 for a terminal: reads from the handler's buffered + input, writes straight to the socket.""" + + def __init__(self, rfile, sock): + self.rfile = rfile + self.sock = sock + self.lock = threading.Lock() + self.closed = False + + def _read(self, n): + data = self.rfile.read(n) + if data is None or len(data) < n: + raise WebSocketClosed() + return data + + def recv(self): + """Next complete message as (opcode, payload); control frames are returned as they come.""" + message, message_op = b"", None + while True: + b1, b2 = self._read(2) + fin, opcode = b1 & 0x80, b1 & 0x0F + length = b2 & 0x7F + if length == 126: + length = struct.unpack("!H", self._read(2))[0] + elif length == 127: + length = struct.unpack("!Q", self._read(8))[0] + if length > 1 << 20: + raise WebSocketClosed() + mask = self._read(4) if b2 & 0x80 else None + payload = self._read(length) if length else b"" + if mask: + full = (mask * (length // 4 + 1))[:length] + payload = (int.from_bytes(payload, "big") ^ int.from_bytes(full, "big")).to_bytes(length, "big") if length else b"" + if opcode >= 0x8: + return opcode, payload + if opcode: + message_op = opcode + message += payload + if fin: + return message_op or 0x1, message + + def send(self, opcode, payload=b""): + n = len(payload) + if n < 126: + header = struct.pack("!BB", 0x80 | opcode, n) + elif n < 1 << 16: + header = struct.pack("!BBH", 0x80 | opcode, 126, n) + else: + header = struct.pack("!BBQ", 0x80 | opcode, 127, n) + with self.lock: + if self.closed: + raise WebSocketClosed() + try: + self.sock.sendall(header + payload) + except OSError as exc: + self.closed = True + raise WebSocketClosed() from exc + + def close(self, code=1000, reason=""): + try: + self.send(0x8, struct.pack("!H", code) + reason.encode()[:120]) + except WebSocketClosed: + pass + self.closed = True + + +def reap_child(pid): + """Hang up on a terminal's process, escalating until it has exited.""" + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): + try: + os.kill(pid, sig) + except ProcessLookupError: + pass + for _ in range(20): + try: + if os.waitpid(pid, os.WNOHANG) != (0, 0): + return + except ChildProcessError: + return + time.sleep(0.1) + + +def app_services(name): + """Services of an app's compose file with the container ports it mentions.""" + app, err = read_app_info(name) + if err is None and app: + try: + with open(app["APP_COMPOSE_FILE"], "r", encoding="utf-8") as fh: + parsed = compose_service_ports(fh.read()) + if parsed is not None: + return parsed + except OSError: + pass + return [{"name": s, "ports": [], "image": ""} for s in compose_services(name) or []] + + +def autodeploy_info(name, public_base): + manifest = read_manifest(name) or {} + repo_url = manifest.get("APP_REPO_URL", "") + repo = forgejo_repo_path(repo_url) + enabled = manifest.get("APP_AUTODEPLOY", "false") == "true" + return { + "ok": True, + "name": name, + "enabled": enabled, + "url": f"{public_base}/hooks/{name}", + "secret": read_hook_secret(name) if enabled else "", + "git": bool(repo_url), + "branch": manifest.get("APP_REPO_BRANCH", ""), + "provider": repo_provider(repo_url) if repo_url else "", + "forgejo": { + "repo": repo, + "can_register": bool(repo and forgejo_token()), + "hook_id": manifest.get("APP_HOOK_ID") or None, + "hooks_url": f"{FORGEJO_URL}/{repo}/settings/hooks" if repo else None, + }, + "last": read_hook_last(name), + } + + +# Actions that only read state (or queue a background job) and may run +# alongside anything else. +LOCK_FREE_ACTIONS = {"validate-compose", "deploy", "restart", "repo-pull", "autodeploy"} class Handler(BaseHTTPRequestHandler): @@ -624,20 +1669,241 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def _file(self, code, filepath, content_type): + def _file(self, code, filepath, content_type, cache=False): try: with open(filepath, "rb") as fh: data = fh.read() self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) - # The UI is a single file that changes with every rebuild. - self.send_header("Cache-Control", "no-cache") + # The UI is a single file that changes with every rebuild; vendored + # libraries only change with the package. + self.send_header("Cache-Control", "max-age=86400" if cache else "no-cache") self.end_headers() self.wfile.write(data) except OSError: self._json(500, {"ok": False, "error": "failed to read file"}) + def _public_base(self): + """Public origin of the panel, for URLs handed to other services.""" + if PUBLIC_URL: + return PUBLIC_URL + host = (self.headers.get("X-Forwarded-Host") or self.headers.get("Host") or f"{BIND}:{PORT}").split(",")[0].strip() + proto = (self.headers.get("X-Forwarded-Proto") or "http").split(",")[0].strip() + return f"{proto}://{host}" + + # ── Server-sent events ── + + def _sse_start(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + self.close_connection = True + + def _sse(self, event, data): + self.wfile.write(f"event: {event}\ndata: {json.dumps(data)}\n\n".encode("utf-8")) + + def _client_gone(self, timeout=0): + """True once the browser closed the connection (it sends nothing else on an SSE stream).""" + try: + ready, _, _ = select.select([self.connection], [], [], timeout) + # Anything readable is either EOF or bytes nobody asked for (dropped). + return bool(ready) and not self.connection.recv(4096) + except OSError: + return True + + def _deployment_log(self, dep): + try: + with open(deploy_log_path(dep["app"], dep["id"]), "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + except OSError: + text = "" + limit = 2 * 1024 * 1024 + self._json(200, {"ok": True, "deployment": dep, "log": text[-limit:], "truncated": len(text) > limit}) + + def _stream_deployment(self, dep, query): + """Send the deployment log as it grows, then a final 'done' event.""" + path = deploy_log_path(dep["app"], dep["id"]) + try: + offset = max(0, int(query.get("offset", ["0"])[0])) + except ValueError: + offset = 0 + chunk_max = 256 * 1024 + self._sse_start() + last_status, last_write = None, time.time() + try: + while True: + # Check the status before reading: once it is final the log is complete. + cur = deploys.get(dep["id"]) + final = cur is None or cur["status"] in FINAL_STATES + if cur and cur["status"] != last_status: + last_status = cur["status"] + self._sse("status", {"deployment": cur}) + data = b"" + try: + with open(path, "rb") as fh: + fh.seek(offset) + data = fh.read(chunk_max) + except FileNotFoundError: + pass + if data and not final and len(data) < chunk_max: + data = data[:data.rfind(b"\n") + 1] # only complete lines while it runs + if data: + offset += len(data) + self._sse("log", {"text": data.decode("utf-8", "replace"), "offset": offset}) + last_write = time.time() + continue + if final: + self._sse("done", {"deployment": cur}) + return + if time.time() - last_write > 15: + self.wfile.write(b": ping\n\n") + last_write = time.time() + if self._client_gone(0.3): + return + except (BrokenPipeError, ConnectionResetError): + return + + def _stream_container_logs(self, name, tail, service): + """Follow `compose logs` and forward new lines as they arrive.""" + args = [PANELCTL, "logs", name, "--tail", tail, "--follow"] + (["--service", service] if service else []) + proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, + start_new_session=True) + fd = proc.stdout.fileno() + buf = b"" + try: + self._sse_start() + while True: + ready, _, _ = select.select([fd, self.connection], [], [], 15) + if not ready: + self.wfile.write(b": ping\n\n") + continue + if self.connection in ready and self._client_gone(): + return + if fd not in ready: + continue + chunk = os.read(fd, 65536) + if chunk: + buf += chunk + # Give a burst of output a moment to arrive, then send it as one event. + while len(buf) < 262144 and select.select([fd], [], [], 0.05)[0]: + more = os.read(fd, 65536) + if not more: + break + buf += more + *lines, buf = buf.split(b"\n") + if not chunk and buf: + lines, buf = lines + [buf], b"" + if lines: + self._sse("lines", {"lines": [clean_line(l.decode("utf-8", "replace")) for l in lines]}) + if not chunk: + self._sse("end", {"code": proc.wait(timeout=10)}) + return + except (BrokenPipeError, ConnectionResetError, subprocess.TimeoutExpired): + return + finally: + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except OSError: + pass + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.stdout.close() + + # ── Web terminal ── + + def _terminal(self, name, container): + if self.headers.get("Upgrade", "").lower() != "websocket" or not self.headers.get("Sec-WebSocket-Key"): + self._json(400, {"ok": False, "error": "expected a WebSocket upgrade"}) + return + # Browsers send Origin with WebSocket requests; refuse other sites' pages. + origin = self.headers.get("Origin") + host = (self.headers.get("X-Forwarded-Host") or self.headers.get("Host") or "").split(",")[0].strip() + if origin and urlparse(origin).netloc != host: + self._json(403, {"ok": False, "error": "cross-origin terminal request refused"}) + return + containers = [c["name"] for c in app_status(name).get("containers", [])] + if container not in containers: + self._json(404, {"ok": False, "error": f"container '{container}' is not part of '{name}'"}) + return + + accept = base64.b64encode(hashlib.sha1((self.headers["Sec-WebSocket-Key"].strip() + WS_GUID).encode()).digest()) + self.wfile.write(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n") + self.close_connection = True + ws = WebSocket(self.rfile, self.connection) + print(f"[panel-api] terminal opened: {name}/{container}") + + pid, fd = pty.fork() + if pid == 0: # child: become `panelctl exec` on the new terminal + try: + os.environ["TERM"] = "xterm-256color" + os.execv(PANELCTL, [PANELCTL, "exec", name, container]) + finally: + os._exit(127) + + def set_size(cols, rows): + try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + except OSError: + pass + + def pump(): + try: + while True: + data = os.read(fd, 65536) + if not data: + break + ws.send(0x2, data) + except (OSError, WebSocketClosed): + pass + ws.close(1000, "the shell exited") + try: + self.connection.shutdown(socket.SHUT_RDWR) # unblock the reader below + except OSError: + pass + + set_size(100, 30) + reader = threading.Thread(target=pump, daemon=True) + reader.start() + try: + while True: + opcode, payload = ws.recv() + if opcode == 0x8: + break + if opcode == 0x9: + ws.send(0xA, payload) + continue + if opcode not in (0x1, 0x2): + continue + try: + msg = json.loads(payload.decode("utf-8")) + except ValueError: + continue + if msg.get("type") == "input" and isinstance(msg.get("data"), str): + os.write(fd, msg["data"].encode("utf-8")) + elif msg.get("type") == "resize": + try: + set_size(max(10, min(int(msg["cols"]), 500)), max(4, min(int(msg["rows"]), 200))) + except (KeyError, TypeError, ValueError): + pass + except (WebSocketClosed, OSError): + pass + finally: + reap_child(pid) + try: + os.close(fd) + except OSError: + pass + ws.close() + reader.join(timeout=2) + print(f"[panel-api] terminal closed: {name}/{container}") + def _read_json(self): # Cached: do_POST may read the body before dispatching. if hasattr(self, "_payload"): @@ -663,10 +1929,29 @@ class Handler(BaseHTTPRequestHandler): parts = [p for p in path.split("/") if p] return path, parts, query + def _refuse_request(self, parts): + """Guard against requests that took the unauthenticated webhook route + in Caddy but address something else here. + + Caddy matches `/hooks/*` on the cleaned path (`/apps/x/remove/../../../hooks/x` + becomes `/hooks/x`) but forwards the original one, so dot segments are + refused outright, and whatever came through the hooks route (tagged with + X-Panel-Hook by the NixOS module) may only be a webhook delivery.""" + if any(unquote(p) in (".", "..") or "/" in unquote(p) for p in parts): + self._json(400, {"ok": False, "error": "invalid path"}) + return True + if self.headers.get("X-Panel-Hook") and not ( + self.command == "POST" and len(parts) == 2 and parts[0] == "hooks"): + self._json(403, {"ok": False, "error": "only webhook deliveries are allowed here"}) + return True + return False + # ── GET ── def do_GET(self): path, parts, query = self._parse_path() + if self._refuse_request(parts): + return if path == "/": index = os.path.join(FRONTEND_DIR, "index.html") @@ -680,6 +1965,83 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "service": "panel-api"}) return + # /vendor/ — third-party browser assets (xterm.js) bundled by the package + if len(parts) == 2 and parts[0] == "vendor": + fname = parts[1] + types = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8"} + ext = os.path.splitext(fname)[1] + fpath = os.path.join(FRONTEND_DIR, "vendor", fname) + if not re.fullmatch(r"[A-Za-z0-9._-]+", fname) or ext not in types or not os.path.isfile(fpath): + self._json(404, {"ok": False, "error": "not found"}) + return + self._file(200, fpath, types[ext], cache=True) + return + + # /deployments/[/log|/stream] + if parts and parts[0] == "deployments" and len(parts) in (2, 3): + dep = deploys.get(int(parts[1])) if parts[1].isdigit() else None + if dep is None: + self._json(404, {"ok": False, "error": "deployment not found"}) + return + if len(parts) == 2: + self._json(200, {"ok": True, "deployment": dep}) + elif parts[2] == "log": + self._deployment_log(dep) + elif parts[2] == "stream": + self._stream_deployment(dep, query) + else: + self._json(404, {"ok": False, "error": "not found"}) + return + + if len(parts) >= 3 and parts[0] == "apps" and ( + parts[2] in ("deployments", "services", "stats", "autodeploy", "terminal") + or parts[2:4] == ["logs", "stream"]): + name = parts[1] + if read_manifest(name) is None: + self._json(404, {"ok": False, "error": f"app '{name}' does not exist"}) + return + + # /apps//deployments?limit=N — deployment history, newest first + if parts[2] == "deployments" and len(parts) == 3: + try: + limit = max(1, min(int(query.get("limit", ["30"])[0]), DEPLOY_KEEP)) + except ValueError: + limit = 30 + self._json(200, {"ok": True, "name": name, "deployments": deploys.list(name, limit), + "queued": runner.queued(name)}) + return + + # /apps//logs/stream?tail=N&service=S — follow container logs (SSE) + if parts[2:4] == ["logs", "stream"] and len(parts) == 4: + tail = query.get("tail", ["300"])[0] + service = query.get("service", [""])[0] + if not tail.isdigit() or (service and not re.fullmatch(r"[A-Za-z0-9._-]+", service)): + self._json(400, {"ok": False, "error": "invalid tail or service"}) + return + self._stream_container_logs(name, tail, service) + return + + # /apps//services — compose services and the container ports they mention + if parts[2] == "services" and len(parts) == 3: + self._json(200, {"ok": True, "name": name, "services": app_services(name)}) + return + + # /apps//stats — CPU / memory now and over the last hour, per container + if parts[2] == "stats" and len(parts) == 3: + sampler.viewed() + self._json(200, {"ok": True, "name": name, **sampler.for_app(name)}) + return + + # /apps//autodeploy — webhook URL, secret and the last delivery + if parts[2] == "autodeploy" and len(parts) == 3: + self._json(200, autodeploy_info(name, self._public_base())) + return + + # /apps//terminal?container=C — WebSocket shell in a container + if parts[2] == "terminal" and len(parts) == 3: + self._terminal(name, query.get("container", [""])[0]) + return + # /integrations — Forgejo connection and the panel's SSH deploy key if path == "/integrations": token = forgejo_token() @@ -760,9 +2122,12 @@ class Handler(BaseHTTPRequestHandler): with ThreadPoolExecutor(max_workers=min(8, len(names))) as pool: statuses = dict(zip(names, pool.map(app_status, names))) busy = busy_snapshot() + latest = deploys.latest() for app in apps: app["status"] = statuses.get(app["name"], {"state": "unknown"}) app["busy"] = busy.get(app["name"]) + app["last_deployment"] = latest.get(app["name"]) + app["queued"] = runner.queued(app["name"]) self._json(200, {"ok": True, "time": int(time.time()), "apps": apps}) return @@ -1066,6 +2431,8 @@ class Handler(BaseHTTPRequestHandler): # ── PUT ── def do_PUT(self): path, parts, query = self._parse_path() + if self._refuse_request(parts): + return if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": name = parts[1] app, err = read_app_info(name) @@ -1111,6 +2478,8 @@ class Handler(BaseHTTPRequestHandler): # ── DELETE ── def do_DELETE(self): path, parts, query = self._parse_path() + if self._refuse_request(parts): + return if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": name = parts[1] @@ -1158,6 +2527,29 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): path, parts, query = self._parse_path() + if self._refuse_request(parts): + return + + # POST /hooks/ — push webhook (reached without Authelia, signed instead) + if len(parts) == 2 and parts[0] == "hooks": + self._webhook(parts[1], query) + return + + # POST /deployments//cancel | /deployments//redeploy + if len(parts) == 3 and parts[0] == "deployments" and parts[1].isdigit(): + self._deployment_action(int(parts[1]), parts[2]) + return + + # POST /compose/inspect {"content": "..."} — services and ports of a compose file + if path == "/compose/inspect": + try: + content = str(self._read_json().get("content", "")) + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + services = compose_service_ports(content) + self._json(200, {"ok": True, "services": services or [], "parsed": services is not None}) + return name, action = None, None if path == "/apps/init": @@ -1185,46 +2577,41 @@ class Handler(BaseHTTPRequestHandler): if path == "/apps/init": try: payload = self._read_json() - name = payload["name"] + name = str(payload["name"]) auth = str(payload.get("auth", True)).lower() source_type = payload.get("source_type", "default") - # Build routes string: "domain|upstream,domain|upstream,..." - routes_parts = [] + # Routes: [{domain, target | upstream, path}] if "routes" in payload and isinstance(payload["routes"], list): - for r in payload["routes"]: - d = r.get("domain", "").strip() - u = r.get("upstream", "").strip() - p = r.get("path", "").strip() - if d and u: - if p: - routes_parts.append(f"{d}|{u}|{p}") - else: - routes_parts.append(f"{d}|{u}") + raw_routes = [r for r in payload["routes"] if isinstance(r, dict) and str(r.get("domain", "")).strip()] elif "domain" in payload and "port" in payload: # Backward compat: single domain + port domain_str = payload.get("domain", "") if "domains" in payload and isinstance(payload["domains"], list): domain_str = ",".join(payload["domains"]) - port = str(payload["port"]) - for d in domain_str.split(","): - d = d.strip() - if d: - routes_parts.append(f"{d}|127.0.0.1:{port}") + raw_routes = [{"domain": d.strip(), "upstream": f"127.0.0.1:{payload['port']}"} + for d in domain_str.split(",") if d.strip()] else: self._json(400, {"ok": False, "error": "missing 'routes' array or 'domain'+'port' fields"}) return - - routes_str = ",".join(routes_parts) except Exception as exc: self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) return + if not is_safe_name(name): + self._json(400, {"ok": False, "error": f"invalid name '{name}' (use lowercase letters, digits and dashes)"}) + return if source_type == "github": # older clients source_type = "git" if source_type not in ["default", "raw", "git"]: self._json(400, {"ok": False, "error": "invalid source_type"}) return + try: + # The starter compose file has a single service called "app". + routes_str = resolve_routes(name, raw_routes, ["app"] if source_type == "default" else None) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return # Validate everything before creating anything. try: @@ -1311,6 +2698,13 @@ class Handler(BaseHTTPRequestHandler): commit = repo_commit(target_dir) summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned" + if source_type in ("raw", "git"): + route_error = finalize_route_services(name) + if route_error: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": route_error}) + return + if env_items or not env_inject: write_app_env(name, env_items) update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"}) @@ -1374,11 +2768,8 @@ class Handler(BaseHTTPRequestHandler): result = {"ok": True, "name": name, "count": len(items), "stdout": f"saved {len(items)} environment variable(s)"} if payload.get("deploy"): - deployed = run_panelctl(["deploy", name]) - deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]])) - deployed["count"] = len(items) - self._json(200 if deployed["ok"] else 400, deployed) - return + # Queued; it starts once this request releases the app. + result["deployment"] = runner.submit(name, "deploy", "manual", "Environment variables changed") self._json(200, result) return @@ -1455,25 +2846,59 @@ class Handler(BaseHTTPRequestHandler): if not isinstance(route_list, list) or not route_list: self._json(400, {"ok": False, "error": "routes must be a non-empty array"}) return - routes_parts = [] - for r in route_list: - d = r.get("domain", "").strip() - u = r.get("upstream", "").strip() - p = r.get("path", "").strip() - if not d or not u: - self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"}) - return - if p: - routes_parts.append(f"{d}|{u}|{p}") - else: - routes_parts.append(f"{d}|{u}") - routes_str = ",".join(routes_parts) + manifest = read_manifest(name) + if manifest is None: + self._json(404, {"ok": False, "error": f"app '{name}' does not exist"}) + return + + def published(routes): + return sorted((r.get("service", ""), r["port"], r["upstream"]) for r in routes if r.get("port")) + + before = published(manifest_routes(manifest)) + try: + routes_str = resolve_routes(name, route_list, compose_services(name)) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return result = run_panelctl(["set-routes", name, routes_str]) + after = manifest_routes(read_manifest(name) or {}) + result["routes"] = after + # Newly published ports only exist once the containers are recreated. + result["needs_deploy"] = result["ok"] and published(after) != before self._json(200 if result["ok"] else 400, result) return + # POST /apps//deploy | restart | repo-pull — queue a deployment. + # Returns at once with the deployment; {"wait": true} blocks until it + # finishes and answers like the old synchronous API. + if action in {"deploy", "restart", "repo-pull"}: + if read_manifest(name) is None: + self._json(404, {"ok": False, "error": f"app '{name}' does not exist"}) + return + try: + payload = self._read_json() or {} + except Exception: + payload = {} + if action == "repo-pull" and not (read_manifest(name) or {}).get("APP_REPO_URL"): + self._json(400, {"ok": False, "error": "app is not linked to a git repository"}) + return + kind, title = {"deploy": ("deploy", "Deploy"), "restart": ("restart", "Restart"), + "repo-pull": ("sync", "Sync from git")}[action] + dep = runner.submit(name, kind, "manual", str(payload.get("title") or title)[:200]) + if payload.get("wait"): + result = deployment_result(runner.wait(dep["id"])) + self._json(200 if result["ok"] else 400, result) + return + self._json(202, {"ok": True, "deployment": dep}) + return + + # POST /apps//autodeploy {"enabled", "register", "regenerate"} + if action == "autodeploy": + self._autodeploy(name) + return + # Simple panelctl pass-through actions - if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}: + if action in {"stop", "render-route", "volume-clear"}: if not is_safe_name(name): self._json(400, {"ok": False, "error": "invalid app name"}) return @@ -1481,84 +2906,6 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return - # POST /apps//repo-pull — sync the checkout to the remote branch and redeploy. - # The repository is the source of truth: fetch + hard reset, so local - # edits or force-pushes never leave the checkout stuck mid-merge. - if action == "repo-pull": - if not is_safe_name(name): - self._json(400, {"ok": False, "error": "invalid app name"}) - return - - app, err = read_app_info(name) - if err is not None or app is None: - self._json(404, {"ok": False, "error": "app not found"}) - return - repo_url = app.get("APP_REPO_URL", "").strip() - if not repo_url: - self._json(400, {"ok": False, "error": "app is not linked to a git repository"}) - return - - repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") - branch = app.get("APP_REPO_BRANCH", "").strip() - git_log = [] - before = None - - if os.path.isdir(os.path.join(repo_dir, ".git")): - before = repo_commit(repo_dir) - ref = branch or repo_current_branch(repo_dir) - if not ref: - self._json(400, {"ok": False, "error": "cannot determine which branch to sync"}) - return - fetched = run_git(["fetch", "origin", ref], cwd=repo_dir) - if not fetched["ok"]: - self._json(400, { - "ok": False, - "error": f"git fetch failed: {git_error(fetched['stderr'])}", - "stderr": fetched["stderr"], - }) - return - reset = run_git(["reset", "--hard", "FETCH_HEAD"], cwd=repo_dir, timeout=60) - if not reset["ok"]: - self._json(400, { - "ok": False, - "error": f"git reset failed: {git_error(reset['stderr'])}", - "stderr": reset["stderr"], - }) - return - git_log.append(reset["stdout"]) - else: - # No checkout yet (e.g. deleted by hand): clone it fresh. - if os.path.exists(repo_dir): - shutil.rmtree(repo_dir) - cloned = clone_repo(repo_url, branch, repo_dir) - if not cloned["ok"]: - self._json(400, { - "ok": False, - "error": f"git clone failed: {git_error(cloned['stderr'])}", - "stderr": cloned["stderr"], - }) - return - git_log.append("cloned repository") - - after = repo_commit(repo_dir) - compose_path = find_compose_file(repo_dir) - if not compose_path: - self._json(400, {"ok": False, "error": "compose file not found in repository root"}) - return - try: - update_manifest(name, {"APP_COMPOSE_FILE": compose_path}) - except (OSError, ValueError) as exc: - self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) - return - - result = run_panelctl(["deploy", name]) - result["stdout"] = "\n".join(filter(None, git_log + [result["stdout"]])) - result["before"] = before - result["after"] = after - result["changed"] = not before or not after or before["sha"] != after["sha"] - self._json(200 if result["ok"] else 400, result) - return - # POST /apps//remove if action == "remove": if not is_safe_name(name): @@ -1572,14 +2919,171 @@ class Handler(BaseHTTPRequestHandler): args = ["remove", name] if keep: args.append("--keep-volumes") + manifest = read_manifest(name) or {} + runner.cancel_app(name) result = run_panelctl(args) + if result["ok"]: + repo = forgejo_repo_path(manifest.get("APP_REPO_URL", "")) + if repo and manifest.get("APP_HOOK_ID"): + delete_forgejo_hook(repo, manifest["APP_HOOK_ID"]) + for path in (hook_secret_path(name), hook_last_path(name)): + try: + os.remove(path) + except FileNotFoundError: + pass + deploys.delete_app(name) self._json(200 if result["ok"] else 400, result) return self._json(404, {"ok": False, "error": "not found"}) + # ── Deployments, webhooks and auto deploy ── + + def _deployment_action(self, dep_id, action): + dep = deploys.get(dep_id) + if dep is None: + self._json(404, {"ok": False, "error": "deployment not found"}) + return + if action == "cancel": + if dep["status"] in FINAL_STATES or not runner.cancel(dep_id): + self._json(409, {"ok": False, "error": f"deployment #{dep_id} is not running"}) + return + self._json(200, {"ok": True, "deployment": deploys.get(dep_id)}) + return + if action != "redeploy": + self._json(404, {"ok": False, "error": "not found"}) + return + # Deploy what this deployment deployed: its commit for git apps, its saved + # compose file otherwise. + manifest = read_manifest(dep["app"]) + if manifest is None: + self._json(404, {"ok": False, "error": f"app '{dep['app']}' does not exist"}) + return + label = f"#{dep_id}" + if manifest.get("APP_REPO_URL") and dep.get("commit_sha"): + label = dep["commit_sha"][:7] + new = runner.submit(dep["app"], "sync", "rollback", f"Redeploy {label}", commit=dep["commit_sha"]) + elif dep.get("snapshot") and os.path.isfile(deploy_snapshot_path(dep["app"], dep_id)): + new = runner.submit(dep["app"], "deploy", "rollback", f"Redeploy compose file of #{dep_id}", snapshot_from=dep_id) + else: + self._json(400, {"ok": False, "error": f"deployment #{dep_id} has no commit or saved compose file to go back to"}) + return + self._json(202, {"ok": True, "deployment": new}) + + def _webhook(self, name, query): + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + length = -1 + if length < 0 or length > WEBHOOK_MAX_BODY: + self._json(413, {"ok": False, "error": "payload too large"}) + return + body = self.rfile.read(length) if length else b"" + manifest = read_manifest(name) + if manifest is None or manifest.get("APP_AUTODEPLOY") != "true": + self._json(404, {"ok": False, "error": "auto deploy is not enabled for this app"}) + return + if not verify_hook(read_hook_secret(name), body, self.headers, query.get("token", [""])[0]): + self._json(401, {"ok": False, "error": "invalid signature or token"}) + return + + event = (self.headers.get("X-Forgejo-Event") or self.headers.get("X-Gitea-Event") + or self.headers.get("X-GitHub-Event") or self.headers.get("X-Gitlab-Event") or "") + record = {"time": time.time(), "event": event or "manual"} + + def done(code, result, **extra): + record.update(result=result, **extra) + write_hook_last(name, record) + self._json(code, {"ok": code < 300, "result": result, **extra}) + + if event.lower() == "ping": + done(200, "pong") + return + try: + payload = json.loads(body.decode("utf-8")) if body.strip() else {} + except ValueError: + payload = {} + payload = payload if isinstance(payload, dict) else {} + if event and event.lower() not in ("push", "push hook"): + done(200, f"ignored {event} event") + return + + repo_url = manifest.get("APP_REPO_URL", "") + branch = manifest.get("APP_REPO_BRANCH", "") + ref = str(payload.get("ref") or "") + if repo_url and ref and branch and ref != f"refs/heads/{branch}": + done(200, f"ignored push to {ref.removeprefix('refs/heads/')} (deploying {branch})") + return + if payload.get("deleted"): + done(200, "ignored branch deletion") + return + + head = payload.get("head_commit") or (payload.get("commits") or [{}])[-1] or {} + message = str(head.get("message") or "").strip().splitlines() + sha = str(payload.get("after") or head.get("id") or "")[:7] + pusher = payload.get("pusher") or payload.get("sender") or {} + who = pusher.get("login") or pusher.get("username") or pusher.get("name") or "" + if repo_url: + title = f"Push to {branch or 'the branch'}" + if sha: + title += f" · {sha}" + if message: + title += f": {message[0]}" + dep = runner.submit(name, "sync", "webhook", title[:200]) + else: + dep = runner.submit(name, "deploy", "webhook", "Deploy (webhook)") + done(202, "deploying", deployment=dep["id"], commit=sha or None, pusher=who or None) + + def _autodeploy(self, name): + manifest = read_manifest(name) + if manifest is None: + self._json(404, {"ok": False, "error": f"app '{name}' does not exist"}) + return + try: + payload = self._read_json() or {} + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + enabled = payload.get("enabled", manifest.get("APP_AUTODEPLOY") == "true") is not False + register = payload.get("register", True) is not False + regenerate = bool(payload.get("regenerate")) + base = self._public_base() + repo = forgejo_repo_path(manifest.get("APP_REPO_URL", "")) + hook_id = manifest.get("APP_HOOK_ID", "") + warning = None + + if enabled: + secret = ensure_hook_secret(name, regenerate=regenerate) + values = {"APP_AUTODEPLOY": "true"} + if repo and register and forgejo_token() and (regenerate or not hook_id): + if hook_id: + delete_forgejo_hook(repo, hook_id) + hook_id = "" + try: + hook_id = str(register_forgejo_hook(repo, f"{base}/hooks/{name}", secret, + manifest.get("APP_REPO_BRANCH", "")) or "") + except ForgejoError as exc: + warning = f"Couldn't add the webhook to {repo} on Forgejo ({exc}). Add it by hand with the URL and secret below." + values["APP_HOOK_ID"] = hook_id + elif repo and register and not forgejo_token() and not hook_id: + warning = "Connect a Forgejo token in Settings to add the webhook automatically, or add it by hand." + update_manifest(name, values) + else: + if repo and hook_id: + delete_forgejo_hook(repo, hook_id) + update_manifest(name, {"APP_AUTODEPLOY": "false", "APP_HOOK_ID": ""}) + + info = autodeploy_info(name, base) + if warning: + info["warning"] = warning + self._json(200, info) + def main(): + global deploys, runner, sampler + deploys = DeployStore(DB_PATH) + runner = DeployRunner(deploys) + sampler = StatsSampler().start() server = ThreadingHTTPServer((BIND, PORT), Handler) server.daemon_threads = True print(f"panel-api listening on http://{BIND}:{PORT}") diff --git a/panelctl.sh b/panelctl.sh index a7b9043..72f2264 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -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 panelctl stop panelctl status - panelctl logs [--tail N] + panelctl logs [--tail N] [--follow] [--service S] + panelctl services + panelctl containers + panelctl stats + panelctl exec panelctl remove [--keep-volumes] panelctl backup panelctl list-backups @@ -73,7 +86,9 @@ Usage: panelctl list panelctl show -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" <"${stack_dir}/compose.yaml" <&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 [--tail N]" + [[ $# -ge 2 ]] || fail "usage: panelctl logs [--tail N] [--follow] [--service S]" cmd_logs "$2" "${@:3}" ;; + services) + [[ $# -eq 2 ]] || fail "usage: panelctl services " + 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 " + cmd_exec "$2" "$3" + ;; validate-compose) [[ $# -eq 2 ]] || fail "usage: panelctl validate-compose " cmd_validate_compose "$2"