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/<name>/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
agent 2026-09-26 22:17:07 +00:00
parent 85e29b8734
commit 2d3b30d078
5 changed files with 2741 additions and 1326 deletions

72
API.md
View file

@ -12,6 +12,7 @@ Default bind: `127.0.0.1:9911`
|--------|------|-------------| |--------|------|-------------|
| GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/` | Web UI (served from `frontend/index.html`) |
| GET | `/health` | Health check | | GET | `/health` | Health check |
| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) |
### Apps — Read ### Apps — Read
@ -25,6 +26,13 @@ Default bind: `127.0.0.1:9911`
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
| GET | `/apps/<name>/backups` | List available backups | | GET | `/apps/<name>/backups` | List available backups |
| GET | `/apps/<name>/backups/<file>` | Download backup zip | | GET | `/apps/<name>/backups/<file>` | Download backup zip |
| GET | `/apps/<name>/repo` | Git source info (URL, branch, deployed commit, local changes) |
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
| GET | `/apps/<name>/volume/download?vol=&path=` | Download a file from a volume |
| PUT | `/apps/<name>/volume/files?vol=&path=` | Upload a file (raw body) |
| DELETE | `/apps/<name>/volume/files?vol=&path=` | Delete a file or folder |
### Apps — Write ### Apps — Write
@ -41,6 +49,70 @@ Default bind: `127.0.0.1:9911`
| POST | `/apps/<name>/backup` | Create volume backup (zip) | | POST | `/apps/<name>/backup` | Create volume backup (zip) |
| POST | `/apps/<name>/restore` | Restore from backup | | POST | `/apps/<name>/restore` | Restore from backup |
| POST | `/apps/<name>/remove` | Remove app | | POST | `/apps/<name>/remove` | Remove app |
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy |
| POST | `/apps/<name>/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 ## Example payloads

View file

@ -22,14 +22,19 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp
## Quick workflow ## Quick workflow
```bash ```bash
# Create a new app (single domain) # Routes are "domain|upstream[|path]" entries, comma-separated.
panelctl init whoami whoami.srazka.com 18080 true
# Create with multiple domains # Create a new app (single route, protected by Authelia)
panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true 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) # 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) # Deploy (compose up + caddy reload)
panelctl deploy whoami panelctl deploy whoami
@ -91,9 +96,34 @@ panelctl remove whoami
### Web UI features ### Web UI features
- Create apps with multiple domains and wildcard support - Live status: one `/status` poll every few seconds (faster while something is
- Live container status indicators (auto-refreshes) running, paused when the tab is hidden) updates cards in place, so open tabs,
- Deploy, restart, stop, remove from the UI unsaved edits and scroll positions are never lost. The header shows when the
- Inline compose editor with save, validate, and save+deploy panel last synced and warns when the Authelia session has expired.
- Log viewer with configurable tail length - Per-app status (running / partial / stopped), container list, and a busy
- Volume backup management: create, list, download, restore 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/<app>/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.

File diff suppressed because it is too large Load diff

View file

