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 "