From 76a1e0991323c46c19999e5d60bc48659e53eae9 Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 22:17:07 +0000 Subject: [PATCH 1/2] panel: redesign web UI and make syncing responsive Web UI (panel/frontend/index.html) rewritten: - Cards update in place from a single /status poll instead of being rebuilt on every action, so open tabs, unsaved compose/route edits, logs and the file browser position survive refreshes. Polling speeds up while an operation runs and pauses in background tabs; a header indicator shows when the panel last synced and detects an expired Authelia session. - New-app dialog (starter / compose / git), suggested port and domain, proper confirm dialogs (the old "OK = keep volumes" remove prompt is gone), toasts, an activity drawer with operation output, overflow menu, search, status filters, keyboard shortcuts, deep links, dark mode and mobile layout. - Tabs: overview (containers + routes), compose editor (dirty tracking, Ctrl+S), logs with follow, validated routes editor, file browser with drag-and-drop upload, backups, and a git source tab (deployed commit, check for updates, sync & deploy). API (panel/panel-api.py): - ThreadingHTTPServer so a long deploy no longer blocks every other request. - Per-app operation lock; concurrent writes to a busy app return 409. - GET /status: all apps, routes and container status in one request (statuses gathered in parallel); status reports running/partial/stopped. - Git sync is fetch + hard reset instead of pull-or-reclone, keeps the stored token, reports before/after commits; GET /apps//repo[?fetch=1]. - Any http(s) git host (e.g. Forgejo), default branch detection, git timeouts, no credential prompts, tokens redacted from errors, and manifest values validated before being written into the bash-sourced manifest. panelctl: - flock around routes.caddy rewrites (util-linux added to the service path). - deploy returns compose output so failures are visible in the UI. - inspect-volumes no longer fails for apps without named podman volumes, which broke the file browser. Docs: README/API.md updated; fixed outdated panelctl init examples. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf --- panel.nix | 1 + panel/API.md | 72 + panel/README.md | 52 +- panel/frontend/index.html | 3214 ++++++++++++++++++++++++------------- panel/panel-api.py | 697 +++++--- panel/panelctl.sh | 32 +- 6 files changed, 2742 insertions(+), 1326 deletions(-) diff --git a/panel.nix b/panel.nix index 68e0e96..166cfd5 100644 --- a/panel.nix +++ b/panel.nix @@ -33,6 +33,7 @@ pkgs.zip pkgs.unzip pkgs.git + pkgs.util-linux # flock, used by panelctl to serialise routes file writes ]; serviceConfig = { diff --git a/panel/API.md b/panel/API.md index 256cef6..87787a3 100644 --- a/panel/API.md +++ b/panel/API.md @@ -12,6 +12,7 @@ Default bind: `127.0.0.1:9911` |--------|------|-------------| | GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/health` | Health check | +| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) | ### Apps — Read @@ -25,6 +26,13 @@ Default bind: `127.0.0.1:9911` | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | +| GET | `/apps//repo` | Git source info (URL, branch, deployed commit, local changes) | +| GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | +| GET | `/apps//volumes` | Volumes the file browser can open | +| GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | +| GET | `/apps//volume/download?vol=&path=` | Download a file from a volume | +| PUT | `/apps//volume/files?vol=&path=` | Upload a file (raw body) | +| DELETE | `/apps//volume/files?vol=&path=` | Delete a file or folder | ### Apps — Write @@ -41,6 +49,70 @@ 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//volume-clear` | Stop the app and empty its default data folder | + +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). + +### Create app (git repository) + +```json +{ + "name": "blog", + "routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}], + "auth": true, + "source_type": "github", + "github_url": "https://git.srazka.com/reudy-net/blog.git", + "github_branch": "", + "github_pat": "" +} +``` + +Any http(s) git host works. An empty branch uses the repository's default branch. +The compose file must be at the repository root. + +### 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 +{ + "ok": true, + "time": 1790460650, + "apps": [ + { + "name": "whoami", + "routes": [{"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}], + "auth": true, + "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", + "repo_url": "", + "repo_branch": "", + "busy": null, + "status": { + "state": "running", + "running": true, + "running_count": 1, + "total": 1, + "containers": [{"name": "whoami-app-1", "state": "running", "status": "Up 3 minutes", "image": "docker.io/traefik/whoami:latest", "running": true}] + } + } + ] +} +``` + +`state` is one of `running`, `partial` (some containers down), `stopped` or `unknown`. ## Example payloads diff --git a/panel/README.md b/panel/README.md index 02ede16..cf6dc4f 100644 --- a/panel/README.md +++ b/panel/README.md @@ -22,14 +22,19 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp ## Quick workflow ```bash -# Create a new app (single domain) -panelctl init whoami whoami.srazka.com 18080 true +# Routes are "domain|upstream[|path]" entries, comma-separated. -# Create with multiple domains -panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true +# Create a new app (single route, protected by Authelia) +panelctl init whoami "whoami.srazka.com|127.0.0.1:18080" true + +# Create with multiple routes (different ports, optional path) +panelctl init myapp "app.srazka.com|127.0.0.1:18081,api.srazka.com|127.0.0.1:18082|/api/*" true # Create with wildcard domain (requires DNS challenge in Caddy) -panelctl init wild "*.srazka.com" 18082 false +panelctl init wild "*.srazka.com|127.0.0.1:18083" false + +# Change routes later (Caddy reloads automatically) +panelctl set-routes whoami "whoami.srazka.com|127.0.0.1:18080,who.srazka.com|127.0.0.1:18080" # Deploy (compose up + caddy reload) panelctl deploy whoami @@ -91,9 +96,34 @@ panelctl remove whoami ### Web UI features -- Create apps with multiple domains and wildcard support -- Live container status indicators (auto-refreshes) -- Deploy, restart, stop, remove from the UI -- Inline compose editor with save, validate, and save+deploy -- Log viewer with configurable tail length -- Volume backup management: create, list, download, restore +- Live status: one `/status` poll every few seconds (faster while something is + running, paused when the tab is hidden) updates cards in place, so open tabs, + unsaved edits and scroll positions are never lost. The header shows when the + panel last synced and warns when the Authelia session has expired. +- Per-app status (running / partial / stopped), container list, and a busy + indicator that is shared between browsers while an operation runs. +- New-app dialog: starter container, pasted compose file or git repository; + suggests the next free port and a domain based on the app name. +- Compose editor with unsaved-changes tracking, Ctrl+S, save & deploy, validate. +- Logs with follow mode, routes editor with validation, file browser with + drag-and-drop upload, backups with restore (and optional redeploy). +- Git source tab: deployed commit, "check for updates", and sync & deploy. +- Activity drawer with the output of every operation (e.g. why a deploy failed). +- Keyboard: `/` search, `N` new app, `Esc` closes menus. Deep links like + `#/whoami/logs` open an app on a specific tab. + +### Git-backed apps + +Apps created from a repository (GitHub, Forgejo/Gitea or any https git host) +are cloned to `stacks//repo`. **Sync** fetches the configured branch and +hard-resets the checkout to it before redeploying, so the repository is the +source of truth: compose edits made in the panel are discarded on the next sync +(the UI warns about this). An access token for a private repository is stored in +the clone's `.git/config`; use a read-only token. + +### Concurrency + +`panel-api` handles requests concurrently, so a long deploy never blocks status +or logs. Mutating operations are serialised per app — a second operation on a +busy app gets HTTP 409 — and `panelctl` takes a `flock` on the shared routes +file while rewriting it. diff --git a/panel/frontend/index.html b/panel/frontend/index.html index 154e071..a7dfc4c 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -1,1191 +1,2219 @@ - - - Panel - - - - + /* ── Menu ── */ + .menu { + position: fixed; z-index: 50; min-width: 190px; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 5px; + } + .menu-item { + display: flex; align-items: center; gap: 9px; width: 100%; + background: none; border: 0; border-radius: 6px; padding: 7px 10px; + color: var(--text); font: 13.5px var(--font); cursor: pointer; text-align: left; + } + .menu-item:hover:not(:disabled) { background: var(--surface-2); } + .menu-item:disabled { opacity: .45; cursor: default; } + .menu-item.danger { color: var(--danger); } + .menu-sep { height: 1px; background: var(--border); margin: 4px 2px; } + + /* ── Toasts ── */ + .toasts { position: fixed; right: 16px; bottom: 16px; z-index: 60; display: flex; flex-direction: column; gap: 8px; width: min(380px, calc(100vw - 32px)); } + .toast { + display: flex; gap: 10px; align-items: flex-start; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 11px 12px; + animation: toast-in .18s ease-out; + } + .toast.leaving { opacity: 0; transform: translateY(6px); transition: .18s; } + @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + .toast > .icon { margin-top: 2px; } + .toast-success > .icon { color: var(--ok); } + .toast-error > .icon { color: var(--danger); } + .toast-info > .icon { color: var(--accent); } + .toast-body { flex: 1; min-width: 0; } + .toast-title { font-weight: 600; font-size: 13.5px; } + .toast-detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } + .toast .link { font-size: 12.5px; margin-top: 4px; } + .toast-close { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; border-radius: 4px; } + .toast-close:hover { color: var(--text); background: var(--surface-2); } + + /* ── Activity drawer ── */ + .drawer { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 40; + width: min(460px, 100vw); + background: var(--surface); border-left: 1px solid var(--border-strong); + box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; + animation: slide-in .18s ease-out; + } + @keyframes slide-in { from { transform: translateX(24px); opacity: 0; } } + .drawer-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); } + .drawer-head h2 { font-size: 15px; flex: 1; } + .drawer-body { flex: 1; overflow: auto; padding: 8px; } + .act { border-radius: 8px; padding: 2px 0; } + .act + .act { border-top: 1px solid var(--border); } + .act summary { list-style: none; cursor: pointer; border-radius: 6px; } + .act summary::-webkit-details-marker { display: none; } + .act summary:hover { background: var(--surface-2); } + .act-head { display: flex; align-items: center; gap: 8px; padding: 8px; font-size: 13px; } + .act-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .act-ok { color: var(--ok); } + .act-error { color: var(--danger); } + .act pre { margin: 0 8px 8px; } + + /* ── Dialogs ── */ + dialog { + border: 1px solid var(--border-strong); border-radius: 14px; padding: 0; + background: var(--surface); color: var(--text); + box-shadow: var(--shadow-lg); + width: min(640px, calc(100vw - 24px)); max-height: calc(100vh - 32px); + } + dialog.sm { width: min(440px, calc(100vw - 24px)); } + dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } + dialog form { display: flex; flex-direction: column; max-height: calc(100vh - 34px); } + .dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 6px; } + .dialog-head h2, dialog.sm h2 { font-size: 17px; } + dialog.sm form { padding: 18px; } + dialog.sm .dialog-body { padding: 10px 0 4px; } + .dialog-body { padding: 10px 18px; overflow: auto; } + .dialog-body p + p { margin-top: 8px; } + .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; border-top: 1px solid var(--border); } + dialog.sm .dialog-actions { padding: 14px 0 0; border: 0; } + .segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 9px; padding: 3px; gap: 2px; background: var(--surface-2); flex-wrap: wrap; } + .segmented button { + border: 0; background: none; padding: 6px 12px; border-radius: 6px; + font: 500 13px var(--font); color: var(--muted); cursor: pointer; + } + .segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); } + + /* ── Responsive ── */ + @media (max-width: 760px) { + .hide-sm { display: none !important; } + .topbar-inner { flex-wrap: wrap; } + .search { order: 3; max-width: none; margin-left: 0; flex-basis: 100%; } + .search kbd, .shortcuts { display: none; } + .grid-2 { grid-template-columns: 1fr; } + /* Card header: name + actions on top, status pill underneath. */ + .app-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px 10px; padding: 12px; } + .app-head > .chev { grid-row: 1 / span 2; align-self: start; margin-top: 3px; } + .app-title { grid-column: 2; grid-row: 1; } + .app-actions { grid-column: 3; grid-row: 1; align-self: start; } + .app-head > .pill { grid-column: 2; grid-row: 2; justify-self: start; min-width: 0; } + .route-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px; } + .route-row .arrow { display: none; } + .route-row .route-path { grid-column: 1 / 3; } + .route-head { display: none; } + .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; } + } + + +
+
+
Panel
+ +
+ + + +
+
+
+ + +
- -
-
-