@ -6,10 +6,12 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer import threading
from urllib.parse import urlparse, parse_qs import time
import urllib.request from concurrent.futures import ThreadPoolExecutor
import urllib.error 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") PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") 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"), 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): def is_safe_name(name):
return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None 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): def run_panelctl(args):
proc = subprocess.run( proc = subprocess.run(
[PANELCTL, *args], [PANELCTL, *args],
@ -54,11 +240,12 @@ def parse_env_blob(blob):
def get_app_volumes(name): def get_app_volumes(name):
result = run_panelctl(["inspect-volumes", name]) result = run_panelctl(["inspect-volumes", name])
volumes = {} volumes = {}
if result["ok"]: # Parse whatever was printed even on a non-zero exit, so one failing
for line in result["stdout"].splitlines(): # `podman volume ls` doesn't hide the app's default data folder.
if "|" in line: for line in result["stdout"].splitlines():
vname, vpath = line.split("|", 1) if "|" in line:
volumes[vname.strip()] = vpath.strip() vname, vpath = line.split("|", 1)
volumes[vname.strip()] = vpath.strip()
return volumes return volumes
def read_app_info(name): def read_app_info(name):
@ -83,36 +270,92 @@ def read_app_info(name):
return app, None 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): def parse_status_output(stdout):
"""Try to determine if any container is running from panelctl status output.""" """Summarise panelctl status output as running / partial / stopped / unknown."""
text = stdout.lower() stdout = stdout or ""
if not text or "no containers" in text: containers = _decode_containers(stdout)
return {"running": False, "raw": stdout} if containers is None:
# podman compose ps --format json returns JSON array text = stdout.lower()
try: if not text.strip() or "no containers" in text:
containers = json.loads(stdout) return {"state": "stopped", "running": False, "running_count": 0, "total": 0, "containers": []}
if isinstance(containers, list): running = re.search(r"\b(up|running)\b", text) is not None
running = any( return {
c.get("State", "").lower() == "running" "state": "running" if running else "unknown",
or c.get("status", "").lower().startswith("up") "running": running,
for c in containers "running_count": None,
) "total": None,
return { "containers": [],
"running": running, "raw": stdout,
"containers": [ }
{
"name": c.get("Name", c.get("name", "?")), parsed = []
"state": c.get("State", c.get("status", "unknown")), for c in containers:
"image": c.get("Image", c.get("image", "")), state = str(c.get("State") or c.get("state") or "").lower()
} status = str(c.get("Status") or c.get("status") or "")
for c in containers is_running = state == "running" or status.lower().startswith("up")
], parsed.append({
} "name": _container_name(c),
except (json.JSONDecodeError, TypeError): "state": state or ("running" if is_running else "unknown"),
pass "status": status,
# Fallback: check for "Up" or "running" in text "image": c.get("Image") or c.get("image") or "",
running = "up" in text or "running" in text "running": is_running,
return {"running": running, "raw": stdout} })
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): def parse_backups_output(stdout):
@ -136,6 +379,10 @@ def parse_backups_output(stdout):
return backups return backups
# Actions that only read state and may run alongside anything else.
LOCK_FREE_ACTIONS = {"validate-compose"}
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _html(self, code, body): def _html(self, code, body):
payload = body.encode("utf-8") payload = body.encode("utf-8")
@ -160,17 +407,24 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data))) 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.end_headers()
self.wfile.write(data) self.wfile.write(data)
except OSError: except OSError:
self._json(500, {"ok": False, "error": "failed to read file"}) self._json(500, {"ok": False, "error": "failed to read file"})
def _read_json(self): 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")) length = int(self.headers.get("Content-Length", "0"))
if length == 0: if length == 0:
return {} self._payload = {}
raw = self.rfile.read(length) else:
return json.loads(raw.decode("utf-8")) raw = self.rfile.read(length)
self._payload = json.loads(raw.decode("utf-8"))
return self._payload
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
# Log to stdout (goes to systemd journal) # Log to stdout (goes to systemd journal)
@ -202,6 +456,22 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "service": "panel-api"}) self._json(200, {"ok": True, "service": "panel-api"})
return 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": if path == "/apps":
result = run_panelctl(["list"]) result = run_panelctl(["list"])
if not result["ok"]: if not result["ok"]:
@ -334,31 +604,46 @@ class Handler(BaseHTTPRequestHandler):
if not result["ok"]: if not result["ok"]:
self._json(404, result) self._json(404, result)
return return
env = parse_env_blob(result["stdout"]) routes = manifest_routes(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)
self._json(200, {"ok": True, "name": name, "routes": routes}) self._json(200, {"ok": True, "name": name, "routes": routes})
return return
# /apps/<name>/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/<name> — show single app # /apps/<name> — show single app
if len(parts) == 2 and parts[0] == "apps": if len(parts) == 2 and parts[0] == "apps":
name = parts[1] name = parts[1]
@ -575,6 +860,28 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
path, parts, query = self._parse_path() 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 # POST /apps/init
if path == "/apps/init": if path == "/apps/init":
try: try:
@ -618,6 +925,18 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"ok": False, "error": "invalid source_type"}) self._json(400, {"ok": False, "error": "invalid source_type"})
return 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: try:
result = run_panelctl(["init", name, routes_str, auth]) result = run_panelctl(["init", name, routes_str, auth])
if not result["ok"]: if not result["ok"]:
@ -642,94 +961,45 @@ class Handler(BaseHTTPRequestHandler):
return return
elif source_type == "github": elif source_type == "github":
repo_url = payload.get("github_url", "").strip() # Any http(s) git host works (GitHub, Forgejo, ...). A token is
branch = payload.get("github_branch", "main").strip() # embedded in the clone URL, so later syncs reuse it from .git/config.
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
target_dir = os.path.join(app["APP_STACK_DIR"], "repo") target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
if os.path.exists(target_dir): if os.path.exists(target_dir):
shutil.rmtree(target_dir) shutil.rmtree(target_dir)
auth_url = repo_url cloned = clone_repo(repo_url, branch, target_dir, token)
if pat: if not cloned["ok"]:
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:
run_panelctl(["remove", name]) 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 return
branch = branch or repo_current_branch(target_dir) or "main"
# Find compose file compose_path = find_compose_file(target_dir)
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: if not compose_path:
run_panelctl(["remove", name]) 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 return
# Update manifest
manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env")
try: try:
with open(manifest_path, "r", encoding="utf-8") as fh: update_manifest(name, {
lines = fh.readlines() "APP_COMPOSE_FILE": compose_path,
with open(manifest_path, "w", encoding="utf-8") as fh: "APP_REPO_URL": redact_credentials(repo_url, ""),
for line in lines: "APP_REPO_BRANCH": branch,
if line.startswith("APP_COMPOSE_FILE="): })
fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') except (OSError, ValueError) as exc:
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:
run_panelctl(["remove", name]) run_panelctl(["remove", name])
self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"})
return 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"}) self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
except Exception as exc: except Exception as exc:
run_panelctl(["remove", name]) run_panelctl(["remove", name])
@ -839,96 +1109,82 @@ class Handler(BaseHTTPRequestHandler):
self._json(200 if result["ok"] else 400, result) self._json(200 if result["ok"] else 400, result)
return return
# POST /apps/<name>/repo-pull — re-clone/pull repo and redeploy # POST /apps/<name>/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 action == "repo-pull":
if not is_safe_name(name): if not is_safe_name(name):
self._json(400, {"ok": False, "error": "invalid app name"}) self._json(400, {"ok": False, "error": "invalid app name"})
return 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: try:
app, err = read_app_info(name) update_manifest(name, {"APP_COMPOSE_FILE": compose_path})
if err is not None or app is None: except (OSError, ValueError) as exc:
self._json(404, {"ok": False, "error": "app not found"}) self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"})
return return
repo_url = app.get("APP_REPO_URL", "").strip() result = run_panelctl(["deploy", name])
branch = app.get("APP_REPO_BRANCH", "main").strip() result["stdout"] = "\n".join(filter(None, git_log + [result["stdout"]]))
result["before"] = before
if not repo_url: result["after"] = after
self._json(400, {"ok": False, "error": "app has no APP_REPO_URL"}) result["changed"] = not before or not after or before["sha"] != after["sha"]
return self._json(200 if result["ok"] else 400, result)
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}"})
return return
# POST /apps/<name>/remove # POST /apps/<name>/remove
@ -952,7 +1208,8 @@ class Handler(BaseHTTPRequestHandler):
def main(): 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"panel-api listening on http://{BIND}:{PORT}")
print(f"frontend dir: {FRONTEND_DIR}") print(f"frontend dir: {FRONTEND_DIR}")
server.serve_forever() server.serve_forever()

