feat: add initial implementation of the frontend panel with app management features

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jakub Dorfman 2026-04-26 23:53:17 +02:00
parent 8cb90be7cd
commit e15298fd3d
5 changed files with 1547 additions and 557 deletions

147
API.md
View file

@ -1,29 +1,48 @@
# panel-api
A tiny local HTTP API wrapper around panelctl for future frontend integration.
HTTP API wrapper around panelctl with a web UI.
The service now also serves a lightweight web UI at `/`.
Default bind:
- 127.0.0.1:9911
Default bind: `127.0.0.1:9911`
## Endpoints
- GET /health
- GET /
- GET /apps
- GET /apps/<name>
- GET /apps/<name>/compose
- POST /apps/init
- POST /apps/<name>/compose
- POST /apps/<name>/render-route
- POST /apps/<name>/deploy
- POST /apps/<name>/stop
- POST /apps/<name>/remove
### Health & UI
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Web UI (served from `frontend/index.html`) |
| GET | `/health` | Health check |
### Apps — Read
| Method | Path | Description |
|--------|------|-------------|
| GET | `/apps` | List all apps |
| GET | `/apps/<name>` | Show single app manifest |
| GET | `/apps/<name>/status` | Container status (running/stopped) |
| GET | `/apps/<name>/compose` | Read compose.yaml content |
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
| GET | `/apps/<name>/backups` | List available backups |
| GET | `/apps/<name>/backups/<file>` | Download backup zip |
### Apps — Write
| Method | Path | Description |
|--------|------|-------------|
| POST | `/apps/init` | Create a new app |
| POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) |
| POST | `/apps/<name>/restart` | Restart (compose down + up) |
| POST | `/apps/<name>/stop` | Stop (compose down) |
| POST | `/apps/<name>/render-route` | Re-render Caddy route |
| POST | `/apps/<name>/compose` | Save compose.yaml content |
| POST | `/apps/<name>/validate-compose` | Validate compose file |
| POST | `/apps/<name>/backup` | Create volume backup (zip) |
| POST | `/apps/<name>/restore` | Restore from backup |
| POST | `/apps/<name>/remove` | Remove app |
## Example payloads
Create app:
### Create app (single domain)
```json
{
@ -34,7 +53,50 @@ Create app:
}
```
Remove and keep volumes:
### Create app (multiple domains)
```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,
"auth": true
}
```
### Create app (wildcard domain)
```json
{
"name": "wildcard",
"domain": "*.srazka.com",
"port": 18082,
"auth": false
}
```
Note: Wildcard domains require DNS challenge configuration in Caddy.
### Save compose
```json
{
"content": "services:\n app:\n image: nginx:latest\n ports:\n - '127.0.0.1:18080:80'\n"
}
```
### Remove and keep volumes
```json
{
@ -42,9 +104,58 @@ Remove and keep volumes:
}
```
### Restore from backup
```json
{
"file": "whoami-20260101-120000.zip"
}
```
## Response format
All JSON responses include an `ok` boolean:
```json
{
"ok": true,
"apps": [...]
}
```
Error responses:
```json
{
"ok": false,
"error": "description",
"stderr": "panelctl error output"
}
```
## Status response
```json
{
"ok": true,
"name": "whoami",
"running": true,
"containers": [
{
"name": "whoami-app-1",
"state": "running",
"image": "docker.io/traefik/whoami:latest"
}
]
}
```
## Local test
```bash
curl -s http://127.0.0.1:9911/health | jq .
curl -s http://127.0.0.1:9911/apps | jq .
curl -s http://127.0.0.1:9911/apps/whoami/status | jq .
curl -s http://127.0.0.1:9911/apps/whoami/logs?tail=50 | jq .
curl -s http://127.0.0.1:9911/apps/whoami/backups | jq .
```

117
README.md
View file