Containers Panel

-

Rootless Podman + Caddy routes from one place.

-
-
- -
-
- - -
Ready.
- - -
- -
-
-

Create App

- - - - -
-
- -
-

- Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT. Optional path: /_/* -

- -
- - -
- - - - - - - - -
- -
-
-
- - -
-
-
Loading apps...
-
-
-
+
+
+
/ search · N new app
+
+
+
+
+ + +
+ + + +
+
+

New app

+ +
+
+
+ + +

Lowercase letters, digits and dashes. Used for the compose project and data folder.

+
+ +
+ Source +
+ + + +
+

+
+ + + + + +
+ Routes +
+ +

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.

+
+ + + + + +
+
+ + +
+
+
+ + +
+

+
+ +
+ + +
+
+ + +
+
+
+ diff --git a/panel/panel-api.py b/panel/panel-api.py index ae01dfd..3a5d2cb 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -6,10 +6,12 @@ import os import re import shutil import subprocess -from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import urlparse, parse_qs -import urllib.request -import urllib.error +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs, quote PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") @@ -20,11 +22,195 @@ FRONTEND_DIR = os.environ.get( os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"), ) +COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"] +GIT_TIMEOUT = 300 +# Manifest values are written into a file that panelctl sources with bash, so +# they must not contain anything that is special inside double quotes. +REPO_URL_RE = re.compile(r"^https?://[^\s\"'`$\\]+$") +BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$") + def is_safe_name(name): return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None +# ── Per-app operation locks ── +# Requests are handled concurrently, so two mutating operations on the same app +# (e.g. a double-clicked deploy, or deploy + restore) must not overlap. + +_busy = {} +_busy_lock = threading.Lock() + + +class AppBusy(Exception): + def __init__(self, name, action): + super().__init__(f"another operation ({action}) is already running on '{name}'") + self.action = action + + +@contextmanager +def app_operation(name, action): + with _busy_lock: + if name in _busy: + raise AppBusy(name, _busy[name]) + _busy[name] = action + try: + yield + finally: + with _busy_lock: + _busy.pop(name, None) + + +def busy_snapshot(): + with _busy_lock: + return dict(_busy) + + +def redact_credentials(text, replacement="***@"): + """Hide user:token@ credentials embedded in URLs.""" + return re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@", r"\1" + replacement, text or "") + + +def last_line(text): + lines = [line.strip() for line in (text or "").splitlines() if line.strip()] + return lines[-1] if lines else "" + + +# ── Git helpers ── + +def run_git(args, cwd=None, timeout=GIT_TIMEOUT): + git_bin = shutil.which("git") + if not git_bin: + return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"} + cmd = [git_bin] + (["-C", cwd] if cwd else []) + args + # Never block on an interactive credential prompt. + env = dict(os.environ, GIT_TERMINAL_PROMPT="0") + try: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout) + except subprocess.TimeoutExpired: + return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"} + return { + "ok": proc.returncode == 0, + "stdout": redact_credentials(proc.stdout.strip()), + "stderr": redact_credentials(proc.stderr.strip()), + } + + +def clone_repo(url, branch, target_dir, token=""): + auth_url = url + if token: + auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1) + args = ["clone"] + if branch: + args += ["--branch", branch] + return run_git(args + ["--", auth_url, target_dir]) + + +def repo_commit(repo_dir, ref="HEAD"): + result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15) + if not result["ok"] or not result["stdout"]: + return None + sha, subject, author, ts = (result["stdout"].split("\x1f") + ["", "", "", ""])[:4] + return { + "sha": sha, + "short": sha[:7], + "subject": subject, + "author": author, + "time": int(ts) if ts.isdigit() else None, + } + + +def repo_current_branch(repo_dir): + result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir, timeout=15) + if result["ok"] and result["stdout"] and result["stdout"] != "HEAD": + return result["stdout"] + return "" + + +def find_compose_file(repo_dir): + for fname in COMPOSE_FILENAMES: + candidate = os.path.join(repo_dir, fname) + if os.path.isfile(candidate): + return candidate + return None + + +# ── Manifest helpers ── + +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}") + 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() + remaining = dict(values) + out = [] + for line in lines: + key = line.split("=", 1)[0].strip() + if key in remaining: + out.append(f'{key}="{remaining.pop(key)}"\n') + else: + out.append(line if line.endswith("\n") else line + "\n") + for key, value in remaining.items(): + out.append(f'{key}="{value}"\n') + with open(manifest_path, "w", encoding="utf-8") as fh: + fh.writelines(out) + + +def manifest_routes(env): + routes_raw = env.get("APP_ROUTES", "") + # Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM + if not routes_raw and "APP_DOMAIN" in env: + upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}") + domains = env.get("APP_DOMAINS", env["APP_DOMAIN"]) + routes_raw = ",".join(f"{d.strip()}|{upstream}" for d in domains.split(",") if d.strip()) + routes = [] + for entry in routes_raw.split(","): + entry = entry.strip() + if not entry: + continue + fields = entry.split("|", 2) + 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() + routes.append(route) + return routes + + +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") + try: + entries = sorted(os.listdir(apps_dir)) + except OSError: + return [] + apps = [] + for fname in entries: + if not fname.endswith(".env"): + continue + name = fname[:-4] + if not is_safe_name(name): + continue + try: + with open(os.path.join(apps_dir, fname), "r", encoding="utf-8") as fh: + env = parse_env_blob(fh.read()) + except OSError: + continue + apps.append({ + "name": name, + "routes": manifest_routes(env), + "auth": env.get("APP_AUTH_PROTECTED", "true") == "true", + "compose_file": env.get("APP_COMPOSE_FILE", ""), + "repo_url": redact_credentials(env.get("APP_REPO_URL", ""), ""), + "repo_branch": env.get("APP_REPO_BRANCH", ""), + }) + return apps + + def run_panelctl(args): proc = subprocess.run( [PANELCTL, *args], @@ -54,11 +240,12 @@ def parse_env_blob(blob): def get_app_volumes(name): result = run_panelctl(["inspect-volumes", name]) volumes = {} - if result["ok"]: - for line in result["stdout"].splitlines(): - if "|" in line: - vname, vpath = line.split("|", 1) - volumes[vname.strip()] = vpath.strip() + # Parse whatever was printed even on a non-zero exit, so one failing + # `podman volume ls` doesn't hide the app's default data folder. + for line in result["stdout"].splitlines(): + if "|" in line: + vname, vpath = line.split("|", 1) + volumes[vname.strip()] = vpath.strip() return volumes def read_app_info(name): @@ -83,36 +270,92 @@ def read_app_info(name): return app, None +def _decode_containers(stdout): + """`compose ps --format json` prints either a JSON array or one object per line, + sometimes mixed with other output. Returns a list of dicts, or None.""" + lines = stdout.splitlines() + for i, line in enumerate(lines): + if line.lstrip().startswith("["): + try: + data, _ = json.JSONDecoder().raw_decode("\n".join(lines[i:]).lstrip()) + except ValueError: + continue + if isinstance(data, list): + return [c for c in data if isinstance(c, dict)] + items = [] + for line in lines: + line = line.strip() + if not line.startswith("{"): + continue + try: + obj = json.loads(line) + except ValueError: + continue + if isinstance(obj, dict): + items.append(obj) + return items or None + + +def _container_name(c): + name = c.get("Name") or c.get("name") + if not name: + names = c.get("Names") + if isinstance(names, list) and names: + name = names[0] + elif isinstance(names, str): + name = names + return name or "?" + + def parse_status_output(stdout): - """Try to determine if any container is running from panelctl status output.""" - text = stdout.lower() - if not text or "no containers" in text: - return {"running": False, "raw": stdout} - # podman compose ps --format json returns JSON array - try: - containers = json.loads(stdout) - if isinstance(containers, list): - running = any( - c.get("State", "").lower() == "running" - or c.get("status", "").lower().startswith("up") - for c in containers - ) - return { - "running": running, - "containers": [ - { - "name": c.get("Name", c.get("name", "?")), - "state": c.get("State", c.get("status", "unknown")), - "image": c.get("Image", c.get("image", "")), - } - for c in containers - ], - } - except (json.JSONDecodeError, TypeError): - pass - # Fallback: check for "Up" or "running" in text - running = "up" in text or "running" in text - return {"running": running, "raw": stdout} + """Summarise panelctl status output as running / partial / stopped / unknown.""" + stdout = stdout or "" + containers = _decode_containers(stdout) + if containers is None: + text = stdout.lower() + if not text.strip() or "no containers" in text: + return {"state": "stopped", "running": False, "running_count": 0, "total": 0, "containers": []} + running = re.search(r"\b(up|running)\b", text) is not None + return { + "state": "running" if running else "unknown", + "running": running, + "running_count": None, + "total": None, + "containers": [], + "raw": stdout, + } + + parsed = [] + for c in containers: + state = str(c.get("State") or c.get("state") or "").lower() + status = str(c.get("Status") or c.get("status") or "") + is_running = state == "running" or status.lower().startswith("up") + parsed.append({ + "name": _container_name(c), + "state": state or ("running" if is_running else "unknown"), + "status": status, + "image": c.get("Image") or c.get("image") or "", + "running": is_running, + }) + running_count = sum(1 for c in parsed if c["running"]) + total = len(parsed) + if total and running_count == total: + state = "running" + elif running_count: + state = "partial" + else: + state = "stopped" + return { + "state": state, + "running": running_count > 0, + "running_count": running_count, + "total": total, + "containers": parsed, + } + + +def app_status(name): + return parse_status_output(run_panelctl(["status", name])["stdout"]) def parse_backups_output(stdout): @@ -136,6 +379,10 @@ def parse_backups_output(stdout): return backups +# Actions that only read state and may run alongside anything else. +LOCK_FREE_ACTIONS = {"validate-compose"} + + class Handler(BaseHTTPRequestHandler): def _html(self, code, body): payload = body.encode("utf-8") @@ -160,17 +407,24 @@ class Handler(BaseHTTPRequestHandler): 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") self.end_headers() self.wfile.write(data) except OSError: self._json(500, {"ok": False, "error": "failed to read file"}) def _read_json(self): + # Cached: do_POST may read the body before dispatching. + if hasattr(self, "_payload"): + return self._payload length = int(self.headers.get("Content-Length", "0")) if length == 0: - return {} - raw = self.rfile.read(length) - return json.loads(raw.decode("utf-8")) + self._payload = {} + else: + raw = self.rfile.read(length) + self._payload = json.loads(raw.decode("utf-8")) + return self._payload def log_message(self, fmt, *args): # Log to stdout (goes to systemd journal) @@ -202,6 +456,22 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "service": "panel-api"}) return + # /status — every app with routes, container status and running operation. + # This is what the UI polls, so it is one request regardless of app count. + if path == "/status": + apps = load_app_summaries() + names = [a["name"] for a in apps] + statuses = {} + if names: + with ThreadPoolExecutor(max_workers=min(8, len(names))) as pool: + statuses = dict(zip(names, pool.map(app_status, names))) + busy = busy_snapshot() + for app in apps: + app["status"] = statuses.get(app["name"], {"state": "unknown"}) + app["busy"] = busy.get(app["name"]) + self._json(200, {"ok": True, "time": int(time.time()), "apps": apps}) + return + if path == "/apps": result = run_panelctl(["list"]) if not result["ok"]: @@ -334,31 +604,46 @@ class Handler(BaseHTTPRequestHandler): if not result["ok"]: self._json(404, result) return - env = parse_env_blob(result["stdout"]) - routes_raw = env.get("APP_ROUTES", "") - # Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM - if not routes_raw and "APP_DOMAIN" in env: - upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}") - domains_str = env.get("APP_DOMAINS", env["APP_DOMAIN"]) - routes_parts = [] - for d in domains_str.split(","): - d = d.strip() - if d: - routes_parts.append(f"{d}|{upstream}") - routes_raw = ",".join(routes_parts) - routes = [] - for entry in routes_raw.split(","): - entry = entry.strip() - if not entry: - continue - parts = entry.split("|", 2) - route = {"domain": parts[0].strip(), "upstream": parts[1].strip()} - if len(parts) > 2: - route["path"] = parts[2].strip() - routes.append(route) + routes = manifest_routes(parse_env_blob(result["stdout"])) self._json(200, {"ok": True, "name": name, "routes": routes}) return + # /apps//repo[?fetch=1] — git source info; fetch=1 also checks the remote + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "repo": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + repo_url = app.get("APP_REPO_URL", "") + if not repo_url: + self._json(404, {"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", "") + info = { + "ok": True, + "name": name, + "url": redact_credentials(repo_url, ""), + "branch": branch, + "cloned": os.path.isdir(os.path.join(repo_dir, ".git")), + } + if info["cloned"]: + info["commit"] = repo_commit(repo_dir) + status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15) + info["dirty"] = bool(status["stdout"]) if status["ok"] else None + if query.get("fetch", ["0"])[0] == "1": + ref = branch or repo_current_branch(repo_dir) + fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir) + if not fetched["ok"]: + info["fetch_error"] = last_line(fetched["stderr"]) or "git fetch failed" + else: + info["remote"] = repo_commit(repo_dir, "FETCH_HEAD") + count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15) + info["behind"] = int(count["stdout"]) if count["ok"] and count["stdout"].isdigit() else None + self._json(200, info) + return + # /apps/ — show single app if len(parts) == 2 and parts[0] == "apps": name = parts[1] @@ -575,6 +860,28 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): path, parts, query = self._parse_path() + name, action = None, None + if path == "/apps/init": + try: + payload = self._read_json() + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + name = str(payload.get("name", "")) if isinstance(payload, dict) else "" + action = "init" + elif len(parts) >= 3 and parts[0] == "apps" and parts[2] not in LOCK_FREE_ACTIONS: + name, action = parts[1], parts[2] + + if not name: + self._handle_post(path, parts, query) + return + try: + with app_operation(name, action): + self._handle_post(path, parts, query) + except AppBusy as exc: + self._json(409, {"ok": False, "error": str(exc), "busy": exc.action}) + + def _handle_post(self, path, parts, query): # POST /apps/init if path == "/apps/init": try: @@ -618,6 +925,18 @@ class Handler(BaseHTTPRequestHandler): self._json(400, {"ok": False, "error": "invalid source_type"}) return + # Validate git parameters before creating anything. + if source_type == "github": + repo_url = str(payload.get("github_url", "")).strip() + branch = str(payload.get("github_branch", "")).strip() + token = str(payload.get("github_pat", "")).strip() + if not REPO_URL_RE.match(repo_url): + self._json(400, {"ok": False, "error": "repository URL must be a plain http(s) URL"}) + return + if branch and not BRANCH_RE.match(branch): + self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"}) + return + try: result = run_panelctl(["init", name, routes_str, auth]) if not result["ok"]: @@ -642,94 +961,45 @@ class Handler(BaseHTTPRequestHandler): return elif source_type == "github": - repo_url = payload.get("github_url", "").strip() - branch = payload.get("github_branch", "main").strip() - pat = payload.get("github_pat", "").strip() - - if not repo_url: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "github_url is required"}) - return - - # Validate URL and extract owner/repo - match = re.match(r'^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?$', repo_url) - if not match: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "invalid github_url format"}) - return - - owner, repo = match.groups() - - if pat: - # Check GitHub API access - api_url = f"https://api.github.com/repos/{owner}/{repo}" - req = urllib.request.Request(api_url, headers={"Authorization": f"Bearer {pat}"}) - try: - urllib.request.urlopen(req) - except urllib.error.URLError as e: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": f"github api check failed: {e.reason}"}) - return - - # Clone the repository - git_bin = shutil.which("git") - if not git_bin: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "git is not installed or not in PATH"}) - return - + # Any http(s) git host works (GitHub, Forgejo, ...). A token is + # embedded in the clone URL, so later syncs reuse it from .git/config. target_dir = os.path.join(app["APP_STACK_DIR"], "repo") if os.path.exists(target_dir): shutil.rmtree(target_dir) - auth_url = repo_url - if pat: - auth_url = auth_url.replace("://", f"://{pat}@") - - if not auth_url.endswith(".git"): - auth_url += ".git" - - clone_result = subprocess.run( - [git_bin, "clone", "--branch", branch, auth_url, target_dir], - capture_output=True, text=True - ) - - if clone_result.returncode != 0: + cloned = clone_repo(repo_url, branch, target_dir, token) + if not cloned["ok"]: run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + self._json(400, { + "ok": False, + "error": f"git clone failed: {last_line(cloned['stderr'])}", + "stderr": cloned["stderr"], + }) return + branch = branch or repo_current_branch(target_dir) or "main" - # Find compose file - compose_path = None - for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]: - candidate = os.path.join(target_dir, fname) - if os.path.isfile(candidate): - compose_path = candidate - break - + compose_path = find_compose_file(target_dir) if not compose_path: run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "could not find compose file in repository root"}) + self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"}) return - - # Update manifest - manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") + try: - with open(manifest_path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - with open(manifest_path, "w", encoding="utf-8") as fh: - for line in lines: - if line.startswith("APP_COMPOSE_FILE="): - fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') - else: - fh.write(line) - fh.write(f'APP_REPO_URL="{repo_url}"\n') - fh.write(f'APP_REPO_BRANCH="{branch}"\n') - except OSError as exc: + update_manifest(name, { + "APP_COMPOSE_FILE": compose_path, + "APP_REPO_URL": redact_credentials(repo_url, ""), + "APP_REPO_BRANCH": branch, + }) + except (OSError, ValueError) as exc: run_panelctl(["remove", name]) self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) return + commit = repo_commit(target_dir) + summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned" + self._json(200, {"ok": True, "code": 0, "stdout": summary}) + return + self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"}) except Exception as exc: run_panelctl(["remove", name]) @@ -839,96 +1109,82 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return - # POST /apps//repo-pull — re-clone/pull repo and redeploy + # 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: {last_line(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: {last_line(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: {last_line(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: - 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 + 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 - repo_url = app.get("APP_REPO_URL", "").strip() - branch = app.get("APP_REPO_BRANCH", "main").strip() - - if not repo_url: - self._json(400, {"ok": False, "error": "app has no APP_REPO_URL"}) - return - - git_bin = shutil.which("git") - if not git_bin: - self._json(400, {"ok": False, "error": "git is not installed"}) - return - - target_dir = os.path.join(app["APP_STACK_DIR"], "repo") - pat = "" - - auth_url = repo_url - if pat: - auth_url = auth_url.replace("://", f"://{pat}@") - if not auth_url.endswith(".git"): - auth_url += ".git" - - if os.path.exists(target_dir): - # Already cloned — try git pull - pull_result = subprocess.run( - [git_bin, "-C", target_dir, "pull", "origin", branch], - capture_output=True, text=True - ) - if pull_result.returncode != 0: - # Fall back to re-clone - shutil.rmtree(target_dir) - clone_result = subprocess.run( - [git_bin, "clone", "--branch", branch, auth_url, target_dir], - capture_output=True, text=True - ) - if clone_result.returncode != 0: - self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) - return - else: - clone_result = subprocess.run( - [git_bin, "clone", "--branch", branch, auth_url, target_dir], - capture_output=True, text=True - ) - if clone_result.returncode != 0: - self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) - return - - # Find compose file - compose_path = None - for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]: - candidate = os.path.join(target_dir, fname) - if os.path.isfile(candidate): - compose_path = candidate - break - - if not compose_path: - self._json(400, {"ok": False, "error": "compose file not found in repository"}) - return - - # Update manifest compose path - manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") - try: - with open(manifest_path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - with open(manifest_path, "w", encoding="utf-8") as fh: - for line in lines: - if line.startswith("APP_COMPOSE_FILE="): - fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') - else: - fh.write(line) - except OSError as exc: - self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) - return - - # Redeploy - result = run_panelctl(["deploy", name]) - self._json(200 if result["ok"] else 400, result) - except Exception as exc: - self._json(500, {"ok": False, "error": f"repo-pull failed: {exc}"}) + 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 @@ -952,7 +1208,8 @@ class Handler(BaseHTTPRequestHandler): def main(): - server = HTTPServer((BIND, PORT), Handler) + server = ThreadingHTTPServer((BIND, PORT), Handler) + server.daemon_threads = True print(f"panel-api listening on http://{BIND}:{PORT}") print(f"frontend dir: {FRONTEND_DIR}") server.serve_forever() diff --git a/panel/panelctl.sh b/panel/panelctl.sh index 4490418..afcb8ab 100644 --- a/panel/panelctl.sh +++ b/panel/panelctl.sh @@ -149,6 +149,19 @@ app_route_file() { echo "${ROUTES_DIR}/routes.caddy" } +# The routes file is shared by all apps and rewritten read-modify-write, so +# concurrent panelctl runs (the API handles requests in parallel) must take turns. +routes_lock() { + exec 9>"${ROUTES_DIR}/.routes.lock" + if command -v flock >/dev/null 2>&1; then + flock -w 30 9 || fail "timed out waiting for the routes file lock" + fi +} + +routes_unlock() { + exec 9>&- +} + load_app() { local name="$1" local manifest @@ -337,6 +350,8 @@ cmd_render_route() { local route_file route_file="$(app_route_file)" + routes_lock + # Strip any existing block for this app from the aggregate file. local tmp tmp="$(mktemp)" @@ -376,6 +391,8 @@ cmd_render_route() { } >>"${tmp}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock log info "rendered route ${route_file}" } @@ -388,10 +405,17 @@ cmd_deploy() { cmd_render_route "${name}" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then + # Capture compose output so callers (the web UI) can show why a deploy failed, + # and still forward it to the journal. + local output + if ! output="$(run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1)"; then + printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true + printf '%s\n' "${output}" >&2 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}'" } @@ -479,10 +503,13 @@ cmd_remove() { local route_file route_file="$(app_route_file)" if [[ -f "${route_file}" ]]; then + routes_lock local tmp tmp="$(mktemp)" sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock fi rm -f "$(app_manifest "${name}")" @@ -671,7 +698,8 @@ cmd_inspect_volumes() { 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 - fi | sort -u | grep -v '^$' + # grep exits 1 when there are no named volumes; that is not an error. + fi | sort -u | grep -v '^$' || true } cmd_list() { -- 2.51.2 From c1ff6c81766e15d70d56bc513f124fb9924fef20 Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 22:52:09 +0000 Subject: [PATCH 2/2] panel: Forgejo integration, ssh deploy key and per-app environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo: - panel.nix passes the local Forgejo's public, API and ssh URLs (derived from forgejo.nix) to panel-api. - Settings dialog: connect a Forgejo access token (verified against /api/v1/user, stored 0600 in state/panel/forgejo-token). - New-app dialog gets a Forgejo repository picker with search and a branch dropdown; private repos are cloned over https with the stored token, or over ssh with the deploy key when no token is connected. The app name and domain are filled in from the repository name. - Commit and compare links in the Source tab point at Forgejo; cards show the provider ("Forgejo · main"). Git over ssh: - ssh:// and git@host:owner/repo URLs are accepted; the panel generates an ed25519 deploy key in state/panel/ssh and uses it for clone/fetch (BatchMode, accept-new host keys). openssh added to the service path. - Credential redaction only applies to http(s) URLs, so ssh usernames are kept; git errors now report the meaningful line instead of git's advice. Environment variables: - Stored per app in state/env/.env (0600), outside the repo and stack. - panelctl passes them to every compose command via env(1), so ${VAR} interpolation works; by default deploy/restart also generate a compose override listing the keys under every service's environment (values are read from compose's environment, never quoted into YAML). - Environment tab (and a section in the new-app dialog) with .env paste import, hidden values, validation of names (reserved podman/compose vars rejected), hints for ${VAR}s the compose file uses but aren't set, and Save / Save & deploy. Removing an app deletes its variables. The API still accepts the old source_type "github" / github_* fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf --- panel.nix | 12 +- panel/API.md | 30 +- panel/README.md | 40 +- panel/frontend/index.html | 748 +++++++++++++++++++++++++++++++++++--- panel/panel-api.py | 426 ++++++++++++++++++++-- panel/panelctl.sh | 114 +++++- 6 files changed, 1269 insertions(+), 101 deletions(-) diff --git a/panel.nix b/panel.nix index 166cfd5..c104b27 100644 --- a/panel.nix +++ b/panel.nix @@ -1,5 +1,8 @@ -{ config, pkgs, ... }: +{ config, lib, pkgs, ... }: +let + forgejoServer = config.services.forgejo.settings.server; +in { environment.systemPackages = [ (pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh)) @@ -34,6 +37,7 @@ pkgs.unzip pkgs.git pkgs.util-linux # flock, used by panelctl to serialise routes file writes + pkgs.openssh # cloning repositories over ssh with the panel's deploy key ]; serviceConfig = { @@ -52,6 +56,12 @@ PANEL_BASE_DIR = "/var/lib/containers"; PANELCTL_PATH = "/run/current-system/sw/bin/panelctl"; PANEL_FRONTEND_DIR = "${./panel/frontend}"; + + # Forgejo integration (repo picker, private clones, commit links). + # The API is reached on localhost; clones use the public URLs. + PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL; + PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}"; + PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}"; }; }; diff --git a/panel/API.md b/panel/API.md index 87787a3..298faea 100644 --- a/panel/API.md +++ b/panel/API.md @@ -13,6 +13,10 @@ Default bind: `127.0.0.1:9911` | GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/health` | Health check | | GET | `/status` | All apps with routes, container status and running operation (what the UI polls) | +| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key | +| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects | +| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) | +| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository | ### Apps — Read @@ -26,7 +30,8 @@ Default bind: `127.0.0.1:9911` | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | -| GET | `/apps//repo` | Git source info (URL, branch, deployed commit, local changes) | +| GET | `/apps//env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` | +| GET | `/apps//repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) | | GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | | GET | `/apps//volumes` | Volumes the file browser can open | | GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | @@ -50,28 +55,37 @@ Default bind: `127.0.0.1:9911` | 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//volume-clear` | Stop the app and empty its default data folder | 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). -### Create app (git repository) +### Create app (git repository, with environment variables) ```json { "name": "blog", "routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}], "auth": true, - "source_type": "github", - "github_url": "https://git.srazka.com/reudy-net/blog.git", - "github_branch": "", - "github_pat": "" + "source_type": "git", + "repo_url": "https://git.srazka.com/reudy-net/blog.git", + "repo_branch": "", + "use_forgejo_token": true, + "env": [{"key": "DATABASE_URL", "value": "postgres://..."}], + "env_inject": true } ``` -Any http(s) git host works. An empty branch uses the repository's default branch. -The compose file must be at the repository root. +- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or + `git@host:owner/repo.git`. ssh URLs use the panel's deploy key. +- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the + token stored in Settings (only for URLs on the configured Forgejo host). +- An empty branch uses the repository's default branch. The compose file must + be at the repository root. +- The older `source_type: "github"` with `github_url` / `github_branch` / + `github_pat` is still accepted. ### Sync response (`repo-pull`) diff --git a/panel/README.md b/panel/README.md index cf6dc4f..5f9cc49 100644 --- a/panel/README.md +++ b/panel/README.md @@ -114,13 +114,49 @@ panelctl remove whoami ### Git-backed apps -Apps created from a repository (GitHub, Forgejo/Gitea or any https git host) -are cloned to `stacks//repo`. **Sync** fetches the configured branch and +Apps created from a repository (Forgejo, GitHub or any git host, over https or +ssh) are cloned to `stacks//repo`. + +**Forgejo.** `panel.nix` points the panel at the local Forgejo +(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken +from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo +access token (read access to repositories and user). With it, the new-app +dialog lists your repositories and branches, and private ones are cloned over +https with the token. Without it, public repositories are listed and private +ones are cloned over ssh with the deploy key. Commit and compare links point at +Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600). + +**SSH / deploy key.** The panel generates an ed25519 key pair in +`state/panel/ssh/` the first time it is needed. Its public half is shown in +Settings (and next to ssh URLs); add it as a read-only deploy key to a +repository — or to your Forgejo account for access to all repositories — to +clone `ssh://git@git.srazka.com:14922/owner/repo.git` style URLs. **Sync** fetches the configured branch and hard-resets the checkout to it before redeploying, so the repository is the source of truth: compose edits made in the panel are discarded on the next sync (the UI warns about this). An access token for a private repository is stored in the clone's `.git/config`; use a read-only token. +### Environment variables + +Each app can have environment variables (the **Environment** tab, or when +creating the app; `.env` text can be pasted in). They are stored in +`state/env/.env` as `KEY=VALUE` lines (mode 0600) — outside the repository +and stack directory, so git syncs never touch them — and `panelctl` passes them +to every compose command: + +- They are always available for `${VAR}` interpolation in the compose file. + The UI points out variables the compose file uses without a default that + aren't set. +- With **Pass to every container** (the default), `deploy`/`restart` also + generate `stacks//.panel-env.yaml`, a compose override that lists the + keys under every service's `environment:`. Compose reads the values from its + own environment, so they are never quoted into YAML, and they take + precedence over values set in the compose file. + +Values must be single-line. Names that would change how podman/compose run +(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected. +Changes apply on the next deploy. Backups do not include variables. + ### Concurrency `panel-api` handles requests concurrently, so a long deploy never blocks status diff --git a/panel/frontend/index.html b/panel/frontend/index.html index a7dfc4c..298ffb1 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -450,6 +450,45 @@ } .segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); } + /* ── Repo picker ── */ + .picker-list { + margin-top: 6px; max-height: 232px; overflow: auto; + border: 1px solid var(--border); border-radius: 8px; + } + .picker-item { + display: flex; align-items: center; gap: 10px; width: 100%; + background: none; border: 0; border-top: 1px solid var(--border); + padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit; + } + .picker-item:first-child { border-top: 0; } + .picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; } + .picker-item.selected { background: var(--accent-soft); } + .picker-item .icon { color: var(--muted); } + .picker-name { font-weight: 600; font-size: 13.5px; } + .picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .picker-main { flex: 1; min-width: 0; } + + /* ── Settings ── */ + .settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); } + .settings-section h3 { font-size: 14px; margin-bottom: 4px; } + .keybox { + margin-top: 10px; display: flex; gap: 8px; align-items: flex-start; + background: var(--surface-2); border-radius: 8px; padding: 10px 12px; + font: 12px/1.5 var(--mono); word-break: break-all; + } + .keybox code { flex: 1; background: none; padding: 0; } + + /* ── Environment editor ── */ + .env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; } + .env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); } + .env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; } + .env-details summary { cursor: pointer; list-style: none; } + .env-details summary::-webkit-details-marker { display: none; } + .env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; } + .env-details[open] summary::before { transform: rotate(90deg); } + .missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + .chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); } + /* ── Responsive ── */ @media (max-width: 760px) { .hide-sm { display: none !important; } @@ -467,6 +506,10 @@ .route-row .arrow { display: none; } .route-row .route-path { grid-column: 1 / 3; } .route-head { display: none; } + .env-row { grid-template-columns: minmax(0, 1fr) 28px; } + .env-row > :nth-child(2) { grid-column: 1; } + .env-row > :nth-child(3) { grid-column: 2; grid-row: 1; } + .env-head { display: none; } .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; } @@ -485,6 +528,7 @@
+ @@ -538,7 +582,7 @@
- +

@@ -549,24 +593,56 @@