View file

@ -149,6 +149,19 @@ app_route_file() {
echo "${ROUTES_DIR}/routes.caddy" 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() { load_app() {
local name="$1" local name="$1"
local manifest local manifest
@ -337,6 +350,8 @@ cmd_render_route() {
local route_file local route_file
route_file="$(app_route_file)" route_file="$(app_route_file)"
routes_lock
# Strip any existing block for this app from the aggregate file. # Strip any existing block for this app from the aggregate file.
local tmp local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
@ -376,6 +391,8 @@ cmd_render_route() {
} >>"${tmp}" } >>"${tmp}"
install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}"
rm -f "${tmp}"
routes_unlock
log info "rendered route ${route_file}" log info "rendered route ${route_file}"
} }
@ -388,10 +405,17 @@ cmd_deploy() {
cmd_render_route "${name}" 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}'" log err "Deployment failed for app '${name}'"
fail "compose up failed" fail "compose up failed"
fi 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}'" log info "Successfully deployed app '${name}'"
} }
@ -479,10 +503,13 @@ cmd_remove() {
local route_file local route_file
route_file="$(app_route_file)" route_file="$(app_route_file)"
if [[ -f "${route_file}" ]]; then if [[ -f "${route_file}" ]]; then
routes_lock
local tmp local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}"
rm -f "${tmp}"
routes_unlock
fi fi
rm -f "$(app_manifest "${name}")" rm -f "$(app_manifest "${name}")"
@ -671,7 +698,8 @@ cmd_inspect_volumes() {
if [[ -n "${podman_bin}" ]]; then 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=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 "${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() { cmd_list() {