@ -1,39 +1,102 @@
# panelctl quickstart
This repository now includes a small helper command named panelctl for Phase 2.
Minimal container management panel for rootless Podman + Caddy.
Base directory:
- /var/lib/containers
## Base directory
Generated structure:
- /var/lib/containers/stacks/<app>/compose.yaml
- /var/lib/containers/volumes/<app>/data
- /var/lib/containers/routes/<app>.caddy
- /var/lib/containers/state/apps/<app>.env
`/var/lib/containers`
Quick workflow:
1. Create a new app definition:
## Generated structure
```
/var/lib/containers/
├── stacks/<app>/compose.yaml # Compose file per app
├── volumes/<app>/data # Persistent volumes
├── routes/routes.caddy # Single aggregate Caddy routes file
├── backups/<app>-<timestamp>.zip # Volume backups
└── state/apps/<app>.env # App manifest
```
All app routes are written to a single `routes/routes.caddy` file that Caddy imports.
## Quick workflow
```bash
# Create a new app (single domain)
panelctl init whoami whoami.srazka.com 18080 true
2. Deploy it with Podman compose:
# Create with multiple domains
panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true
# Create with wildcard domain (requires DNS challenge in Caddy)
panelctl init wild "*.srazka.com" 18082 false
# Deploy (compose up + caddy reload)
panelctl deploy whoami
3. If deploy says caddy reload needs root:
sudo systemctl reload caddy
4. List apps:
# Check container status
panelctl status whoami
# View logs
panelctl logs whoami --tail 50
# Restart containers
panelctl restart whoami
# Stop containers
panelctl stop whoami
# Validate compose file
panelctl validate-compose whoami
# Backup volumes to zip
panelctl backup whoami
# List backups
panelctl list-backups whoami
# Restore from backup
panelctl restore whoami whoami-20260101-120000.zip
# List all apps
panelctl list
5. Inspect one app:
# Show app manifest
panelctl show whoami
6. Remove app but keep data:
# Remove app (keeps volumes)
panelctl remove whoami --keep-volumes
Notes:
- The default compose file uses traefik/whoami for smoke testing.
- Edit each generated compose.yaml before production use.
- App names must be lowercase slugs.
- If deploy reports XDG_RUNTIME_DIR missing, enable lingering for the runtime user:
`sudo loginctl enable-linger reudy`
# Remove app and all data
panelctl remove whoami
API service:
- Nix now runs panel-api as a systemd service on 127.0.0.1:9911.
- Caddy proxies https://panel.srazka.com to panel-api with your existing forward_auth pattern.
- Open https://panel.srazka.com for the web UI.
- API docs are in panel/API.md.
# If deploy says caddy reload needs root:
sudo systemctl reload caddy
```
## Notes
- The default compose file uses `traefik/whoami` for smoke testing — edit before production use.
- App names must be lowercase slugs (`[a-z0-9-]`).
- Wildcard domains (`*.example.com`) require DNS challenge in Caddy (provider-specific).
- Backups stop containers for consistency, then restart if they were running.
- If deploy reports `XDG_RUNTIME_DIR` missing, enable lingering:
```
sudo loginctl enable-linger reudy
```
## Web UI & API
- Nix runs `panel-api` as a systemd service on `127.0.0.1:9911`.
- Caddy proxies `https://panel.srazka.com` → panel-api with Authelia forward_auth.
- Open `https://panel.srazka.com` for the web UI.
- API docs: [API.md](API.md)
### 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

772
frontend/index.html Normal file
View file

@ -0,0 +1,772 @@
<!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: #1f1f1f18;
--warn: #b91c1c;
--warn-bg: #fef2f2;
--ok: #15803d;
--ok-bg: #f0fdf4;
--muted: #6b7280;
--card-bg: #ffffffee;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; }
body {
font-family: "Space Grotesk", system-ui, 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);
min-height: 100vh;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 24px 20px 48px; }
/* ── Header ── */
.hero {
border: 2px solid var(--ink);
border-radius: var(--radius);
padding: 20px 24px;
background: var(--card-bg);
box-shadow: 6px 6px 0 #0000000d;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.hero h1 { font-size: clamp(1.4rem, 3vw, 2.2rem); letter-spacing: .02em; }
.hero p { opacity: .7; font-size: .95rem; }
.hero-actions { display: flex; gap: 8px; }
/* ── Status bar ── */
.status-bar {
margin-top: 12px;
border: 2px solid var(--ink);
border-radius: var(--radius);
padding: 10px 14px;
background: var(--card-bg);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .85rem;
min-height: 40px;
transition: border-color .2s;
}
.status-bar.ok { border-color: var(--ok); background: var(--ok-bg); }
.status-bar.err { border-color: var(--warn); background: var(--warn-bg); }
/* ── Layout ── */
.main-grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 16px;
margin-top: 16px;
}
@media (max-width: 860px) {
.main-grid { grid-template-columns: 1fr; }
}
/* ── Cards ── */
.card {
border: 2px solid var(--ink);
border-radius: var(--radius);
background: var(--card-bg);
padding: 16px;
}
.card h2 { font-size: 1.05rem; margin-bottom: 12px; }
/* ── Forms ── */
label { display: block; font-size: .85rem; font-weight: 500; margin: 10px 0 4px; color: var(--muted); }
input, select, textarea {
width: 100%;
border: 2px solid var(--ink);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
font-size: .9rem;
background: #fff;
}
textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem;
line-height: 1.45;
resize: vertical;
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* ── Buttons ── */
button, .btn {
border: 2px solid var(--ink);
border-radius: 8px;
padding: 6px 12px;
font: inherit;
font-size: .85rem;
font-weight: 600;
background: #fff;
cursor: pointer;
transition: background .15s, transform .1s;
white-space: nowrap;
}
button:hover { background: #f3f3f3; }
button:active { transform: scale(.97); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent-dark); }
.btn-primary:hover { background: var(--accent-dark); }
.btn-danger { color: var(--warn); border-color: var(--warn); }
.btn-danger:hover { background: var(--warn-bg); }
.btn-sm { padding: 4px 8px; font-size: .8rem; }
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── Domain tags ── */
.domain-tags {
display: flex; flex-wrap: wrap; gap: 6px;
margin-top: 6px; min-height: 32px;
}
.domain-tag {
display: inline-flex; align-items: center; gap: 4px;
background: var(--accent); color: #fff;
border-radius: 6px; padding: 3px 8px;
font-size: .82rem; font-weight: 500;
}
.domain-tag button {
background: none; border: none; color: #fff;
font-size: 1rem; padding: 0 2px; cursor: pointer;
line-height: 1;
}
.domain-input-row { display: flex; gap: 6px; margin-top: 6px; }
.domain-input-row input { flex: 1; }
/* ── App list ── */
.app-list { display: flex; flex-direction: column; gap: 12px; }
.app-card {
border: 2px solid var(--ink);
border-radius: var(--radius);
background: var(--card-bg);
overflow: hidden;
transition: box-shadow .15s;
}
.app-card:hover { box-shadow: 4px 4px 0 #0000000a; }
.app-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
gap: 10px;
}
.app-header:hover { background: #f9f9f7; }
.app-info { flex: 1; min-width: 0; }
.app-name { font-weight: 700; font-size: 1rem; }
.app-meta {
font-size: .82rem; color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.app-status-dot {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0;
}
.dot-running { background: var(--ok); box-shadow: 0 0 6px #15803d88; }
.dot-stopped { background: var(--muted); }
.dot-unknown { background: #d97706; }
.app-actions {
display: flex; gap: 6px; flex-shrink: 0;
}
.app-body {
border-top: 1px solid var(--line);
padding: 14px;
display: none;
}
.app-body.open { display: block; }
/* ── Tabs ── */
.tabs { display: flex; gap: 0; border-bottom: 2px solid var(--line); margin-bottom: 12px; }
.tab {
padding: 6px 14px;
font-size: .85rem;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
color: var(--muted);
background: none; border-left: none; border-right: none; border-top: none;
border-radius: 0;
}
.tab:hover { color: var(--ink); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Logs ── */
.log-output {
background: #1a1a2e;
color: #e0e0e0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .78rem;
line-height: 1.5;
padding: 12px;
border-radius: 8px;
max-height: 320px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* ── Backups ── */
.backup-list { font-size: .85rem; }
.backup-item {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--line);
gap: 8px;
}
.backup-item:last-child { border-bottom: none; }
.backup-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; }
.backup-size { color: var(--muted); font-size: .8rem; }
/* ── Empty state ── */
.empty {
text-align: center;
padding: 32px 16px;
color: var(--muted);
font-size: .95rem;
}
/* ── Animations ── */
.fade-in { animation: appear .25s ease; }
@keyframes appear {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<main class="wrap">
<!-- Header -->
<section class="hero fade-in">
<div>
<h1>Containers Panel</h1>
<p>Rootless Podman + Caddy routes from one place.</p>
</div>
<div class="hero-actions">
<button class="btn-primary" id="refreshBtn">↻ Refresh</button>
</div>
</section>
<!-- Status -->
<div class="status-bar" id="status">Ready.</div>
<!-- Main grid -->
<section class="main-grid">
<!-- Sidebar: Create -->
<div>
<div class="card fade-in">
<h2>Create App</h2>
<label for="name">Name</label>
<input id="name" placeholder="whoami" autocomplete="off" />
<label>Domains</label>
<div class="domain-tags" id="domainTags"></div>
<div class="domain-input-row">
<input id="domainInput" placeholder="whoami.srazka.com" />
<button class="btn-sm" id="addDomainBtn">Add</button>
</div>
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
Supports wildcards: <code>*.example.com</code>
</p>
<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="btn-group" style="margin-top:14px;">
<button class="btn-primary" id="createBtn">Create App</button>
</div>
</div>
</div>
<!-- Main: App list -->
<div>
<div class="app-list" id="appList">
<div class="empty">Loading apps...</div>
</div>
</div>
</section>
</main>
<script>
// ─── API Client ───
const api = {
async request(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;
},
getApps: () => api.request("/apps"),
getApp: (n) => api.request(`/apps/${n}`),
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`),
getBackups: (n) => api.request(`/apps/${n}/backups`),
init: (p) => api.request("/apps/init", "POST", p),
deploy: (n) => api.request(`/apps/${n}/deploy`, "POST"),
restart: (n) => api.request(`/apps/${n}/restart`, "POST"),
stop: (n) => api.request(`/apps/${n}/stop`, "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"),
backup: (n) => api.request(`/apps/${n}/backup`, "POST"),
restore: (n, f) => api.request(`/apps/${n}/restore`, "POST", { file: f }),
renderRoute: (n) => api.request(`/apps/${n}/render-route`, "POST"),
};
// ─── State ───
let apps = [];
let expandedApp = null;
let activeTab = {}; // { appName: tabName }
// ─── Status bar ───
const statusEl = document.getElementById("status");
function setStatus(msg, isError = false) {
statusEl.textContent = msg;
statusEl.className = "status-bar" + (isError ? " err" : msg !== "Ready." ? " ok" : "");
}
// ─── Domain tag input ───
const domainTags = [];
const domainTagsEl = document.getElementById("domainTags");
const domainInput = document.getElementById("domainInput");
function renderDomainTags() {
domainTagsEl.innerHTML = "";
domainTags.forEach((d, i) => {
const tag = document.createElement("span");
tag.className = "domain-tag";
tag.innerHTML = `${escHtml(d)} <button data-i="${i}">&times;</button>`;
tag.querySelector("button").onclick = () => { domainTags.splice(i, 1); renderDomainTags(); };
domainTagsEl.appendChild(tag);
});
}
document.getElementById("addDomainBtn").onclick = () => {
const v = domainInput.value.trim();
if (v && !domainTags.includes(v)) {
domainTags.push(v);
domainInput.value = "";
renderDomainTags();
}
};
domainInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); }
});
// ─── Create app ───
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];
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; }
try {
setStatus(`Creating ${name}...`);
await api.init({ name, domain: domains.join(","), port, auth });
setStatus(`Created ${name}.`);
document.getElementById("name").value = "";
domainTags.length = 0;
renderDomainTags();
await loadApps();
} catch (err) {
setStatus(`Create failed: ${err.message}`, true);
}
};
// ─── Refresh ───
document.getElementById("refreshBtn").onclick = () => loadApps();
// ─── Helpers ───
function escHtml(s) {
const d = document.createElement("div");
d.textContent = 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);
const isExpanded = expandedApp === app.name;
card.innerHTML = `
<div class="app-header" data-toggle="${app.name}">
<div style="display:flex;align-items:center;gap:10px;">
<span class="app-status-dot dot-unknown" id="dot-${app.name}"></span>
<div class="app-info">
<div class="app-name">${escHtml(app.name)}</div>
<div class="app-meta">${domains.map(d => escHtml(d)).join(", ")} → ${escHtml(app.upstream)} ${app.auth === "true" ? "🔒" : ""}</div>
</div>
</div>
<div class="app-actions">
<button class="btn-sm btn-primary" data-action="deploy" data-app="${app.name}">Deploy</button>
<button class="btn-sm" data-action="restart" data-app="${app.name}">Restart</button>
<button class="btn-sm" data-action="stop" data-app="${app.name}">Stop</button>
<button class="btn-sm btn-danger" data-action="remove" data-app="${app.name}">Remove</button>
</div>
</div>
<div class="app-body ${isExpanded ? "open" : ""}" id="body-${app.name}">
<div class="tabs">
<button class="tab active" data-tab="compose" data-app="${app.name}">Compose</button>
<button class="tab" data-tab="logs" data-app="${app.name}">Logs</button>
<button class="tab" data-tab="backups" data-app="${app.name}">Backups</button>
</div>
<div class="tab-panel active" id="tab-compose-${app.name}">
<textarea id="compose-${app.name}" rows="12" placeholder="Loading..."></textarea>
<div class="btn-group" style="margin-top:8px;">
<button class="btn-sm btn-primary" data-action="save-compose" data-app="${app.name}">Save</button>
<button class="btn-sm" data-action="validate-compose" data-app="${app.name}">Validate</button>
<button class="btn-sm" data-action="save-deploy" data-app="${app.name}">Save &amp; Deploy</button>
</div>
</div>
<div class="tab-panel" id="tab-logs-${app.name}">
<div class="btn-group" style="margin-bottom:8px;">
<button class="btn-sm" data-action="refresh-logs" data-app="${app.name}">Refresh Logs</button>
<select id="log-tail-${app.name}" style="width:auto;padding:4px 8px;font-size:.82rem;">
<option value="50">50 lines</option>
<option value="100" selected>100 lines</option>
<option value="300">300 lines</option>
<option value="1000">1000 lines</option>
</select>
</div>
<div class="log-output" id="logs-${app.name}">Click "Refresh Logs" to load.</div>
</div>
<div class="tab-panel" id="tab-backups-${app.name}">
<div class="btn-group" style="margin-bottom:10px;">
<button class="btn-sm btn-primary" data-action="create-backup" data-app="${app.name}">Create Backup</button>
<button class="btn-sm" data-action="refresh-backups" data-app="${app.name}">Refresh</button>
</div>
<div class="backup-list" id="backups-${app.name}">
<div style="color:var(--muted);font-size:.85rem;">Click "Refresh" to load backups.</div>
</div>
</div>
</div>
`;
return card;
}
function renderApps() {
const list = document.getElementById("appList");
list.innerHTML = "";
if (!apps.length) {
list.innerHTML = '<div class="empty">No apps yet. Create one to get started.</div>';
return;
}
apps.forEach(app => list.appendChild(renderAppCard(app)));
attachCardListeners();
// Fetch status for each app
apps.forEach(app => fetchStatus(app.name));
// Load compose for expanded app
if (expandedApp) {
loadCompose(expandedApp);
}
}
function attachCardListeners() {
// Toggle expand
document.querySelectorAll("[data-toggle]").forEach(el => {
el.onclick = (e) => {
if (e.target.closest("button[data-action]")) return;
const name = el.dataset.toggle;
expandedApp = expandedApp === name ? null : name;
document.querySelectorAll(".app-body").forEach(b => b.classList.remove("open"));
if (expandedApp) {
document.getElementById(`body-${name}`).classList.add("open");
loadCompose(name);
}
};
});
// Tabs
document.querySelectorAll(".tab").forEach(tab => {
tab.onclick = () => {
const app = tab.dataset.app;
const tabName = tab.dataset.tab;
activeTab[app] = tabName;
// Update tab buttons
tab.closest(".tabs").querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
// Update panels
document.getElementById(`tab-compose-${app}`).classList.toggle("active", tabName === "compose");
document.getElementById(`tab-logs-${app}`).classList.toggle("active", tabName === "logs");
document.getElementById(`tab-backups-${app}`).classList.toggle("active", tabName === "backups");
if (tabName === "logs") loadLogs(app);
if (tabName === "backups") loadBackups(app);
};
});
// Action buttons
document.querySelectorAll("[data-action]").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
handleAction(btn.dataset.action, btn.dataset.app);
};
});
}
// ─── Actions ───
async function handleAction(action, name) {
try {
switch (action) {
case "deploy":
setStatus(`Deploying ${name}...`);
await api.deploy(name);
setStatus(`Deployed ${name}.`);
await loadApps();
break;
case "restart":
setStatus(`Restarting ${name}...`);
await api.restart(name);
setStatus(`Restarted ${name}.`);
await loadApps();
break;
case "stop":
setStatus(`Stopping ${name}...`);
await api.stop(name);
setStatus(`Stopped ${name}.`);
await loadApps();
break;
case "remove": {
const keep = confirm("Keep volumes? OK = keep, Cancel = delete everything.");
setStatus(`Removing ${name}...`);
await api.remove(name, keep);
setStatus(`Removed ${name}.`);
if (expandedApp === name) expandedApp = null;
await loadApps();
break;
}
case "save-compose":
await saveCompose(name);
break;
case "validate-compose":
await validateCompose(name);
break;
case "save-deploy":
await saveCompose(name);
setStatus(`Deploying ${name}...`);
await api.deploy(name);
setStatus(`Saved & deployed ${name}.`);
await loadApps();
break;
case "refresh-logs":
await loadLogs(name);
break;
case "create-backup":
setStatus(`Backing up ${name}...`);
await api.backup(name);
setStatus(`Backup created for ${name}.`);
await loadBackups(name);
break;
case "refresh-backups":
await loadBackups(name);
break;
case "render-route":
setStatus(`Rendering route for ${name}...`);
await api.renderRoute(name);
setStatus(`Route rendered for ${name}.`);
break;
}
} catch (err) {
setStatus(`${action} failed for ${name}: ${err.message}`, true);
}
}
// ─── Data loaders ───
async function loadApps() {
try {
const data = await api.getApps();
apps = data.apps || [];
renderApps();
setStatus(`Loaded ${apps.length} app(s).`);
} catch (err) {
setStatus(`Failed to load apps: ${err.message}`, true);
}
}
async function fetchStatus(name) {
const dot = document.getElementById(`dot-${name}`);
if (!dot) return;
try {
const data = await api.getStatus(name);
const running = data.running || false;
dot.className = "app-status-dot " + (running ? "dot-running" : "dot-stopped");
dot.title = running ? "Running" : "Stopped";
} catch {
dot.className = "app-status-dot dot-unknown";
dot.title = "Unknown";
}
}
async function loadCompose(name) {
const el = document.getElementById(`compose-${name}`);
if (!el) return;
try {
const data = await api.getCompose(name);
el.value = data.content || "";
} catch (err) {
el.value = `# Failed to load: ${err.message}`;
}
}
async function saveCompose(name) {
const el = document.getElementById(`compose-${name}`);
if (!el) return;
try {
setStatus(`Saving compose for ${name}...`);
await api.saveCompose(name, el.value);
setStatus(`Compose saved for ${name}.`);
} catch (err) {
setStatus(`Save failed: ${err.message}`, true);
}
}
async function validateCompose(name) {
try {
setStatus(`Validating compose for ${name}...`);
const data = await api.validateCompose(name);
setStatus(data.stdout || `Compose is valid for ${name}.`);
} catch (err) {
setStatus(`Validation failed: ${err.message}`, true);
}
}
async function loadLogs(name) {
const el = document.getElementById(`logs-${name}`);
const tailSel = document.getElementById(`log-tail-${name}`);
if (!el) return;
const tail = tailSel ? tailSel.value : "100";
try {
el.textContent = "Loading logs...";
const data = await api.getLogs(name, tail);
el.textContent = data.logs || data.stdout || "No logs available.";
el.scrollTop = el.scrollHeight;
} catch (err) {
el.textContent = `Failed to load logs: ${err.message}`;
}
}
async function loadBackups(name) {
const el = document.getElementById(`backups-${name}`);
if (!el) return;
try {
const data = await api.getBackups(name);
const backups = data.backups || [];
if (!backups.length) {
el.innerHTML = '<div style="color:var(--muted);font-size:.85rem;">No backups yet.</div>';
return;
}
el.innerHTML = "";
backups.forEach(b => {
const item = document.createElement("div");
item.className = "backup-item";
const date = b.mtime ? new Date(b.mtime * 1000).toLocaleString() : "";
item.innerHTML = `
<div>
<div class="backup-name">${escHtml(b.name)}</div>
<div class="backup-size">${escHtml(b.size)} · ${escHtml(date)}</div>
</div>
<div class="btn-group">
<a class="btn btn-sm" href="/apps/${name}/backups/${encodeURIComponent(b.name)}" download>Download</a>
<button class="btn-sm btn-danger" data-restore="${name}" data-file="${escHtml(b.name)}">Restore</button>
</div>
`;
item.querySelector("[data-restore]").onclick = async () => {
if (!confirm(`Restore ${name} from ${b.name}? This will stop the app and overwrite volumes.`)) return;
try {
setStatus(`Restoring ${name}...`);
await api.restore(name, b.name);
setStatus(`Restored ${name} from ${b.name}. Deploy to start.`);
} catch (err) {
setStatus(`Restore failed: ${err.message}`, true);
}
};
el.appendChild(item);
});
} catch (err) {
el.innerHTML = `<div style="color:var(--warn);font-size:.85rem;">Failed: ${err.message}</div>`;
}
}
// ─── Init ───
loadApps();
// Auto-refresh every 15s
setInterval(() => {
apps.forEach(app => fetchStatus(app.name));
}, 15000);
</script>
</body>
</html>

View file

@ -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,123 +194,226 @@ 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"}:
# 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
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 = {}
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":
payload = {}
if not is_safe_name(name):
self._json(400, {"ok": False, "error": "invalid app name"})
return
try:
payload = self._read_json()
except Exception:
@ -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()

View file

@ -7,6 +7,7 @@ VOLUMES_DIR="${BASE_DIR}/volumes"
ROUTES_DIR="${BASE_DIR}/routes"
STATE_DIR="${BASE_DIR}/state"
APPS_DIR="${STATE_DIR}/apps"
BACKUPS_DIR="${BASE_DIR}/backups"
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth
@ -19,17 +20,36 @@ usage() {
panelctl - minimal app panel helper
Usage:
panelctl init <name> <domain> <port> [auth]
panelctl init <name> <domains> <port> [auth]
panelctl render-route <name>
panelctl deploy <name>
panelctl restart <name>
panelctl stop <name>
panelctl status <name>
panelctl logs <name> [--tail N]
panelctl remove <name> [--keep-volumes]
panelctl backup <name>
panelctl list-backups <name>
panelctl restore <name> <backup-file>
panelctl validate-compose <name>
panelctl list
panelctl show <name>
Domains can be comma-separated for multiple domains:
panelctl init myapp "app.example.com,www.example.com" 18080 true
Wildcard domains are supported (requires DNS challenge in Caddy):
panelctl init myapp "*.example.com" 18080 true
Examples:
panelctl init whoami whoami.srazka.com 18080 true
panelctl deploy whoami
panelctl restart whoami
panelctl status whoami
panelctl logs whoami --tail 50
panelctl backup whoami
panelctl list-backups whoami
panelctl restore whoami whoami-20260101-120000.zip
EOF
}
@ -39,7 +59,7 @@ fail() {
}
ensure_base_dirs() {
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}"
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
}
validate_name() {
@ -47,10 +67,25 @@ validate_name() {
[[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)"
}
validate_domain() {
validate_single_domain() {
local domain="$1"
[[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'"
[[ "${domain}" == *.* ]] || fail "domain must include a dot"
# Allow wildcard prefix *.
local check="${domain}"
if [[ "${check}" == \*.* ]]; then
check="${check#\*.}"
fi
[[ "${check}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || fail "invalid domain '${domain}'"
[[ "${domain}" == *.* ]] || fail "domain '${domain}' must include a dot"
}
validate_domains() {
local domains_str="$1"
IFS=',' read -ra domains <<< "${domains_str}"
[[ ${#domains[@]} -ge 1 ]] || fail "at least one domain is required"
for d in "${domains[@]}"; do
d="$(echo "${d}" | xargs)" # trim whitespace
validate_single_domain "${d}"
done
}
validate_port() {
@ -74,8 +109,8 @@ app_volume_dir() {
echo "${VOLUMES_DIR}/${name}"
}
# All routes go into a single aggregate file that Caddy imports.
app_route_file() {
local name="$1"
echo "${ROUTES_DIR}/routes.caddy"
}
@ -140,7 +175,6 @@ ensure_podman_runtime_env() {
export DBUS_SESSION_BUS_ADDRESS
fi
# Avoid inherited docker/podman remote host env from external callers.
unset DOCKER_HOST
unset CONTAINER_HOST
}
@ -182,7 +216,7 @@ EOF
write_manifest() {
local name="$1"
local domain="$2"
local domains="$2"
local port="$3"
local auth="$4"
local manifest
@ -193,11 +227,17 @@ write_manifest() {
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
route_file="$(app_route_file "${name}")"
route_file="$(app_route_file)"
# First domain is the primary (used for APP_DOMAIN backward compat)
local primary_domain
IFS=',' read -ra domain_arr <<< "${domains}"
primary_domain="$(echo "${domain_arr[0]}" | xargs)"
cat >"${manifest}" <<EOF
APP_NAME="${name}"
APP_DOMAIN="${domain}"
APP_DOMAIN="${primary_domain}"
APP_DOMAINS="${domains}"
APP_PORT="${port}"
APP_UPSTREAM="127.0.0.1:${port}"
APP_AUTH_PROTECTED="${auth}"
@ -210,12 +250,12 @@ EOF
cmd_init() {
local name="$1"
local domain="$2"
local domains="$2"
local port="$3"
local auth="${4:-true}"
validate_name "${name}"
validate_domain "${domain}"
validate_domains "${domains}"
validate_port "${port}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
@ -232,7 +272,7 @@ cmd_init() {
mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${port}"
write_manifest "${name}" "${domain}" "${port}" "${auth}"
write_manifest "${name}" "${domains}" "${port}" "${auth}"
cmd_render_route "${name}"
echo "initialized app '${name}'"
@ -248,21 +288,35 @@ cmd_render_route() {
auth_block="${FORWARD_AUTH_BLOCK}"
fi
# Append to aggregate routes file (idempotent: remove stale block first).
# Build domain list for Caddy block header.
local caddy_domains=""
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 "${caddy_domains}" ]]; then
caddy_domains="${caddy_domains}, ${d}"
else
caddy_domains="${d}"
fi
done
local route_file
route_file="$(app_route_file)"
# Strip any existing block for this app from the aggregate file.
local tmp
tmp="$(mktemp)"
# Strip any existing block for this app from the aggregate file.
if [[ -f "${APP_ROUTE_FILE}" ]]; then
# Use sed to remove the block between # route:name:start and # route:name:end
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true
if [[ -f "${route_file}" ]]; then
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
else
printf "" >"${tmp}"
fi
{
printf "# route:%s:start\n" "${name}"
printf "%s {\n" "${APP_DOMAIN}"
printf "%s {\n" "${caddy_domains}"
if [[ -n "${auth_block}" ]]; then
printf "%s\n" "${auth_block}"
fi
@ -271,8 +325,8 @@ cmd_render_route() {
printf "# route:%s:end\n" "${name}"
} >>"${tmp}"
mv "${tmp}" "${APP_ROUTE_FILE}"
echo "rendered route ${APP_ROUTE_FILE}"
mv "${tmp}" "${route_file}"
echo "rendered route ${route_file}"
}
maybe_reload_caddy() {
@ -300,21 +354,37 @@ cmd_deploy() {
validate_name "${name}"
load_app "${name}"
echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info
echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
cmd_render_route "${name}"
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info; then
echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then
echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err 2>/dev/null || true
fail "compose up failed"
fi
maybe_reload_caddy
echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info
echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
echo "deployed app '${name}'"
}
cmd_restart() {
local name="$1"
validate_name "${name}"
load_app "${name}"
echo "Restarting app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
run_compose -f "${APP_COMPOSE_FILE}" down || fail "compose down failed"
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1; then
fail "compose up failed during restart"
fi
echo "restarted app '${name}'"
}
cmd_stop() {
local name="$1"
validate_name "${name}"
@ -324,6 +394,52 @@ cmd_stop() {
echo "stopped app '${name}'"
}
cmd_status() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \
echo "no containers running"
}
cmd_logs() {
local name="$1"
shift
validate_name "${name}"
load_app "${name}"
local tail_lines="100"
while [[ $# -gt 0 ]]; do
case "$1" in
--tail)
tail_lines="$2"
shift 2
;;
*)
shift
;;
esac
done
run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || echo "no logs available"
}
cmd_validate_compose() {
local name="$1"
validate_name "${name}"
load_app "${name}"
if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then
echo "compose file is valid"
else
local output
output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)"
fail "compose validation failed: ${output}"
fi
}
cmd_remove() {
local name="$1"
local keep_volumes="${2:-}"
@ -333,11 +449,13 @@ cmd_remove() {
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
# Remove this app's block from the aggregate routes file.
if [[ -f "${APP_ROUTE_FILE}" ]]; then
local route_file
route_file="$(app_route_file)"
if [[ -f "${route_file}" ]]; then
local tmp
tmp="$(mktemp)"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true
mv "${tmp}" "${APP_ROUTE_FILE}"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
mv "${tmp}" "${route_file}"
fi
rm -f "$(app_manifest "${name}")"
@ -351,6 +469,107 @@ cmd_remove() {
echo "removed app '${name}'"
}
cmd_backup() {
local name="$1"
validate_name "${name}"
load_app "${name}"
ensure_base_dirs
local volume_dir
volume_dir="$(app_volume_dir "${name}")"
[[ -d "${volume_dir}" ]] || fail "volume directory '${volume_dir}' does not exist"
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
local backup_file="${BACKUPS_DIR}/${name}-${timestamp}.zip"
# Stop containers before backup for consistency
local was_running=false
if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
was_running=true
echo "stopping containers for consistent backup..."
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
fi
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
# Also include the compose file in the backup
local stack_dir
stack_dir="$(app_stack_dir "${name}")"
if [[ -f "${stack_dir}/compose.yaml" ]]; then
(cd "${stack_dir}" && zip -j "${backup_file}" compose.yaml) || true
fi
# Restart if it was running
if [[ "${was_running}" == "true" ]]; then
echo "restarting containers after backup..."
run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true
fi
local size
size="$(du -h "${backup_file}" | cut -f1)"
echo "backup created: ${backup_file} (${size})"
}
cmd_list_backups() {
local name="$1"
validate_name "${name}"
ensure_base_dirs
local found=0
for bf in "${BACKUPS_DIR}/${name}"-*.zip; do
[[ -e "${bf}" ]] || continue
found=1
local fname size mtime
fname="$(basename "${bf}")"
size="$(du -h "${bf}" | cut -f1)"
mtime="$(stat -c '%Y' "${bf}" 2>/dev/null || stat -f '%m' "${bf}" 2>/dev/null || echo "0")"
echo "${fname} ${size} ${mtime}"
done
if [[ "${found}" -eq 0 ]]; then
echo "no backups found for '${name}'"
fi
}
cmd_restore() {
local name="$1"
local backup_file="$2"
validate_name "${name}"
load_app "${name}"
# Resolve backup file path
local full_path="${backup_file}"
if [[ ! -f "${full_path}" ]]; then
full_path="${BACKUPS_DIR}/${backup_file}"
fi
[[ -f "${full_path}" ]] || fail "backup file '${backup_file}' not found"
# Ensure it's a zip file within the backups directory
local norm_path
norm_path="$(realpath "${full_path}")"
local norm_backups
norm_backups="$(realpath "${BACKUPS_DIR}")"
[[ "${norm_path}" == "${norm_backups}"/* ]] || fail "backup file must be in the backups directory"
local volume_dir
volume_dir="$(app_volume_dir "${name}")"
# Stop containers before restore
echo "stopping containers for restore..."
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
# Clear existing volume data and extract backup
rm -rf "${volume_dir:?}"/*
mkdir -p "${volume_dir}"
(cd "${volume_dir}" && unzip -o "${norm_path}") || fail "unzip failed"
echo "restored '${name}' from $(basename "${norm_path}")"
echo "run 'panelctl deploy ${name}' to start the app"
}
cmd_list() {
ensure_base_dirs
local found=0
@ -359,7 +578,8 @@ cmd_list() {
found=1
# shellcheck disable=SC1090
source "${mf}"
echo "${APP_NAME} ${APP_DOMAIN} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
local domains="${APP_DOMAINS:-${APP_DOMAIN}}"
echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
done
if [[ "${found}" -eq 0 ]]; then
@ -381,7 +601,7 @@ main() {
case "${cmd}" in
init)
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domain> <port> [auth]"
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domains> <port> [auth]"
cmd_init "$2" "$3" "$4" "${5:-true}"
;;
render-route)
@ -392,14 +612,42 @@ main() {
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
cmd_deploy "$2"
;;
restart)
[[ $# -eq 2 ]] || fail "usage: panelctl restart <name>"
cmd_restart "$2"
;;
stop)
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
cmd_stop "$2"
;;
status)
[[ $# -eq 2 ]] || fail "usage: panelctl status <name>"
cmd_status "$2"
;;
logs)
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N]"
cmd_logs "$2" "${@:3}"
;;
validate-compose)
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
cmd_validate_compose "$2"
;;
remove)
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
cmd_remove "$2" "${3:-}"
;;
backup)
[[ $# -eq 2 ]] || fail "usage: panelctl backup <name>"
cmd_backup "$2"
;;
list-backups)
[[ $# -eq 2 ]] || fail "usage: panelctl list-backups <name>"
cmd_list_backups "$2"
;;
restore)
[[ $# -eq 3 ]] || fail "usage: panelctl restore <name> <backup-file>"
cmd_restore "$2" "$3"
;;
list)
[[ $# -eq 1 ]] || fail "usage: panelctl list"
cmd_list