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"