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:
parent
85e29b8734
commit
2d3b30d078
5 changed files with 2741 additions and 1326 deletions
72
API.md
72
API.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
52
README.md
52
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
3214
frontend/index.html
3214
frontend/index.html
File diff suppressed because it is too large
Load diff
697
panel-api.py
697
panel-api.py
|
|
@ -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()
|
||||||
|
|
|
||||||
32
panelctl.sh
32
panelctl.sh
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue