From 2d3b30d078b0695764915bb33572c482d461cd0d Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 22:17:07 +0000 Subject: [PATCH] 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 --- API.md | 72 + README.md | 52 +- frontend/index.html | 3214 ++++++++++++++++++++++++++++--------------- panel-api.py | 697 +++++++--- panelctl.sh | 32 +- 5 files changed, 2741 insertions(+), 1326 deletions(-) diff --git a/API.md b/API.md index 256cef6..87787a3 100644 --- a/API.md +++ b/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/README.md b/README.md index 02ede16..cf6dc4f 100644 --- a/README.md +++ b/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/frontend/index.html b/frontend/index.html index 154e071..a7dfc4c 100644 --- a/frontend/index.html +++ b/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-api.py b/panel-api.py index ae01dfd..3a5d2cb 100644 --- a/panel-api.py +++ b/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/panelctl.sh b/panelctl.sh index 4490418..afcb8ab 100644 --- a/panelctl.sh +++ b/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() {