From 4be4ae4144b2c4ebbeb47c4337ed7cd46944fd33 Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 29 Apr 2026 18:47:11 +0200 Subject: [PATCH 01/10] feat: add repo-pull action to re-clone/pull repository and redeploy app Co-authored-by: Copilot --- frontend/index.html | 8 ++++ panel-api.py | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/frontend/index.html b/frontend/index.html index af3490b..42dd781 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -390,6 +390,7 @@ const api = { deploy: (n) => api.request(`/apps/${n}/deploy`, "POST"), restart: (n) => api.request(`/apps/${n}/restart`, "POST"), stop: (n) => api.request(`/apps/${n}/stop`, "POST"), + repoPull: (n) => api.request(`/apps/${n}/repo-pull`, "POST"), remove: (n, keep) => api.request(`/apps/${n}/remove`, "POST", { keepVolumes: keep }), saveCompose: (n, c) => api.request(`/apps/${n}/compose`, "POST", { content: c }), validateCompose: (n) => api.request(`/apps/${n}/validate-compose`, "POST"), @@ -534,6 +535,7 @@ function renderAppCard(app) { + @@ -699,6 +701,12 @@ async function handleAction(action, name, btnElement) { setStatus(`Stopped ${name}.`); await loadApps(); break; + case "repo-pull": + setStatus(`Pulling repo for ${name}...`); + await api.repoPull(name); + setStatus(`Repo pulled for ${name}.`); + await loadApps(); + break; case "remove": { const keep = confirm("Keep volumes? OK = keep, Cancel = delete everything."); setStatus(`Removing ${name}...`); diff --git a/panel-api.py b/panel-api.py index e320f88..0aa7847 100644 --- a/panel-api.py +++ b/panel-api.py @@ -738,6 +738,98 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return + # POST /apps//repo-pull — re-clone/pull repo and redeploy + if action == "repo-pull": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + 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 + + 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}"}) + return + # POST /apps//remove if action == "remove": if not is_safe_name(name): From f17324658144e07f738297820c90d5bb85cf561c Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 29 Apr 2026 18:53:02 +0200 Subject: [PATCH 02/10] feat: add repo_url to app data and update frontend to conditionally display Git Pull button Co-authored-by: Copilot --- frontend/index.html | 2 +- panel-api.py | 1 + panelctl.sh | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 42dd781..8e75917 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -535,7 +535,7 @@ function renderAppCard(app) { - + ${app.repo_url ? `` : ""} diff --git a/panel-api.py b/panel-api.py index 0aa7847..8297462 100644 --- a/panel-api.py +++ b/panel-api.py @@ -220,6 +220,7 @@ class Handler(BaseHTTPRequestHandler): "domains": fields[1], "upstream": fields[2], "auth": fields[3].replace("auth=", ""), + "repo_url": fields[4] if len(fields) >= 5 else "", }) self._json(200, {"ok": True, "apps": apps}) return diff --git a/panelctl.sh b/panelctl.sh index cda8969..43d246b 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -601,7 +601,8 @@ cmd_list() { # shellcheck disable=SC1090 source "${mf}" local domains="${APP_DOMAINS:-${APP_DOMAIN}}" - echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}" + local repo_info="${APP_REPO_URL:-}" + echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED} ${repo_info}" done if [[ "${found}" -eq 0 ]]; then From 40503ef5b7acb8545d3a6b2eb3751751ec76fb68 Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 29 Apr 2026 18:56:26 +0200 Subject: [PATCH 03/10] fix: reset leftover variables in cmd_list function Co-authored-by: Copilot --- panelctl.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/panelctl.sh b/panelctl.sh index 43d246b..b36ab07 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -599,6 +599,8 @@ cmd_list() { [[ -e "${mf}" ]] || continue found=1 # shellcheck disable=SC1090 + source /dev/null # reset any leftover variables + unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true source "${mf}" local domains="${APP_DOMAINS:-${APP_DOMAIN}}" local repo_info="${APP_REPO_URL:-}" From 7399cf83f9bf133b2e15a9b91077d853bb2c3e4d Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 29 Apr 2026 19:36:02 +0200 Subject: [PATCH 04/10] fix: add --build flag to run_compose for deployment and restart commands Co-authored-by: Copilot --- panelctl.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/panelctl.sh b/panelctl.sh index b36ab07..66fcd40 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -345,7 +345,7 @@ cmd_deploy() { cmd_render_route "${name}" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then log err "Deployment failed for app '${name}'" fail "compose up failed" fi @@ -362,7 +362,7 @@ cmd_restart() { run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --remove-orphans 2>&1; then + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1; then fail "compose up failed during restart" fi From 7fd2531501e20276d354f7c9f62847cf51cc5237 Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 20 May 2026 00:21:25 +0200 Subject: [PATCH 05/10] added support for multiple routes multiple ports --- API.md | 59 +++++++---- frontend/index.html | 234 ++++++++++++++++++++++++++++++-------------- panel-api.py | 112 +++++++++++++++++++-- panelctl.sh | 191 ++++++++++++++++++++++++++---------- 4 files changed, 445 insertions(+), 151 deletions(-) diff --git a/API.md b/API.md index 16f9ddb..792450d 100644 --- a/API.md +++ b/API.md @@ -19,6 +19,7 @@ Default bind: `127.0.0.1:9911` |--------|------|-------------| | GET | `/apps` | List all apps | | GET | `/apps/` | Show single app manifest | +| GET | `/apps//routes` | Get parsed route entries | | GET | `/apps//status` | Container status (running/stopped) | | GET | `/apps//compose` | Read compose.yaml content | | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | @@ -30,6 +31,7 @@ Default bind: `127.0.0.1:9911` | Method | Path | Description | |--------|------|-------------| | POST | `/apps/init` | Create a new app | +| POST | `/apps//routes` | Update routes (hot — Caddy reloads automatically) | | POST | `/apps//deploy` | Deploy (compose up + caddy reload) | | POST | `/apps//restart` | Restart (compose down + up) | | POST | `/apps//stop` | Stop (compose down) | @@ -42,35 +44,27 @@ Default bind: `127.0.0.1:9911` ## Example payloads -### Create app (single domain) +### Create app (single route) ```json { "name": "whoami", - "domain": "whoami.srazka.com", - "port": 18080, + "routes": [ + {"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"} + ], "auth": true } ``` -### Create app (multiple domains) +### Create app (multiple routes, different ports) ```json { "name": "myapp", - "domain": "app.srazka.com,www.app.srazka.com", - "port": 18081, - "auth": true -} -``` - -Or using the `domains` array format: - -```json -{ - "name": "myapp", - "domains": ["app.srazka.com", "www.app.srazka.com"], - "port": 18081, + "routes": [ + {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"}, + {"domain": "api.app.srazka.com", "upstream": "127.0.0.1:18081"} + ], "auth": true } ``` @@ -80,14 +74,28 @@ Or using the `domains` array format: ```json { "name": "wildcard", - "domain": "*.srazka.com", - "port": 18082, + "routes": [ + {"domain": "*.srazka.com", "upstream": "127.0.0.1:18082"} + ], "auth": false } ``` Note: Wildcard domains require DNS challenge configuration in Caddy. +### Update routes (hot) + +```json +{ + "routes": [ + {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"}, + {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"} + ] +} +``` + +Caddy reloads automatically via the systemd path watcher. Containers stay running. + ### Save compose ```json @@ -112,6 +120,19 @@ Note: Wildcard domains require DNS challenge configuration in Caddy. } ``` +## Routes response + +```json +{ + "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"} + ] +} +``` + ## Response format All JSON responses include an `ok` boolean: diff --git a/frontend/index.html b/frontend/index.html index 8e75917..93b2e8d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -149,6 +149,19 @@ .domain-input-row { display: flex; gap: 6px; margin-top: 6px; } .domain-input-row input { flex: 1; } + /* ── Route table ── */ + .route-row { + display: flex; gap: 6px; align-items: center; + margin-top: 6px; + } + .route-row input { + flex: 1; + padding: 6px 8px; + font-size: .85rem; + } + .route-row input:first-child { flex: 3; } + .route-row button { flex-shrink: 0; } + /* ── App list ── */ .app-list { display: flex; flex-direction: column; gap: 12px; } @@ -294,28 +307,21 @@ - -
-
- - + +
+
+

- Supports wildcards: *.example.com + Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT

-
-
- - -
-
- - -
+
+ +
@@ -382,6 +388,8 @@ const api = { }, getApps: () => api.request("/apps"), getApp: (n) => api.request(`/apps/${n}`), + getRoutes: (n) => api.request(`/apps/${n}/routes`), + setRoutes: (n, r) => api.request(`/apps/${n}/routes`, "POST", { routes: r }), getStatus: (n) => api.request(`/apps/${n}/status`), getLogs: (n, tail=100)=> api.request(`/apps/${n}/logs?tail=${tail}`), getCompose: (n) => api.request(`/apps/${n}/compose`), @@ -427,33 +435,33 @@ function setStatus(msg, isError = false) { statusEl.className = "status-bar" + (isError ? " err" : msg !== "Ready." ? " ok" : ""); } -// ─── Domain tag input ─── -const domainTags = []; -const domainTagsEl = document.getElementById("domainTags"); -const domainInput = document.getElementById("domainInput"); +// ─── Route table input ─── +let createRoutes = [{ domain: "", upstream: "" }]; -function renderDomainTags() { - domainTagsEl.innerHTML = ""; - domainTags.forEach((d, i) => { - const tag = document.createElement("span"); - tag.className = "domain-tag"; - tag.innerHTML = `${escHtml(d)} `; - tag.querySelector("button").onclick = () => { domainTags.splice(i, 1); renderDomainTags(); }; - domainTagsEl.appendChild(tag); +function renderRouteTable() { + const table = document.getElementById("routeTable"); + table.innerHTML = ""; + createRoutes.forEach((r, i) => { + const row = document.createElement("div"); + row.className = "route-row"; + row.innerHTML = ` + + → + + ${createRoutes.length > 1 ? `` : ""} + `; + row.querySelector(".route-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; }; + row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = e.target.value; }; + const delBtn = row.querySelector("[data-ridx]"); + if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); }; + table.appendChild(row); }); } -document.getElementById("addDomainBtn").onclick = () => { - const v = domainInput.value.trim(); - if (v && !domainTags.includes(v)) { - domainTags.push(v); - domainInput.value = ""; - renderDomainTags(); - } +document.getElementById("addRouteBtn").onclick = () => { + createRoutes.push({ domain: "", upstream: "" }); + renderRouteTable(); }; -domainInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); } -}); const sourceSelect = document.getElementById("createSource"); const sourceRaw = document.getElementById("sourceRaw"); @@ -466,10 +474,7 @@ sourceSelect.addEventListener("change", () => { document.getElementById("createBtn").onclick = async () => { const name = document.getElementById("name").value.trim(); - const port = Number(document.getElementById("port").value); const auth = document.getElementById("auth").value === "true"; - const domains = [...domainTags]; - const source_type = sourceSelect.value; const compose_content = document.getElementById("createCompose").value; const github_url = document.getElementById("createGithubUrl").value.trim(); @@ -477,22 +482,32 @@ document.getElementById("createBtn").onclick = async () => { const github_pat = document.getElementById("createGithubPat").value.trim(); if (!name) { setStatus("Name is required.", true); return; } - if (!domains.length) { setStatus("At least one domain is required.", true); return; } - if (!port || port < 1024 || port > 65535) { setStatus("Port must be 1024-65535.", true); return; } + + // Validate routes + const validRoutes = createRoutes.filter(r => r.domain.trim() && r.upstream.trim()); + if (!validRoutes.length) { setStatus("At least one route with domain and upstream is required.", true); return; } + for (const r of validRoutes) { + const port = r.upstream.split(":").pop(); + const pnum = Number(port); + if (!port || isNaN(pnum) || pnum < 1024 || pnum > 65535) { + setStatus(`Upstream port must be 1024-65535 in "${r.upstream}"`, true); + return; + } + } if (source_type === "raw" && !compose_content.trim()) { setStatus("Compose YAML is required for raw source.", true); return; } if (source_type === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; } try { setStatus(`Creating ${name}...`); - await api.init({ name, domain: domains.join(","), port, auth, source_type, compose_content, github_url, github_branch, github_pat }); + await api.init({ name, routes: validRoutes.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })), auth, source_type, compose_content, github_url, github_branch, github_pat }); setStatus(`Created ${name}.`); document.getElementById("name").value = ""; document.getElementById("createCompose").value = ""; document.getElementById("createGithubUrl").value = ""; document.getElementById("createGithubPat").value = ""; - domainTags.length = 0; - renderDomainTags(); + createRoutes = [{ domain: "", upstream: "" }]; + renderRouteTable(); await loadApps(); } catch (err) { setStatus(`Create failed: ${err.message}`, true); @@ -509,17 +524,23 @@ function escHtml(s) { return d.innerHTML; } -function formatDomains(domainsStr) { - return domainsStr.split(",").map(d => d.trim()).filter(Boolean); -} - // ─── App card rendering ─── function renderAppCard(app) { const card = document.createElement("div"); card.className = "app-card fade-in"; card.dataset.name = app.name; - const domains = formatDomains(app.domains || app.domain); + // Parse first_route for display + let firstDomain = app.first_route || app.domain || ""; + let firstUpstream = app.upstream || ""; + if (app.first_route && app.first_route.includes("|")) { + const parts = app.first_route.split("|"); + firstDomain = parts[0]; + firstUpstream = parts[1]; + } + const routeCount = parseInt(app.route_count || "1", 10); + const extraLabel = routeCount > 1 ? ` +${routeCount - 1} more route${routeCount > 2 ? "s" : ""}` : ""; + const isExpanded = expandedApp === app.name; card.innerHTML = ` @@ -528,7 +549,7 @@ function renderAppCard(app) {
${escHtml(app.name)}
-
${domains.map(d => escHtml(d)).join(", ")} → ${escHtml(app.upstream)} ${app.auth === "true" ? "🔒" : ""}
+
${escHtml(firstDomain)} → ${escHtml(firstUpstream)}${escHtml(extraLabel)} ${app.auth === "true" ? "🔒" : ""}
@@ -591,9 +612,17 @@ function renderAppCard(app) {
- + +
+
+
+
+ + +
+

Saving updates the Caddy route file (hot reload). Edit the compose file separately if new ports need exposing.

+
-
Click "Load App Variables" to view routing details & directories.
`; @@ -658,6 +687,7 @@ function attachCardListeners() { if (tabName === "logs") loadLogs(app); if (tabName === "backups") loadBackups(app); if (tabName === "volumes") initVolumesTab(app); + if (tabName === "routing") loadRouteEditor(app); }; }); @@ -781,23 +811,13 @@ async function handleAction(action, name, btnElement) { uploadInput.click(); break; case "fetch-routing": - setStatus(`Fetching routing details for ${name}...`); - const appRes = await api.getApp(name); - if (appRes.app) { - const info = appRes.app; - document.getElementById(`routing-${name}`).innerHTML = ` -Primary Domain: ${escHtml(info.APP_DOMAIN || "-")} -All Domains: ${escHtml(info.APP_DOMAINS || "-")} -Upstream Target: ${escHtml(info.APP_UPSTREAM || "-")} -Protected: ${escHtml(info.APP_AUTH_PROTECTED || "-")} -Port: ${escHtml(info.APP_PORT || "-")} - -Volumes Directory: ${escHtml(info.APP_VOLUME_DIR || "-")} -Stack Directory: ${escHtml(info.APP_STACK_DIR || "-")} -Caddy Route Block: ${escHtml(info.APP_ROUTE_FILE || "-")} - `.trim(); - } - setStatus(`Loaded routing for ${name}.`); + await loadRouteEditor(name); + break; + case "save-routes": + await saveRouteEditor(name); + break; + case "add-route-edit": + await addRouteEditRow(name); break; } } catch (err) { @@ -885,6 +905,73 @@ async function loadLogs(name) { } } +// ─── Route editor (routing tab) ─── +let routeEditState = {}; // { appName: [{ domain, upstream }, ...] } + +async function loadRouteEditor(name) { + try { + const data = await api.getRoutes(name); + routeEditState[name] = data.routes || []; + renderRouteEditor(name); + setStatus(`Loaded ${data.routes.length} route(s) for ${name}.`); + } catch (err) { + const info = document.getElementById(`routing-info-${name}`); + if (info) info.innerHTML = `Failed: ${err.message}`; + } +} + +function renderRouteEditor(name) { + const table = document.getElementById(`route-edit-table-${name}`); + const routes = routeEditState[name] || []; + if (!table) return; + table.innerHTML = ""; + routes.forEach((r, i) => { + const row = document.createElement("div"); + row.className = "route-row"; + row.innerHTML = ` + + → + + + `; + row.querySelector(".route-domain").oninput = (e) => { routeEditState[name][i].domain = e.target.value; }; + row.querySelector(".route-upstream").oninput = (e) => { routeEditState[name][i].upstream = e.target.value; }; + row.querySelector("[data-ridx]").onclick = () => { + routeEditState[name].splice(i, 1); + renderRouteEditor(name); + }; + table.appendChild(row); + }); + if (!routes.length) { + table.innerHTML = '
No routes defined.
'; + } +} + +async function addRouteEditRow(name) { + if (!routeEditState[name]) routeEditState[name] = []; + routeEditState[name].push({ domain: "", upstream: "" }); + renderRouteEditor(name); +} + +async function saveRouteEditor(name) { + const routes = routeEditState[name] || []; + const valid = routes.filter(r => r.domain.trim() && r.upstream.trim()); + if (!valid.length) { + setStatus("At least one valid route is required.", true); + return; + } + try { + setStatus(`Saving routes for ${name}...`); + const cleanRoutes = valid.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })); + await api.setRoutes(name, cleanRoutes); + routeEditState[name] = cleanRoutes; + renderRouteEditor(name); + setStatus(`Routes saved for ${name}. Caddy reloaded.`); + } catch (err) { + setStatus(`Save routes failed: ${err.message}`, true); + } +} + window.api = api; // Expose for inline html onclicks async function initVolumesTab(name) { @@ -1078,6 +1165,7 @@ async function loadBackups(name) { } // ─── Init ─── +renderRouteTable(); loadApps(); // Auto-refresh every 15s diff --git a/panel-api.py b/panel-api.py index 8297462..cb63774 100644 --- a/panel-api.py +++ b/panel-api.py @@ -214,11 +214,23 @@ class Handler(BaseHTTPRequestHandler): fields = line.split() if len(fields) < 4: continue + # New format: name domain|upstream routes=N auth=bool [repo_url] + first_route = fields[1] + route_parts = first_route.split("|") + domain = route_parts[0].split(",")[0] if route_parts else first_route + upstream = route_parts[1] if len(route_parts) > 1 else "" + route_count_str = fields[2].replace("routes=", "") + # Backward compat: fields[2] may be upstream if old format + if not route_count_str.isdigit(): + upstream = fields[2] + route_count_str = "1" apps.append({ "name": fields[0], - "domain": fields[1].split(",")[0], - "domains": fields[1], - "upstream": fields[2], + "domain": domain, + "domains": domain, + "upstream": upstream, + "first_route": first_route, + "route_count": route_count_str, "auth": fields[3].replace("auth=", ""), "repo_url": fields[4] if len(fields) >= 5 else "", }) @@ -312,6 +324,41 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(chunk) return + # /apps//routes — get parsed routes + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "routes": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + result = run_panelctl(["show", name]) + 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 + if "|" in entry: + domain, upstream = entry.split("|", 1) + routes.append({"domain": domain.strip(), "upstream": upstream.strip()}) + else: + routes.append({"domain": entry.strip(), "upstream": ""}) + self._json(200, {"ok": True, "name": name, "routes": routes}) + return + # /apps/ — show single app if len(parts) == 2 and parts[0] == "apps": name = parts[1] @@ -533,14 +580,32 @@ class Handler(BaseHTTPRequestHandler): try: payload = self._read_json() name = payload["name"] - # Support both "domain" (string, possibly comma-separated) and "domains" (array) - if "domains" in payload and isinstance(payload["domains"], list): - domain = ",".join(payload["domains"]) - else: - domain = str(payload.get("domain", "")) - port = str(payload["port"]) auth = str(payload.get("auth", True)).lower() source_type = payload.get("source_type", "default") + + # Build routes string: "domain|upstream,domain|upstream,..." + routes_parts = [] + if "routes" in payload and isinstance(payload["routes"], list): + for r in payload["routes"]: + d = r.get("domain", "").strip() + u = r.get("upstream", "").strip() + if d and u: + routes_parts.append(f"{d}|{u}") + elif "domain" in payload and "port" in payload: + # Backward compat: single domain + port + domain_str = payload.get("domain", "") + if "domains" in payload and isinstance(payload["domains"], list): + domain_str = ",".join(payload["domains"]) + port = str(payload["port"]) + for d in domain_str.split(","): + d = d.strip() + if d: + routes_parts.append(f"{d}|127.0.0.1:{port}") + else: + self._json(400, {"ok": False, "error": "missing 'routes' array or 'domain'+'port' fields"}) + return + + routes_str = ",".join(routes_parts) except Exception as exc: self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) return @@ -550,7 +615,7 @@ class Handler(BaseHTTPRequestHandler): return try: - result = run_panelctl(["init", name, domain, port, auth]) + result = run_panelctl(["init", name, routes_str, auth]) if not result["ok"]: self._json(400, result) return @@ -730,6 +795,33 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return + # POST /apps//routes — hot update routes + if action == "routes": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + try: + payload = self._read_json() + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + route_list = payload.get("routes", []) + if not isinstance(route_list, list) or not route_list: + self._json(400, {"ok": False, "error": "routes must be a non-empty array"}) + return + routes_parts = [] + for r in route_list: + d = r.get("domain", "").strip() + u = r.get("upstream", "").strip() + if not d or not u: + self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"}) + return + routes_parts.append(f"{d}|{u}") + routes_str = ",".join(routes_parts) + result = run_panelctl(["set-routes", name, routes_str]) + self._json(200 if result["ok"] else 400, result) + return + # Simple panelctl pass-through actions if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}: if not is_safe_name(name): diff --git a/panelctl.sh b/panelctl.sh index 66fcd40..1f4c469 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -15,12 +15,38 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { } ' +validate_route_entry() { + local entry="$1" + # Format: domain|upstream (upstream = host:port) + local domain="${entry%%|*}" + local upstream="${entry#*|}" + [[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'" + [[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'" + [[ "${entry}" == *"|"* ]] || fail "route entry '${entry}' missing '|' separator (expected domain|upstream)" + validate_single_domain "${domain}" + # Validate upstream has a port + local upstream_port="${upstream##*:}" + [[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'" + validate_port "${upstream_port}" +} + +validate_routes() { + local routes_str="$1" + IFS=',' read -ra entries <<< "${routes_str}" + [[ ${#entries[@]} -ge 1 ]] || fail "at least one route is required" + for entry in "${entries[@]}"; do + entry="$(echo "${entry}" | xargs)" + validate_route_entry "${entry}" + done +} + usage() { cat <<'EOF' panelctl - minimal app panel helper Usage: - panelctl init [auth] + panelctl init "|[,...]" [auth] + panelctl set-routes "|[,...]" panelctl render-route panelctl deploy panelctl restart @@ -36,14 +62,15 @@ Usage: panelctl list panelctl show -Domains can be comma-separated for multiple domains: - panelctl init myapp "app.example.com,www.example.com" 18080 true +Each route is a domain|upstream pair. Upstream is host:port. +Multiple routes are comma-separated: + panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true Wildcard domains are supported (requires DNS challenge in Caddy): - panelctl init myapp "*.example.com" 18080 true + panelctl init myapp "*.example.com|127.0.0.1:18080" true Examples: - panelctl init whoami whoami.srazka.com 18080 true + panelctl init whoami "whoami.srazka.com|127.0.0.1:18080" true panelctl deploy whoami panelctl restart whoami panelctl status whoami @@ -128,6 +155,23 @@ load_app() { [[ -f "${manifest}" ]] || fail "app '${name}' does not exist" # shellcheck disable=SC1090 source "${manifest}" + + # Backward compat: migrate old APP_DOMAIN/APP_PORT/APP_UPSTREAM to APP_ROUTES + if [[ -z "${APP_ROUTES:-}" && -n "${APP_DOMAIN:-}" ]]; then + local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}" + local routes="" + local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}" + IFS=',' read -ra domain_arr <<< "${domains_str}" + for d in "${domain_arr[@]}"; do + d="$(echo "${d}" | xargs)" + if [[ -n "${routes}" ]]; then + routes="${routes},${d}|${upstream}" + else + routes="${d}|${upstream}" + fi + done + APP_ROUTES="${routes}" + fi } compose_command() { @@ -203,19 +247,24 @@ run_compose() { write_default_compose() { local name="$1" - local port="$2" + local routes="$2" local stack_dir local volume_dir stack_dir="$(app_stack_dir "${name}")" volume_dir="$(app_volume_dir "${name}")" + # Use first route's upstream port for the default compose mapping + local first_route="${routes%%,*}" + local first_upstream="${first_route#*|}" + local container_port="${first_upstream##*:}" + cat >"${stack_dir}/compose.yaml" <"${manifest}" <>"${tmp}" @@ -452,6 +482,45 @@ cmd_remove() { log info "removed app '${name}'" } +cmd_set_routes() { + local name="$1" + local routes="$2" + local manifest + + validate_name "${name}" + validate_routes "${routes}" + manifest="$(app_manifest "${name}")" + [[ -f "${manifest}" ]] || fail "app '${name}' does not exist" + + # Update APP_ROUTES in the manifest file, strip old fields, preserve others + local tmp + tmp="$(mktemp)" + local found_routes=false + while IFS= read -r line; do + case "${line}" in + APP_ROUTES=*) + printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}" + found_routes=true + ;; + APP_DOMAIN=*|APP_DOMAINS=*|APP_PORT=*|APP_UPSTREAM=*) + # Strip old format fields + ;; + *) + printf '%s\n' "${line}" >> "${tmp}" + ;; + esac + done < "${manifest}" + if ! "${found_routes}"; then + printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}" + fi + install -m 0664 "${tmp}" "${manifest}" + + # Re-render Caddy routes (auto-reloads via systemd.path watcher) + cmd_render_route "${name}" + + log info "updated routes for app '${name}'. Edit compose file if new ports need exposing." +} + cmd_backup() { local name="$1" validate_name "${name}" @@ -602,9 +671,29 @@ cmd_list() { source /dev/null # reset any leftover variables unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true source "${mf}" - local domains="${APP_DOMAINS:-${APP_DOMAIN}}" + # Backward compat: build APP_ROUTES from old format + local routes="${APP_ROUTES:-}" + if [[ -z "${routes}" && -n "${APP_DOMAIN:-}" ]]; then + local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}" + local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}" + IFS=',' read -ra domain_arr <<< "${domains_str}" + for d in "${domain_arr[@]}"; do + d="$(echo "${d}" | xargs)" + if [[ -n "${routes}" ]]; then + routes="${routes},${d}|${upstream}" + else + routes="${d}|${upstream}" + fi + done + fi + # Show abbreviated: first route's domain + upstream, and count + local first_route="${routes%%,*}" + local route_count=1 + if [[ "${routes}" == *","* ]]; then + route_count="$(( $(grep -o ',' <<< "${routes}" | wc -l) + 1 ))" + fi local repo_info="${APP_REPO_URL:-}" - echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED} ${repo_info}" + echo "${APP_NAME} ${first_route} routes=${route_count} auth=${APP_AUTH_PROTECTED} ${repo_info}" done if [[ "${found}" -eq 0 ]]; then @@ -626,8 +715,12 @@ main() { case "${cmd}" in init) - [[ $# -ge 4 ]] || fail "usage: panelctl init [auth]" - cmd_init "$2" "$3" "$4" "${5:-true}" + [[ $# -ge 3 ]] || fail "usage: panelctl init [auth]" + cmd_init "$2" "$3" "${4:-true}" + ;; + set-routes) + [[ $# -eq 3 ]] || fail "usage: panelctl set-routes " + cmd_set_routes "$2" "$3" ;; render-route) [[ $# -eq 2 ]] || fail "usage: panelctl render-route " From 85e29b87340dd86d927b40dca6d5b6537ad73d5f Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 20 May 2026 01:07:37 +0200 Subject: [PATCH 06/10] added path to routing --- API.md | 23 +++++++++++++++++++++-- frontend/index.html | 38 ++++++++++++++++++++++++++------------ panel-api.py | 22 +++++++++++++++------- panelctl.sh | 31 ++++++++++++++++++++++--------- 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/API.md b/API.md index 792450d..256cef6 100644 --- a/API.md +++ b/API.md @@ -69,6 +69,20 @@ Default bind: `127.0.0.1:9911` } ``` +### Create app (multiple routes, different ports, with paths) + +```json +{ + "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) ```json @@ -89,11 +103,14 @@ Note: Wildcard domains require DNS challenge configuration in Caddy. { "routes": [ {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"}, - {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"} + {"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 ` rule for sub-path routing. + Caddy reloads automatically via the systemd path watcher. Containers stay running. ### Save compose @@ -128,11 +145,13 @@ Caddy reloads automatically via the systemd path watcher. Containers stay runnin "name": "myapp", "routes": [ {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"}, - {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"} + {"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: diff --git a/frontend/index.html b/frontend/index.html index 93b2e8d..154e071 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -155,12 +155,14 @@ margin-top: 6px; } .route-row input { - flex: 1; padding: 6px 8px; font-size: .85rem; } - .route-row input:first-child { flex: 3; } + .route-row .route-domain { flex: 3; } + .route-row .route-upstream { flex: 2; } + .route-row .route-path { flex: 1; } .route-row button { flex-shrink: 0; } + .route-row .arrow { color: var(--muted); font-weight: 600; flex-shrink: 0; } /* ── App list ── */ .app-list { display: flex; flex-direction: column; gap: 12px; } @@ -307,13 +309,13 @@ - +

- Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT + Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT. Optional path: /_/*

@@ -436,7 +438,7 @@ function setStatus(msg, isError = false) { } // ─── Route table input ─── -let createRoutes = [{ domain: "", upstream: "" }]; +let createRoutes = [{ domain: "", upstream: "", path: "" }]; function renderRouteTable() { const table = document.getElementById("routeTable"); @@ -446,12 +448,14 @@ function renderRouteTable() { row.className = "route-row"; row.innerHTML = ` - → + → + ${createRoutes.length > 1 ? `` : ""} `; row.querySelector(".route-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; }; row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = e.target.value; }; + row.querySelector(".route-path").oninput = (e) => { createRoutes[i].path = e.target.value; }; const delBtn = row.querySelector("[data-ridx]"); if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); }; table.appendChild(row); @@ -459,7 +463,7 @@ function renderRouteTable() { } document.getElementById("addRouteBtn").onclick = () => { - createRoutes.push({ domain: "", upstream: "" }); + createRoutes.push({ domain: "", upstream: "", path: "" }); renderRouteTable(); }; @@ -500,13 +504,17 @@ document.getElementById("createBtn").onclick = async () => { try { setStatus(`Creating ${name}...`); - await api.init({ name, routes: validRoutes.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })), auth, source_type, compose_content, github_url, github_branch, github_pat }); + await api.init({ name, routes: validRoutes.map(r => { + const route = { domain: r.domain.trim(), upstream: r.upstream.trim() }; + if (r.path && r.path.trim()) route.path = r.path.trim(); + return route; + }), auth, source_type, compose_content, github_url, github_branch, github_pat }); setStatus(`Created ${name}.`); document.getElementById("name").value = ""; document.getElementById("createCompose").value = ""; document.getElementById("createGithubUrl").value = ""; document.getElementById("createGithubPat").value = ""; - createRoutes = [{ domain: "", upstream: "" }]; + createRoutes = [{ domain: "", upstream: "", path: "" }]; renderRouteTable(); await loadApps(); } catch (err) { @@ -930,12 +938,14 @@ function renderRouteEditor(name) { row.className = "route-row"; row.innerHTML = ` - → + → + `; row.querySelector(".route-domain").oninput = (e) => { routeEditState[name][i].domain = e.target.value; }; row.querySelector(".route-upstream").oninput = (e) => { routeEditState[name][i].upstream = e.target.value; }; + row.querySelector(".route-path").oninput = (e) => { routeEditState[name][i].path = e.target.value; }; row.querySelector("[data-ridx]").onclick = () => { routeEditState[name].splice(i, 1); renderRouteEditor(name); @@ -949,7 +959,7 @@ function renderRouteEditor(name) { async function addRouteEditRow(name) { if (!routeEditState[name]) routeEditState[name] = []; - routeEditState[name].push({ domain: "", upstream: "" }); + routeEditState[name].push({ domain: "", upstream: "", path: "" }); renderRouteEditor(name); } @@ -962,7 +972,11 @@ async function saveRouteEditor(name) { } try { setStatus(`Saving routes for ${name}...`); - const cleanRoutes = valid.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })); + const cleanRoutes = valid.map(r => { + const route = { domain: r.domain.trim(), upstream: r.upstream.trim() }; + if (r.path && r.path.trim()) route.path = r.path.trim(); + return route; + }); await api.setRoutes(name, cleanRoutes); routeEditState[name] = cleanRoutes; renderRouteEditor(name); diff --git a/panel-api.py b/panel-api.py index cb63774..ae01dfd 100644 --- a/panel-api.py +++ b/panel-api.py @@ -351,11 +351,11 @@ class Handler(BaseHTTPRequestHandler): entry = entry.strip() if not entry: continue - if "|" in entry: - domain, upstream = entry.split("|", 1) - routes.append({"domain": domain.strip(), "upstream": upstream.strip()}) - else: - routes.append({"domain": entry.strip(), "upstream": ""}) + 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}) return @@ -589,8 +589,12 @@ class Handler(BaseHTTPRequestHandler): for r in payload["routes"]: d = r.get("domain", "").strip() u = r.get("upstream", "").strip() + p = r.get("path", "").strip() if d and u: - routes_parts.append(f"{d}|{u}") + if p: + routes_parts.append(f"{d}|{u}|{p}") + else: + routes_parts.append(f"{d}|{u}") elif "domain" in payload and "port" in payload: # Backward compat: single domain + port domain_str = payload.get("domain", "") @@ -813,10 +817,14 @@ class Handler(BaseHTTPRequestHandler): for r in route_list: d = r.get("domain", "").strip() u = r.get("upstream", "").strip() + p = r.get("path", "").strip() if not d or not u: self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"}) return - routes_parts.append(f"{d}|{u}") + if p: + routes_parts.append(f"{d}|{u}|{p}") + else: + routes_parts.append(f"{d}|{u}") routes_str = ",".join(routes_parts) result = run_panelctl(["set-routes", name, routes_str]) self._json(200 if result["ok"] else 400, result) diff --git a/panelctl.sh b/panelctl.sh index 1f4c469..4490418 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -17,17 +17,18 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { validate_route_entry() { local entry="$1" - # Format: domain|upstream (upstream = host:port) - local domain="${entry%%|*}" - local upstream="${entry#*|}" + # Format: domain|upstream[/path] or domain|upstream (path is optional) + IFS='|' read -r domain upstream path <<< "${entry}" [[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'" [[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'" - [[ "${entry}" == *"|"* ]] || fail "route entry '${entry}' missing '|' separator (expected domain|upstream)" validate_single_domain "${domain}" # Validate upstream has a port local upstream_port="${upstream##*:}" [[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'" validate_port "${upstream_port}" + if [[ -n "${path}" ]]; then + [[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'" + fi } validate_routes() { @@ -351,12 +352,24 @@ cmd_render_route() { IFS=',' read -ra route_entries <<< "${APP_ROUTES}" for entry in "${route_entries[@]}"; do entry="$(echo "${entry}" | xargs)" - local domain="${entry%%|*}" - local upstream="${entry#*|}" - if [[ -n "${auth_block}" ]]; then - printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}" + IFS='|' read -r domain upstream path <<< "${entry}" + # If upstream is empty (no second pipe), this is the old format + if [[ -z "${upstream}" ]]; then + upstream="${path}" + path="" + fi + if [[ -n "${path}" ]]; then + if [[ -n "${auth_block}" ]]; then + printf "%s {\n%s reverse_proxy %s %s\n}\n" "${domain}" "${auth_block}" "${path}" "${upstream}" + else + printf "%s {\n reverse_proxy %s %s\n}\n" "${domain}" "${path}" "${upstream}" + fi else - printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}" + if [[ -n "${auth_block}" ]]; then + printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}" + else + printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}" + fi fi done printf "# route:%s:end\n" "${name}" From 2d3b30d078b0695764915bb33572c482d461cd0d Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 22:17:07 +0000 Subject: [PATCH 07/10] 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//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 Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf --- API.md | 72 + README.md | 52 +- frontend/index.html | 3214 ++++++++++++++++++++++++++++--------------- panel-api.py | 697 +++++++--- panelctl.sh | 32 +- 5 files changed, 2741 insertions(+), 1326 deletions(-) diff --git a/API.md b/API.md index 256cef6..87787a3 100644 --- a/API.md +++ b/API.md @@ -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//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | +| GET | `/apps//repo` | Git source info (URL, branch, deployed commit, local changes) | +| GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | +| GET | `/apps//volumes` | Volumes the file browser can open | +| GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | +| GET | `/apps//volume/download?vol=&path=` | Download a file from a volume | +| PUT | `/apps//volume/files?vol=&path=` | Upload a file (raw body) | +| DELETE | `/apps//volume/files?vol=&path=` | Delete a file or folder | ### Apps — Write @@ -41,6 +49,70 @@ Default bind: `127.0.0.1:9911` | POST | `/apps//backup` | Create volume backup (zip) | | POST | `/apps//restore` | Restore from backup | | POST | `/apps//remove` | Remove app | +| POST | `/apps//repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | +| POST | `/apps//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 diff --git a/README.md b/README.md index 02ede16..cf6dc4f 100644 --- a/README.md +++ b/README.md @@ -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//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. diff --git a/frontend/index.html b/frontend/index.html index 154e071..a7dfc4c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,1191 +1,2219 @@ - - - Panel - - - - + /* ── Menu ── */ + .menu { + position: fixed; z-index: 50; min-width: 190px; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 5px; + } + .menu-item { + display: flex; align-items: center; gap: 9px; width: 100%; + background: none; border: 0; border-radius: 6px; padding: 7px 10px; + color: var(--text); font: 13.5px var(--font); cursor: pointer; text-align: left; + } + .menu-item:hover:not(:disabled) { background: var(--surface-2); } + .menu-item:disabled { opacity: .45; cursor: default; } + .menu-item.danger { color: var(--danger); } + .menu-sep { height: 1px; background: var(--border); margin: 4px 2px; } + + /* ── Toasts ── */ + .toasts { position: fixed; right: 16px; bottom: 16px; z-index: 60; display: flex; flex-direction: column; gap: 8px; width: min(380px, calc(100vw - 32px)); } + .toast { + display: flex; gap: 10px; align-items: flex-start; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 11px 12px; + animation: toast-in .18s ease-out; + } + .toast.leaving { opacity: 0; transform: translateY(6px); transition: .18s; } + @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + .toast > .icon { margin-top: 2px; } + .toast-success > .icon { color: var(--ok); } + .toast-error > .icon { color: var(--danger); } + .toast-info > .icon { color: var(--accent); } + .toast-body { flex: 1; min-width: 0; } + .toast-title { font-weight: 600; font-size: 13.5px; } + .toast-detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } + .toast .link { font-size: 12.5px; margin-top: 4px; } + .toast-close { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; border-radius: 4px; } + .toast-close:hover { color: var(--text); background: var(--surface-2); } + + /* ── Activity drawer ── */ + .drawer { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 40; + width: min(460px, 100vw); + background: var(--surface); border-left: 1px solid var(--border-strong); + box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; + animation: slide-in .18s ease-out; + } + @keyframes slide-in { from { transform: translateX(24px); opacity: 0; } } + .drawer-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); } + .drawer-head h2 { font-size: 15px; flex: 1; } + .drawer-body { flex: 1; overflow: auto; padding: 8px; } + .act { border-radius: 8px; padding: 2px 0; } + .act + .act { border-top: 1px solid var(--border); } + .act summary { list-style: none; cursor: pointer; border-radius: 6px; } + .act summary::-webkit-details-marker { display: none; } + .act summary:hover { background: var(--surface-2); } + .act-head { display: flex; align-items: center; gap: 8px; padding: 8px; font-size: 13px; } + .act-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .act-ok { color: var(--ok); } + .act-error { color: var(--danger); } + .act pre { margin: 0 8px 8px; } + + /* ── Dialogs ── */ + dialog { + border: 1px solid var(--border-strong); border-radius: 14px; padding: 0; + background: var(--surface); color: var(--text); + box-shadow: var(--shadow-lg); + width: min(640px, calc(100vw - 24px)); max-height: calc(100vh - 32px); + } + dialog.sm { width: min(440px, calc(100vw - 24px)); } + dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } + dialog form { display: flex; flex-direction: column; max-height: calc(100vh - 34px); } + .dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 6px; } + .dialog-head h2, dialog.sm h2 { font-size: 17px; } + dialog.sm form { padding: 18px; } + dialog.sm .dialog-body { padding: 10px 0 4px; } + .dialog-body { padding: 10px 18px; overflow: auto; } + .dialog-body p + p { margin-top: 8px; } + .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; border-top: 1px solid var(--border); } + dialog.sm .dialog-actions { padding: 14px 0 0; border: 0; } + .segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 9px; padding: 3px; gap: 2px; background: var(--surface-2); flex-wrap: wrap; } + .segmented button { + border: 0; background: none; padding: 6px 12px; border-radius: 6px; + font: 500 13px var(--font); color: var(--muted); cursor: pointer; + } + .segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); } + + /* ── Responsive ── */ + @media (max-width: 760px) { + .hide-sm { display: none !important; } + .topbar-inner { flex-wrap: wrap; } + .search { order: 3; max-width: none; margin-left: 0; flex-basis: 100%; } + .search kbd, .shortcuts { display: none; } + .grid-2 { grid-template-columns: 1fr; } + /* Card header: name + actions on top, status pill underneath. */ + .app-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px 10px; padding: 12px; } + .app-head > .chev { grid-row: 1 / span 2; align-self: start; margin-top: 3px; } + .app-title { grid-column: 2; grid-row: 1; } + .app-actions { grid-column: 3; grid-row: 1; align-self: start; } + .app-head > .pill { grid-column: 2; grid-row: 2; justify-self: start; min-width: 0; } + .route-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px; } + .route-row .arrow { display: none; } + .route-row .route-path { grid-column: 1 / 3; } + .route-head { display: none; } + .file-row { grid-template-columns: 20px minmax(0, 1fr) auto; } + .file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; } + .logs { height: 320px; } + } + + +
+
+
Panel
+ +
+ + + +
+
+
+ + +
- -
-
-

Containers Panel

-

Rootless Podman + Caddy routes from one place.

-
-
- -
-
- - -
Ready.
- - -
- -
-
-

Create App

- - - - -
-
- -
-

- Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT. Optional path: /_/* -

- -
- - -
- - - - - - - - -
- -
-
-
- - -
-
-
Loading apps...
-
-
-
+
+
+
/ search · N new app
+
+
+
+
+ + +
+ + + +
+
+

New app

+ +
+
+
+ + +

Lowercase letters, digits and dashes. Used for the compose project and data folder.

+
+ +
+ Source +
+ + + +
+

+
+ + + + + +
+ Routes +
+ +

Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. /api/*. Wildcard domains need a DNS challenge.

+
+ + + + + +
+
+ + +
+
+
+ + +
+

+
+ +
+ + +
+
+ + +
+
+
+ diff --git a/panel-api.py b/panel-api.py index ae01dfd..3a5d2cb 100644 --- a/panel-api.py +++ b/panel-api.py @@ -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//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/ — 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//repo-pull — re-clone/pull repo and redeploy + # POST /apps//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//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() diff --git a/panelctl.sh b/panelctl.sh index 4490418..afcb8ab 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -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() { From db46c5e793bf365afe566aaa2b5fb53effdf3efc Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 22:52:09 +0000 Subject: [PATCH 08/10] panel: Forgejo integration, ssh deploy key and per-app environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo: - panel.nix passes the local Forgejo's public, API and ssh URLs (derived from forgejo.nix) to panel-api. - Settings dialog: connect a Forgejo access token (verified against /api/v1/user, stored 0600 in state/panel/forgejo-token). - New-app dialog gets a Forgejo repository picker with search and a branch dropdown; private repos are cloned over https with the stored token, or over ssh with the deploy key when no token is connected. The app name and domain are filled in from the repository name. - Commit and compare links in the Source tab point at Forgejo; cards show the provider ("Forgejo · main"). Git over ssh: - ssh:// and git@host:owner/repo URLs are accepted; the panel generates an ed25519 deploy key in state/panel/ssh and uses it for clone/fetch (BatchMode, accept-new host keys). openssh added to the service path. - Credential redaction only applies to http(s) URLs, so ssh usernames are kept; git errors now report the meaningful line instead of git's advice. Environment variables: - Stored per app in state/env/.env (0600), outside the repo and stack. - panelctl passes them to every compose command via env(1), so ${VAR} interpolation works; by default deploy/restart also generate a compose override listing the keys under every service's environment (values are read from compose's environment, never quoted into YAML). - Environment tab (and a section in the new-app dialog) with .env paste import, hidden values, validation of names (reserved podman/compose vars rejected), hints for ${VAR}s the compose file uses but aren't set, and Save / Save & deploy. Removing an app deletes its variables. The API still accepts the old source_type "github" / github_* fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf --- API.md | 30 +- README.md | 40 ++- frontend/index.html | 748 +++++++++++++++++++++++++++++++++++++++++--- panel-api.py | 426 +++++++++++++++++++++++-- panelctl.sh | 114 +++++-- 5 files changed, 1258 insertions(+), 100 deletions(-) diff --git a/API.md b/API.md index 87787a3..298faea 100644 --- a/API.md +++ b/API.md @@ -13,6 +13,10 @@ 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) | +| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key | +| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects | +| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) | +| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository | ### Apps — Read @@ -26,7 +30,8 @@ Default bind: `127.0.0.1:9911` | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | -| GET | `/apps//repo` | Git source info (URL, branch, deployed commit, local changes) | +| GET | `/apps//env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` | +| GET | `/apps//repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) | | GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | | GET | `/apps//volumes` | Volumes the file browser can open | | GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | @@ -50,28 +55,37 @@ Default bind: `127.0.0.1:9911` | POST | `/apps//restore` | Restore from backup | | POST | `/apps//remove` | Remove app | | POST | `/apps//repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | +| POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` | | POST | `/apps//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) +### Create app (git repository, with environment variables) ```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": "" + "source_type": "git", + "repo_url": "https://git.srazka.com/reudy-net/blog.git", + "repo_branch": "", + "use_forgejo_token": true, + "env": [{"key": "DATABASE_URL", "value": "postgres://..."}], + "env_inject": true } ``` -Any http(s) git host works. An empty branch uses the repository's default branch. -The compose file must be at the repository root. +- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or + `git@host:owner/repo.git`. ssh URLs use the panel's deploy key. +- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the + token stored in Settings (only for URLs on the configured Forgejo host). +- An empty branch uses the repository's default branch. The compose file must + be at the repository root. +- The older `source_type: "github"` with `github_url` / `github_branch` / + `github_pat` is still accepted. ### Sync response (`repo-pull`) diff --git a/README.md b/README.md index cf6dc4f..5f9cc49 100644 --- a/README.md +++ b/README.md @@ -114,13 +114,49 @@ panelctl remove whoami ### Git-backed apps -Apps created from a repository (GitHub, Forgejo/Gitea or any https git host) -are cloned to `stacks//repo`. **Sync** fetches the configured branch and +Apps created from a repository (Forgejo, GitHub or any git host, over https or +ssh) are cloned to `stacks//repo`. + +**Forgejo.** `panel.nix` points the panel at the local Forgejo +(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken +from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo +access token (read access to repositories and user). With it, the new-app +dialog lists your repositories and branches, and private ones are cloned over +https with the token. Without it, public repositories are listed and private +ones are cloned over ssh with the deploy key. Commit and compare links point at +Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600). + +**SSH / deploy key.** The panel generates an ed25519 key pair in +`state/panel/ssh/` the first time it is needed. Its public half is shown in +Settings (and next to ssh URLs); add it as a read-only deploy key to a +repository — or to your Forgejo account for access to all repositories — to +clone `ssh://git@git.srazka.com:14922/owner/repo.git` style URLs. **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. +### Environment variables + +Each app can have environment variables (the **Environment** tab, or when +creating the app; `.env` text can be pasted in). They are stored in +`state/env/.env` as `KEY=VALUE` lines (mode 0600) — outside the repository +and stack directory, so git syncs never touch them — and `panelctl` passes them +to every compose command: + +- They are always available for `${VAR}` interpolation in the compose file. + The UI points out variables the compose file uses without a default that + aren't set. +- With **Pass to every container** (the default), `deploy`/`restart` also + generate `stacks//.panel-env.yaml`, a compose override that lists the + keys under every service's `environment:`. Compose reads the values from its + own environment, so they are never quoted into YAML, and they take + precedence over values set in the compose file. + +Values must be single-line. Names that would change how podman/compose run +(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected. +Changes apply on the next deploy. Backups do not include variables. + ### Concurrency `panel-api` handles requests concurrently, so a long deploy never blocks status diff --git a/frontend/index.html b/frontend/index.html index a7dfc4c..298ffb1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -450,6 +450,45 @@ } .segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); } + /* ── Repo picker ── */ + .picker-list { + margin-top: 6px; max-height: 232px; overflow: auto; + border: 1px solid var(--border); border-radius: 8px; + } + .picker-item { + display: flex; align-items: center; gap: 10px; width: 100%; + background: none; border: 0; border-top: 1px solid var(--border); + padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit; + } + .picker-item:first-child { border-top: 0; } + .picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; } + .picker-item.selected { background: var(--accent-soft); } + .picker-item .icon { color: var(--muted); } + .picker-name { font-weight: 600; font-size: 13.5px; } + .picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .picker-main { flex: 1; min-width: 0; } + + /* ── Settings ── */ + .settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); } + .settings-section h3 { font-size: 14px; margin-bottom: 4px; } + .keybox { + margin-top: 10px; display: flex; gap: 8px; align-items: flex-start; + background: var(--surface-2); border-radius: 8px; padding: 10px 12px; + font: 12px/1.5 var(--mono); word-break: break-all; + } + .keybox code { flex: 1; background: none; padding: 0; } + + /* ── Environment editor ── */ + .env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; } + .env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); } + .env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; } + .env-details summary { cursor: pointer; list-style: none; } + .env-details summary::-webkit-details-marker { display: none; } + .env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; } + .env-details[open] summary::before { transform: rotate(90deg); } + .missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + .chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); } + /* ── Responsive ── */ @media (max-width: 760px) { .hide-sm { display: none !important; } @@ -467,6 +506,10 @@ .route-row .arrow { display: none; } .route-row .route-path { grid-column: 1 / 3; } .route-head { display: none; } + .env-row { grid-template-columns: minmax(0, 1fr) 28px; } + .env-row > :nth-child(2) { grid-column: 1; } + .env-row > :nth-child(3) { grid-column: 2; grid-row: 1; } + .env-head { display: none; } .file-row { grid-template-columns: 20px minmax(0, 1fr) auto; } .file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; } .logs { height: 320px; } @@ -485,6 +528,7 @@
+ @@ -538,7 +582,7 @@
- +

@@ -549,24 +593,56 @@