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 4143768c45
commit 76a1e09913
6 changed files with 2742 additions and 1326 deletions

View file

@ -33,6 +33,7 @@
pkgs.zip
pkgs.unzip
pkgs.git
pkgs.util-linux # flock, used by panelctl to serialise routes file writes
];
serviceConfig = {

View file

@ -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/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
| GET | `/apps/<name>/backups` | List available backups |
| 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
@ -41,6 +49,70 @@ Default bind: `127.0.0.1:9911`
| POST | `/apps/<name>/backup` | Create volume backup (zip) |
| POST | `/apps/<name>/restore` | Restore from backup |
| 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

View file

@ -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/<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 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/<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
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/<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 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/<name>/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()

View file

@ -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() {