feat: add initial implementation of the frontend panel with app management features
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
8cb90be7cd
commit
e15298fd3d
5 changed files with 1547 additions and 557 deletions
746
panel-api.py
746
panel-api.py
|
|
@ -1,437 +1,27 @@
|
|||
#!/usr/bin/env python3
|
||||
"""panel-api — HTTP wrapper around panelctl with a web UI."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
|
||||
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
|
||||
PORT = int(os.environ.get("PANEL_API_PORT", "9911"))
|
||||
BASE_DIR = os.environ.get("PANEL_BASE_DIR", "/var/lib/containers")
|
||||
|
||||
INDEX_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--ink: #1f1f1f;
|
||||
--paper: #fbf6ef;
|
||||
--accent: #0e8a6b;
|
||||
--accent-dark: #0a664f;
|
||||
--line: #1f1f1f22;
|
||||
--warn: #7a1818;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 400px at -10% -20%, #ffd79a 0%, transparent 60%),
|
||||
radial-gradient(900px 300px at 120% 5%, #9de2cf 0%, transparent 55%),
|
||||
var(--paper);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
margin: 0 auto;
|
||||
padding: 28px 28px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 7px 7px 0 #0000001a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 3.5vw, 2.6rem);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 8px 0 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
.grid {
|
||||
grid-template-columns: 1.1fr 1.9fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin: 10px 0 5px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent-dark);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
padding: 8px 6px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.status.ok { border-color: #1b7a39; }
|
||||
.status.err { border-color: var(--warn); }
|
||||
|
||||
.editor-wrap {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font: 0.88rem/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
background: #fff;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: appear 280ms ease;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero fade-in">
|
||||
<h1>Containers Panel</h1>
|
||||
<p class="sub">Rootless Podman + Caddy routes from one place.</p>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card fade-in">
|
||||
<h2>Create App</h2>
|
||||
<label for="name">Name</label>
|
||||
<input id="name" placeholder="whoami" />
|
||||
<label for="domain">Domain</label>
|
||||
<input id="domain" placeholder="whoami.srazka.com" />
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="port">Host Port</label>
|
||||
<input id="port" type="number" min="1024" max="65535" value="18080" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="auth">Protected</label>
|
||||
<select id="auth">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack" style="margin-top: 12px;">
|
||||
<button class="primary" id="createBtn">Create</button>
|
||||
<button id="refreshBtn">Refresh List</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card fade-in">
|
||||
<h2>Apps</h2>
|
||||
<table class="table" id="appsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Domain</th>
|
||||
<th>Upstream</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="appsBody"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="editor-wrap">
|
||||
<h2 style="margin-top: 12px;">Compose Editor</h2>
|
||||
<div class="stack" style="margin-bottom: 8px;">
|
||||
<div class="mono" id="composeTarget">No app selected.</div>
|
||||
<button id="saveComposeBtn">Save Compose</button>
|
||||
</div>
|
||||
<textarea id="composeText" placeholder="Select an app and click Compose to load compose.yaml"></textarea>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="status mono" id="status">Ready.</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const statusEl = document.getElementById("status");
|
||||
const appsBody = document.getElementById("appsBody");
|
||||
const composeText = document.getElementById("composeText");
|
||||
const composeTarget = document.getElementById("composeTarget");
|
||||
let selectedComposeApp = null;
|
||||
|
||||
function setStatus(msg, isError = false) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.classList.toggle("ok", !isError);
|
||||
statusEl.classList.toggle("err", isError);
|
||||
}
|
||||
|
||||
async function api(path, method = "GET", payload = null) {
|
||||
const opts = { method, headers: {} };
|
||||
if (payload) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(payload);
|
||||
}
|
||||
|
||||
const res = await fetch(path, opts);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.stderr || data.error || data.stdout || "request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function actionButton(label, fn) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", fn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function runAction(name, action, body = null) {
|
||||
try {
|
||||
setStatus(`Running ${action} on ${name}...`);
|
||||
await api(`/apps/${name}/${action}`, "POST", body);
|
||||
setStatus(`${action} completed for ${name}.`);
|
||||
await loadApps();
|
||||
} catch (err) {
|
||||
setStatus(`${action} failed for ${name}: ${err.message}`, true);
|
||||
}
|
||||
|
||||
async function loadCompose(name) {
|
||||
try {
|
||||
setStatus(`Loading compose for ${name}...`);
|
||||
const data = await api(`/apps/${name}/compose`);
|
||||
composeText.value = data.content;
|
||||
selectedComposeApp = name;
|
||||
composeTarget.textContent = `Editing: ${name}`;
|
||||
setStatus(`Compose loaded for ${name}.`);
|
||||
} catch (err) {
|
||||
setStatus(`Failed to load compose: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCompose() {
|
||||
if (!selectedComposeApp) {
|
||||
setStatus("No app selected for compose editing.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(`Saving compose for ${selectedComposeApp}...`);
|
||||
await api(`/apps/${selectedComposeApp}/compose`, "POST", { content: composeText.value });
|
||||
setStatus(`Compose saved for ${selectedComposeApp}.`);
|
||||
} catch (err) {
|
||||
setStatus(`Failed to save compose: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rowForApp(app) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "fade-in";
|
||||
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "stack";
|
||||
actions.appendChild(actionButton("Deploy", () => runAction(app.name, "deploy")));
|
||||
actions.appendChild(actionButton("Stop", () => runAction(app.name, "stop")));
|
||||
actions.appendChild(actionButton("Compose", () => loadCompose(app.name)));
|
||||
actions.appendChild(actionButton("Route", () => runAction(app.name, "render-route")));
|
||||
actions.appendChild(actionButton("Remove", async () => {
|
||||
const keep = window.confirm("Keep volumes? Press OK to keep, Cancel to delete.");
|
||||
await runAction(app.name, "remove", { keepVolumes: keep });
|
||||
}));
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="mono">${app.name}</td>
|
||||
<td class="mono">${app.domain}</td>
|
||||
<td class="mono">${app.upstream}</td>
|
||||
`;
|
||||
tr.appendChild(actions);
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
const data = await api("/apps");
|
||||
appsBody.innerHTML = "";
|
||||
if (!data.apps.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = '<td colspan="4" class="mono">No apps yet.</td>';
|
||||
appsBody.appendChild(tr);
|
||||
} else {
|
||||
data.apps.forEach((app) => appsBody.appendChild(rowForApp(app)));
|
||||
}
|
||||
setStatus(`Loaded ${data.apps.length} app(s).`);
|
||||
} catch (err) {
|
||||
setStatus(`Failed to load apps: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const name = document.getElementById("name").value.trim();
|
||||
const domain = document.getElementById("domain").value.trim();
|
||||
const port = Number(document.getElementById("port").value);
|
||||
const auth = document.getElementById("auth").value === "true";
|
||||
|
||||
if (!name || !domain || !port) {
|
||||
setStatus("Name, domain and port are required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(`Creating ${name}...`);
|
||||
await api("/apps/init", "POST", { name, domain, port, auth });
|
||||
setStatus(`Created ${name}.`);
|
||||
await loadApps();
|
||||
} catch (err) {
|
||||
setStatus(`Create failed: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("createBtn").addEventListener("click", createApp);
|
||||
document.getElementById("refreshBtn").addEventListener("click", loadApps);
|
||||
document.getElementById("saveComposeBtn").addEventListener("click", saveCompose);
|
||||
loadApps();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
FRONTEND_DIR = os.environ.get(
|
||||
"PANEL_FRONTEND_DIR",
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
|
||||
)
|
||||
|
||||
|
||||
def is_safe_name(name):
|
||||
return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None
|
||||
|
||||
|
||||
def read_app_info(name):
|
||||
if not is_safe_name(name):
|
||||
return None, {"ok": False, "error": "invalid app name"}
|
||||
|
||||
result = run_panelctl(["show", name])
|
||||
if not result["ok"]:
|
||||
return None, result
|
||||
|
||||
app = parse_env_blob(result["stdout"])
|
||||
compose_file = app.get("APP_COMPOSE_FILE", "")
|
||||
if not compose_file:
|
||||
return None, {"ok": False, "error": "missing APP_COMPOSE_FILE in manifest"}
|
||||
|
||||
base_stacks = os.path.join(BASE_DIR, "stacks") + os.sep
|
||||
norm_compose = os.path.abspath(compose_file)
|
||||
if not norm_compose.startswith(base_stacks):
|
||||
return None, {"ok": False, "error": "compose path is outside allowed base directory"}
|
||||
|
||||
app["APP_COMPOSE_FILE"] = norm_compose
|
||||
return app, None
|
||||
|
||||
|
||||
def run_panelctl(args):
|
||||
proc = subprocess.run(
|
||||
[PANELCTL, *args],
|
||||
|
|
@ -458,6 +48,81 @@ def parse_env_blob(blob):
|
|||
return out
|
||||
|
||||
|
||||
def read_app_info(name):
|
||||
if not is_safe_name(name):
|
||||
return None, {"ok": False, "error": "invalid app name"}
|
||||
|
||||
result = run_panelctl(["show", name])
|
||||
if not result["ok"]:
|
||||
return None, result
|
||||
|
||||
app = parse_env_blob(result["stdout"])
|
||||
compose_file = app.get("APP_COMPOSE_FILE", "")
|
||||
if not compose_file:
|
||||
return None, {"ok": False, "error": "missing APP_COMPOSE_FILE in manifest"}
|
||||
|
||||
base_stacks = os.path.join(BASE_DIR, "stacks") + os.sep
|
||||
norm_compose = os.path.abspath(compose_file)
|
||||
if not norm_compose.startswith(base_stacks):
|
||||
return None, {"ok": False, "error": "compose path is outside allowed base directory"}
|
||||
|
||||
app["APP_COMPOSE_FILE"] = norm_compose
|
||||
return app, None
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def parse_backups_output(stdout):
|
||||
"""Parse panelctl list-backups output into structured data."""
|
||||
backups = []
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line or "no backups" in line.lower():
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 1:
|
||||
entry = {"name": parts[0]}
|
||||
if len(parts) >= 2:
|
||||
entry["size"] = parts[1]
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
entry["mtime"] = int(parts[2])
|
||||
except ValueError:
|
||||
pass
|
||||
backups.append(entry)
|
||||
return backups
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _html(self, code, body):
|
||||
payload = body.encode("utf-8")
|
||||
|
|
@ -475,6 +140,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _file(self, code, filepath, content_type):
|
||||
try:
|
||||
with open(filepath, "rb") as fh:
|
||||
data = fh.read()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except OSError:
|
||||
self._json(500, {"ok": False, "error": "failed to read file"})
|
||||
|
||||
def _read_json(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length == 0:
|
||||
|
|
@ -483,14 +160,29 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
# Log to stdout (goes to systemd journal)
|
||||
print(f"[panel-api] {self.address_string()} {fmt % args}")
|
||||
|
||||
# ── Routing helpers ──
|
||||
|
||||
def _parse_path(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
query = parse_qs(parsed.query)
|
||||
parts = [p for p in path.split("/") if p]
|
||||
return path, parts, query
|
||||
|
||||
# ── GET ──
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
path, parts, query = self._parse_path()
|
||||
|
||||
if path == "/":
|
||||
self._html(200, INDEX_HTML)
|
||||
index = os.path.join(FRONTEND_DIR, "index.html")
|
||||
if os.path.isfile(index):
|
||||
self._file(200, index, "text/html; charset=utf-8")
|
||||
else:
|
||||
self._html(200, "<h1>Panel</h1><p>Frontend not found.</p>")
|
||||
return
|
||||
|
||||
if path == "/health":
|
||||
|
|
@ -502,134 +194,237 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if not result["ok"]:
|
||||
self._json(500, result)
|
||||
return
|
||||
|
||||
apps = []
|
||||
for line in result["stdout"].splitlines():
|
||||
if not line.strip() or line.strip() == "no apps found":
|
||||
continue
|
||||
# format: name domain upstream auth=true|false
|
||||
fields = line.split()
|
||||
if len(fields) < 4:
|
||||
continue
|
||||
app = {
|
||||
apps.append({
|
||||
"name": fields[0],
|
||||
"domain": fields[1],
|
||||
"domain": fields[1].split(",")[0],
|
||||
"domains": fields[1],
|
||||
"upstream": fields[2],
|
||||
"auth": fields[3].replace("auth=", ""),
|
||||
}
|
||||
apps.append(app)
|
||||
|
||||
})
|
||||
self._json(200, {"ok": True, "apps": apps})
|
||||
return
|
||||
|
||||
if path.startswith("/apps/") and path.endswith("/compose"):
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if len(parts) != 3:
|
||||
self._json(404, {"ok": False, "error": "not found"})
|
||||
return
|
||||
|
||||
_, name, _ = parts
|
||||
# /apps/<name>/compose
|
||||
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "compose":
|
||||
name = parts[1]
|
||||
app, err = read_app_info(name)
|
||||
if err is not None:
|
||||
self._json(404, err)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(app["APP_COMPOSE_FILE"], "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
except OSError as exc:
|
||||
self._json(500, {"ok": False, "error": f"failed to read compose file: {exc}"})
|
||||
self._json(500, {"ok": False, "error": f"failed to read compose: {exc}"})
|
||||
return
|
||||
|
||||
self._json(200, {"ok": True, "name": name, "content": content})
|
||||
return
|
||||
|
||||
if path.startswith("/apps/"):
|
||||
name = path.split("/")[-1]
|
||||
if not name:
|
||||
self._json(400, {"ok": False, "error": "missing app name"})
|
||||
# /apps/<name>/status
|
||||
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "status":
|
||||
name = parts[1]
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
result = run_panelctl(["status", name])
|
||||
status = parse_status_output(result["stdout"])
|
||||
self._json(200, {"ok": True, "name": name, **status})
|
||||
return
|
||||
|
||||
# /apps/<name>/logs
|
||||
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "logs":
|
||||
name = parts[1]
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
tail = query.get("tail", ["100"])[0]
|
||||
try:
|
||||
tail = str(int(tail))
|
||||
except ValueError:
|
||||
tail = "100"
|
||||
result = run_panelctl(["logs", name, "--tail", tail])
|
||||
self._json(200, {"ok": True, "name": name, "logs": result["stdout"]})
|
||||
return
|
||||
|
||||
# /apps/<name>/backups
|
||||
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "backups":
|
||||
name = parts[1]
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
result = run_panelctl(["list-backups", name])
|
||||
backups = parse_backups_output(result["stdout"])
|
||||
self._json(200, {"ok": True, "name": name, "backups": backups})
|
||||
return
|
||||
|
||||
# /apps/<name>/backups/<filename> — download backup zip
|
||||
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "backups":
|
||||
name = parts[1]
|
||||
filename = parts[3]
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
# Validate filename: must match <name>-<timestamp>.zip
|
||||
if not re.match(r"^[a-z0-9-]+-\d{8}-\d{6}\.zip$", filename):
|
||||
self._json(400, {"ok": False, "error": "invalid backup filename"})
|
||||
return
|
||||
backup_path = os.path.join(BASE_DIR, "backups", filename)
|
||||
norm_path = os.path.abspath(backup_path)
|
||||
norm_backups = os.path.abspath(os.path.join(BASE_DIR, "backups")) + os.sep
|
||||
if not norm_path.startswith(norm_backups):
|
||||
self._json(403, {"ok": False, "error": "path traversal denied"})
|
||||
return
|
||||
if not os.path.isfile(norm_path):
|
||||
self._json(404, {"ok": False, "error": "backup not found"})
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||
size = os.path.getsize(norm_path)
|
||||
self.send_header("Content-Length", str(size))
|
||||
self.end_headers()
|
||||
with open(norm_path, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
return
|
||||
|
||||
# /apps/<name> — show single app
|
||||
if len(parts) == 2 and parts[0] == "apps":
|
||||
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
|
||||
|
||||
self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])})
|
||||
return
|
||||
|
||||
self._json(404, {"ok": False, "error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
# ── POST ──
|
||||
|
||||
def do_POST(self):
|
||||
path, parts, query = self._parse_path()
|
||||
|
||||
# POST /apps/init
|
||||
if path == "/apps/init":
|
||||
try:
|
||||
payload = self._read_json()
|
||||
name = payload["name"]
|
||||
domain = payload["domain"]
|
||||
# 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()
|
||||
except Exception as exc:
|
||||
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||
return
|
||||
|
||||
result = run_panelctl(["init", name, domain, port, auth])
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
return
|
||||
|
||||
action_prefix = "/apps/"
|
||||
if path.startswith(action_prefix):
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if len(parts) == 3 and parts[2] == "compose":
|
||||
_, name, _ = parts
|
||||
if len(parts) >= 3 and parts[0] == "apps":
|
||||
name = parts[1]
|
||||
action = parts[2]
|
||||
|
||||
# POST /apps/<name>/compose — save compose file
|
||||
if action == "compose":
|
||||
app, err = read_app_info(name)
|
||||
if err is not None:
|
||||
self._json(404, err)
|
||||
return
|
||||
|
||||
try:
|
||||
payload = self._read_json()
|
||||
except Exception as exc:
|
||||
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||
return
|
||||
|
||||
content = payload.get("content", "")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
self._json(400, {"ok": False, "error": "compose content must be a non-empty string"})
|
||||
return
|
||||
|
||||
try:
|
||||
with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
except OSError as exc:
|
||||
self._json(500, {"ok": False, "error": f"failed to write compose file: {exc}"})
|
||||
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
|
||||
return
|
||||
|
||||
self._json(200, {"ok": True, "name": name, "saved": True})
|
||||
return
|
||||
|
||||
# /apps/<name>/<action>
|
||||
if len(parts) == 3:
|
||||
_, name, action = parts
|
||||
if action in {"deploy", "stop", "render-route"}:
|
||||
result = run_panelctl([action, name])
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
# POST /apps/<name>/validate-compose
|
||||
if action == "validate-compose":
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
if action == "remove":
|
||||
result = run_panelctl(["validate-compose", name])
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
return
|
||||
|
||||
# POST /apps/<name>/backup
|
||||
if action == "backup":
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
result = run_panelctl(["backup", name])
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
return
|
||||
|
||||
# POST /apps/<name>/restore
|
||||
if action == "restore":
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
try:
|
||||
payload = self._read_json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
try:
|
||||
payload = self._read_json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
keep = payload.get("keepVolumes", False)
|
||||
args = ["remove", name]
|
||||
if keep:
|
||||
args.append("--keep-volumes")
|
||||
result = run_panelctl(args)
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
backup_file = payload.get("file", "")
|
||||
if not backup_file:
|
||||
self._json(400, {"ok": False, "error": "backup file name is required"})
|
||||
return
|
||||
result = run_panelctl(["restore", name, backup_file])
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
return
|
||||
|
||||
# Simple panelctl pass-through actions
|
||||
if action in {"deploy", "stop", "restart", "render-route"}:
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
result = run_panelctl([action, name])
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
return
|
||||
|
||||
# POST /apps/<name>/remove
|
||||
if action == "remove":
|
||||
if not is_safe_name(name):
|
||||
self._json(400, {"ok": False, "error": "invalid app name"})
|
||||
return
|
||||
try:
|
||||
payload = self._read_json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
keep = payload.get("keepVolumes", False)
|
||||
args = ["remove", name]
|
||||
if keep:
|
||||
args.append("--keep-volumes")
|
||||
result = run_panelctl(args)
|
||||
self._json(200 if result["ok"] else 400, result)
|
||||
return
|
||||
|
||||
self._json(404, {"ok": False, "error": "not found"})
|
||||
|
||||
|
|
@ -637,6 +432,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
def main():
|
||||
server = HTTPServer((BIND, PORT), Handler)
|
||||
print(f"panel-api listening on http://{BIND}:{PORT}")
|
||||
print(f"frontend dir: {FRONTEND_DIR}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue