panel: redesign web UI and make syncing responsive

Web UI (panel/frontend/index.html) rewritten:
- Cards update in place from a single /status poll instead of being rebuilt
  on every action, so open tabs, unsaved compose/route edits, logs and the
  file browser position survive refreshes. Polling speeds up while an
  operation runs and pauses in background tabs; a header indicator shows
  when the panel last synced and detects an expired Authelia session.
- New-app dialog (starter / compose / git), suggested port and domain,
  proper confirm dialogs (the old "OK = keep volumes" remove prompt is gone),
  toasts, an activity drawer with operation output, overflow menu, search,
  status filters, keyboard shortcuts, deep links, dark mode and mobile layout.
- Tabs: overview (containers + routes), compose editor (dirty tracking,
  Ctrl+S), logs with follow, validated routes editor, file browser with
  drag-and-drop upload, backups, and a git source tab (deployed commit,
  check for updates, sync & deploy).

API (panel/panel-api.py):
- ThreadingHTTPServer so a long deploy no longer blocks every other request.
- Per-app operation lock; concurrent writes to a busy app return 409.
- GET /status: all apps, routes and container status in one request
  (statuses gathered in parallel); status reports running/partial/stopped.
- Git sync is fetch + hard reset instead of pull-or-reclone, keeps the stored
  token, reports before/after commits; GET /apps/<name>/repo[?fetch=1].
- Any http(s) git host (e.g. Forgejo), default branch detection, git
  timeouts, no credential prompts, tokens redacted from errors, and manifest
  values validated before being written into the bash-sourced manifest.

panelctl:
- flock around routes.caddy rewrites (util-linux added to the service path).
- deploy returns compose output so failures are visible in the UI.
- inspect-volumes no longer fails for apps without named podman volumes,
  which broke the file browser.

Docs: README/API.md updated; fixed outdated panelctl init examples.

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

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