panel/API.md
agent 2d3b30d078 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
2026-09-26 22:17:07 +00:00

7 KiB

panel-api

HTTP API wrapper around panelctl with a web UI.

Default bind: 127.0.0.1:9911

Endpoints

Health & UI

Method Path Description
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

Method Path Description
GET /apps List all apps
GET /apps/<name> Show single app manifest
GET /apps/<name>/routes Get parsed route entries
GET /apps/<name>/status Container status (running/stopped)
GET /apps/<name>/compose Read compose.yaml content
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

Method Path Description
POST /apps/init Create a new app
POST /apps/<name>/routes Update routes (hot — Caddy reloads automatically)
POST /apps/<name>/deploy Deploy (compose up + caddy reload)
POST /apps/<name>/restart Restart (compose down + up)
POST /apps/<name>/stop Stop (compose down)
POST /apps/<name>/render-route Re-render Caddy route
POST /apps/<name>/compose Save compose.yaml content
POST /apps/<name>/validate-compose Validate compose file
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)

{
  "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)

{
  "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)

{
  "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

Create app (single route)

{
  "name": "whoami",
  "routes": [
    {"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}
  ],
  "auth": true
}

Create app (multiple routes, different ports)

{
  "name": "myapp",
  "routes": [
    {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
    {"domain": "api.app.srazka.com", "upstream": "127.0.0.1:18081"}
  ],
  "auth": true
}

Create app (multiple routes, different ports, with paths)

{
  "name": "pocketbase",
  "routes": [
    {"domain": "pb.srazka.com", "upstream": "127.0.0.1:8090", "path": "/_/*"}
  ],
  "auth": true
}

The path field is optional. When present, it generates a Caddy reverse_proxy /_/* 127.0.0.1:8090 rule, letting you route requests to a specific path prefix within a domain.

Create app (wildcard domain)

{
  "name": "wildcard",
  "routes": [
    {"domain": "*.srazka.com", "upstream": "127.0.0.1:18082"}
  ],
  "auth": false
}

Note: Wildcard domains require DNS challenge configuration in Caddy.

Update routes (hot)

{
  "routes": [
    {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
    {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"},
    {"domain": "pb.srazka.com", "upstream": "127.0.0.1:8090", "path": "/_/*"}
  ]
}

The optional path field generates a Caddy reverse_proxy <path> <upstream> rule for sub-path routing.

Caddy reloads automatically via the systemd path watcher. Containers stay running.

Save compose

{
  "content": "services:\n  app:\n    image: nginx:latest\n    ports:\n      - '127.0.0.1:18080:80'\n"
}

Remove and keep volumes

{
  "keepVolumes": true
}

Restore from backup

{
  "file": "whoami-20260101-120000.zip"
}

Routes response

{
  "ok": true,
  "name": "myapp",
  "routes": [
    {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
    {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081", "path": "/api/*"}
  ]
}

The path field is only present when a route has a path configured.

Response format

All JSON responses include an ok boolean:

{
  "ok": true,
  "apps": [...]
}

Error responses:

{
  "ok": false,
  "error": "description",
  "stderr": "panelctl error output"
}

Status response

{
  "ok": true,
  "name": "whoami",
  "running": true,
  "containers": [
    {
      "name": "whoami-app-1",
      "state": "running",
      "image": "docker.io/traefik/whoami:latest"
    }
  ]
}

Local test

curl -s http://127.0.0.1:9911/health | jq .
curl -s http://127.0.0.1:9911/apps | jq .
curl -s http://127.0.0.1:9911/apps/whoami/status | jq .
curl -s http://127.0.0.1:9911/apps/whoami/logs?tail=50 | jq .
curl -s http://127.0.0.1:9911/apps/whoami/backups | jq .