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 # 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 ## Endpoints
- GET /health ### Health & UI
- GET /
- GET /apps | Method | Path | Description |
- GET /apps/<name> |--------|------|-------------|
- GET /apps/<name>/compose | GET | `/` | Web UI (served from `frontend/index.html`) |
- POST /apps/init | GET | `/health` | Health check |
- POST /apps/<name>/compose
- POST /apps/<name>/render-route ### Apps — Read
- POST /apps/<name>/deploy
- POST /apps/<name>/stop | Method | Path | Description |
- POST /apps/<name>/remove |--------|------|-------------|
| 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 ## Example payloads
Create app: ### Create app (single domain)
```json ```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 ```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 ## Local test
```bash ```bash
curl -s http://127.0.0.1:9911/health | jq . 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 | 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 .
``` ```

127
README.md
View file

@ -1,39 +1,102 @@
# panelctl quickstart # 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: ## Base directory
- /var/lib/containers
Generated structure: `/var/lib/containers`
- /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
Quick workflow: ## Generated structure
1. Create a new app definition:
panelctl init whoami whoami.srazka.com 18080 true
2. Deploy it with Podman compose:
panelctl deploy whoami
3. If deploy says caddy reload needs root:
sudo systemctl reload caddy
4. List apps:
panelctl list
5. Inspect one app:
panelctl show whoami
6. Remove app but keep data:
panelctl remove whoami --keep-volumes
Notes: ```
- The default compose file uses traefik/whoami for smoke testing. /var/lib/containers/
- Edit each generated compose.yaml before production use. ├── stacks/<app>/compose.yaml # Compose file per app
- App names must be lowercase slugs. ├── volumes/<app>/data # Persistent volumes
- If deploy reports XDG_RUNTIME_DIR missing, enable lingering for the runtime user: ├── routes/routes.caddy # Single aggregate Caddy routes file
`sudo loginctl enable-linger reudy` ├── backups/<app>-<timestamp>.zip # Volume backups
└── state/apps/<app>.env # App manifest
```
API service: All app routes are written to a single `routes/routes.caddy` file that Caddy imports.
- 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. ## Quick workflow
- Open https://panel.srazka.com for the web UI.
- API docs are in panel/API.md. ```bash
# Create a new app (single domain)
panelctl init whoami whoami.srazka.com 18080 true
# 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
# 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
# Show app manifest
panelctl show whoami
# Remove app (keeps volumes)
panelctl remove whoami --keep-volumes
# Remove app and all data
panelctl remove whoami
# 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 #!/usr/bin/env python3
"""panel-api — HTTP wrapper around panelctl with a web UI."""
import json import json
import os import os
import re import re
import subprocess import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer 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") PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
PORT = int(os.environ.get("PANEL_API_PORT", "9911")) PORT = int(os.environ.get("PANEL_API_PORT", "9911"))
BASE_DIR = os.environ.get("PANEL_BASE_DIR", "/var/lib/containers") BASE_DIR = os.environ.get("PANEL_BASE_DIR", "/var/lib/containers")
FRONTEND_DIR = os.environ.get(
INDEX_HTML = """<!doctype html> "PANEL_FRONTEND_DIR",
<html lang="en"> os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
<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>
"""
def is_safe_name(name): def is_safe_name(name):
return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None 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): def run_panelctl(args):
proc = subprocess.run( proc = subprocess.run(
[PANELCTL, *args], [PANELCTL, *args],
@ -458,6 +48,81 @@ def parse_env_blob(blob):
return out 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): class Handler(BaseHTTPRequestHandler):
def _html(self, code, body): def _html(self, code, body):
payload = body.encode("utf-8") payload = body.encode("utf-8")
@ -475,6 +140,18 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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): def _read_json(self):
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
if length == 0: if length == 0:
@ -483,14 +160,29 @@ class Handler(BaseHTTPRequestHandler):
return json.loads(raw.decode("utf-8")) return json.loads(raw.decode("utf-8"))
def log_message(self, fmt, *args): 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): def do_GET(self):
parsed = urlparse(self.path) path, parts, query = self._parse_path()
path = parsed.path
if 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 return
if path == "/health": if path == "/health":
@ -502,134 +194,237 @@ class Handler(BaseHTTPRequestHandler):
if not result["ok"]: if not result["ok"]:
self._json(500, result) self._json(500, result)
return return
apps = [] apps = []
for line in result["stdout"].splitlines(): for line in result["stdout"].splitlines():
if not line.strip() or line.strip() == "no apps found": if not line.strip() or line.strip() == "no apps found":
continue continue
# format: name domain upstream auth=true|false
fields = line.split() fields = line.split()
if len(fields) < 4: if len(fields) < 4:
continue continue
app = { apps.append({
"name": fields[0], "name": fields[0],
"domain": fields[1], "domain": fields[1].split(",")[0],
"domains": fields[1],
"upstream": fields[2], "upstream": fields[2],
"auth": fields[3].replace("auth=", ""), "auth": fields[3].replace("auth=", ""),
} })
apps.append(app)
self._json(200, {"ok": True, "apps": apps}) self._json(200, {"ok": True, "apps": apps})
return return
if path.startswith("/apps/") and path.endswith("/compose"): # /apps/<name>/compose
parts = [p for p in path.split("/") if p] if len(parts) == 3 and parts[0] == "apps" and parts[2] == "compose":
if len(parts) != 3: name = parts[1]
self._json(404, {"ok": False, "error": "not found"})
return
_, name, _ = parts
app, err = read_app_info(name) app, err = read_app_info(name)
if err is not None: if err is not None:
self._json(404, err) self._json(404, err)
return return
try: try:
with open(app["APP_COMPOSE_FILE"], "r", encoding="utf-8") as fh: with open(app["APP_COMPOSE_FILE"], "r", encoding="utf-8") as fh:
content = fh.read() content = fh.read()
except OSError as exc: 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 return
self._json(200, {"ok": True, "name": name, "content": content}) self._json(200, {"ok": True, "name": name, "content": content})
return return
if path.startswith("/apps/"): # /apps/<name>/status
name = path.split("/")[-1] if len(parts) == 3 and parts[0] == "apps" and parts[2] == "status":
if not name: name = parts[1]
self._json(400, {"ok": False, "error": "missing app name"}) if not is_safe_name(name):
self._json(400, {"ok": False, "error": "invalid app name"})
return 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]) result = run_panelctl(["show", name])
if not result["ok"]: if not result["ok"]:
self._json(404, result) self._json(404, result)
return return
self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])}) self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])})
return return
self._json(404, {"ok": False, "error": "not found"}) self._json(404, {"ok": False, "error": "not found"})
def do_POST(self): # ── POST ──
parsed = urlparse(self.path)
path = parsed.path
def do_POST(self):
path, parts, query = self._parse_path()
# POST /apps/init
if path == "/apps/init": if path == "/apps/init":
try: try:
payload = self._read_json() payload = self._read_json()
name = payload["name"] 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"]) port = str(payload["port"])
auth = str(payload.get("auth", True)).lower() auth = str(payload.get("auth", True)).lower()
except Exception as exc: except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return return
result = run_panelctl(["init", name, domain, port, auth]) result = run_panelctl(["init", name, domain, port, auth])
self._json(200 if result["ok"] else 400, result) self._json(200 if result["ok"] else 400, result)
return return
action_prefix = "/apps/" if len(parts) >= 3 and parts[0] == "apps":
if path.startswith(action_prefix): name = parts[1]
parts = [p for p in path.split("/") if p] action = parts[2]
if len(parts) == 3 and parts[2] == "compose":
_, name, _ = parts # POST /apps/<name>/compose — save compose file
if action == "compose":
app, err = read_app_info(name) app, err = read_app_info(name)
if err is not None: if err is not None:
self._json(404, err) self._json(404, err)
return return
try: try:
payload = self._read_json() payload = self._read_json()
except Exception as exc: except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return return
content = payload.get("content", "") content = payload.get("content", "")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():
self._json(400, {"ok": False, "error": "compose content must be a non-empty string"}) self._json(400, {"ok": False, "error": "compose content must be a non-empty string"})
return return
try: try:
with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh: with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh:
fh.write(content) fh.write(content)
except OSError as exc: 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 return
self._json(200, {"ok": True, "name": name, "saved": True}) self._json(200, {"ok": True, "name": name, "saved": True})
return return
# /apps/<name>/<action> # POST /apps/<name>/validate-compose
if len(parts) == 3: if action == "validate-compose":
_, name, action = parts if not is_safe_name(name):
if action in {"deploy", "stop", "render-route"}: self._json(400, {"ok": False, "error": "invalid app name"})
result = run_panelctl([action, name])
self._json(200 if result["ok"] else 400, result)
return 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 = {} payload = {}
try: backup_file = payload.get("file", "")
payload = self._read_json() if not backup_file:
except Exception: self._json(400, {"ok": False, "error": "backup file name is required"})
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 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"}) self._json(404, {"ok": False, "error": "not found"})
@ -637,6 +432,7 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
server = HTTPServer((BIND, PORT), Handler) server = HTTPServer((BIND, PORT), Handler)
print(f"panel-api listening on http://{BIND}:{PORT}") print(f"panel-api listening on http://{BIND}:{PORT}")
print(f"frontend dir: {FRONTEND_DIR}")
server.serve_forever() server.serve_forever()

View file

@ -7,6 +7,7 @@ VOLUMES_DIR="${BASE_DIR}/volumes"
ROUTES_DIR="${BASE_DIR}/routes" ROUTES_DIR="${BASE_DIR}/routes"
STATE_DIR="${BASE_DIR}/state" STATE_DIR="${BASE_DIR}/state"
APPS_DIR="${STATE_DIR}/apps" APPS_DIR="${STATE_DIR}/apps"
BACKUPS_DIR="${BASE_DIR}/backups"
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
@ -19,17 +20,36 @@ usage() {
panelctl - minimal app panel helper panelctl - minimal app panel helper
Usage: Usage:
panelctl init <name> <domain> <port> [auth] panelctl init <name> <domains> <port> [auth]
panelctl render-route <name> panelctl render-route <name>
panelctl deploy <name> panelctl deploy <name>
panelctl restart <name>
panelctl stop <name> panelctl stop <name>
panelctl status <name>
panelctl logs <name> [--tail N]
panelctl remove <name> [--keep-volumes] panelctl remove <name> [--keep-volumes]
panelctl backup <name>
panelctl list-backups <name>
panelctl restore <name> <backup-file>
panelctl validate-compose <name>
panelctl list panelctl list
panelctl show <name> 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: Examples:
panelctl init whoami whoami.srazka.com 18080 true panelctl init whoami whoami.srazka.com 18080 true
panelctl deploy whoami 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 EOF
} }
@ -39,7 +59,7 @@ fail() {
} }
ensure_base_dirs() { 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() { 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)" [[ "${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" local domain="$1"
[[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'" # Allow wildcard prefix *.
[[ "${domain}" == *.* ]] || fail "domain must include a dot" 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() { validate_port() {
@ -74,8 +109,8 @@ app_volume_dir() {
echo "${VOLUMES_DIR}/${name}" echo "${VOLUMES_DIR}/${name}"
} }
# All routes go into a single aggregate file that Caddy imports.
app_route_file() { app_route_file() {
local name="$1"
echo "${ROUTES_DIR}/routes.caddy" echo "${ROUTES_DIR}/routes.caddy"
} }
@ -140,7 +175,6 @@ ensure_podman_runtime_env() {
export DBUS_SESSION_BUS_ADDRESS export DBUS_SESSION_BUS_ADDRESS
fi fi
# Avoid inherited docker/podman remote host env from external callers.
unset DOCKER_HOST unset DOCKER_HOST
unset CONTAINER_HOST unset CONTAINER_HOST
} }
@ -182,7 +216,7 @@ EOF
write_manifest() { write_manifest() {
local name="$1" local name="$1"
local domain="$2" local domains="$2"
local port="$3" local port="$3"
local auth="$4" local auth="$4"
local manifest local manifest
@ -193,11 +227,17 @@ write_manifest() {
manifest="$(app_manifest "${name}")" manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")" stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_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 cat >"${manifest}" <<EOF
APP_NAME="${name}" APP_NAME="${name}"
APP_DOMAIN="${domain}" APP_DOMAIN="${primary_domain}"
APP_DOMAINS="${domains}"
APP_PORT="${port}" APP_PORT="${port}"
APP_UPSTREAM="127.0.0.1:${port}" APP_UPSTREAM="127.0.0.1:${port}"
APP_AUTH_PROTECTED="${auth}" APP_AUTH_PROTECTED="${auth}"
@ -210,12 +250,12 @@ EOF
cmd_init() { cmd_init() {
local name="$1" local name="$1"
local domain="$2" local domains="$2"
local port="$3" local port="$3"
local auth="${4:-true}" local auth="${4:-true}"
validate_name "${name}" validate_name "${name}"
validate_domain "${domain}" validate_domains "${domains}"
validate_port "${port}" validate_port "${port}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false" [[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
@ -232,7 +272,7 @@ cmd_init() {
mkdir -p "${stack_dir}" "${volume_dir}/data" mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${port}" write_default_compose "${name}" "${port}"
write_manifest "${name}" "${domain}" "${port}" "${auth}" write_manifest "${name}" "${domains}" "${port}" "${auth}"
cmd_render_route "${name}" cmd_render_route "${name}"
echo "initialized app '${name}'" echo "initialized app '${name}'"
@ -248,21 +288,35 @@ cmd_render_route() {
auth_block="${FORWARD_AUTH_BLOCK}" auth_block="${FORWARD_AUTH_BLOCK}"
fi 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 local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
# Strip any existing block for this app from the aggregate file. if [[ -f "${route_file}" ]]; then
if [[ -f "${APP_ROUTE_FILE}" ]]; then sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
# 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
else else
printf "" >"${tmp}" printf "" >"${tmp}"
fi fi
{ {
printf "# route:%s:start\n" "${name}" printf "# route:%s:start\n" "${name}"
printf "%s {\n" "${APP_DOMAIN}" printf "%s {\n" "${caddy_domains}"
if [[ -n "${auth_block}" ]]; then if [[ -n "${auth_block}" ]]; then
printf "%s\n" "${auth_block}" printf "%s\n" "${auth_block}"
fi fi
@ -271,8 +325,8 @@ cmd_render_route() {
printf "# route:%s:end\n" "${name}" printf "# route:%s:end\n" "${name}"
} >>"${tmp}" } >>"${tmp}"
mv "${tmp}" "${APP_ROUTE_FILE}" mv "${tmp}" "${route_file}"
echo "rendered route ${APP_ROUTE_FILE}" echo "rendered route ${route_file}"
} }
maybe_reload_caddy() { maybe_reload_caddy() {
@ -300,21 +354,37 @@ cmd_deploy() {
validate_name "${name}" validate_name "${name}"
load_app "${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}" cmd_render_route "${name}"
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info; then 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 echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err 2>/dev/null || true
fail "compose up failed" fail "compose up failed"
fi fi
maybe_reload_caddy 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}'" 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() { cmd_stop() {
local name="$1" local name="$1"
validate_name "${name}" validate_name "${name}"
@ -324,6 +394,52 @@ cmd_stop() {
echo "stopped app '${name}'" 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() { cmd_remove() {
local name="$1" local name="$1"
local keep_volumes="${2:-}" local keep_volumes="${2:-}"
@ -333,11 +449,13 @@ cmd_remove() {
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
# Remove this app's block from the aggregate routes file. # 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 local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
mv "${tmp}" "${APP_ROUTE_FILE}" mv "${tmp}" "${route_file}"
fi fi
rm -f "$(app_manifest "${name}")" rm -f "$(app_manifest "${name}")"
@ -351,6 +469,107 @@ cmd_remove() {
echo "removed app '${name}'" 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() { cmd_list() {
ensure_base_dirs ensure_base_dirs
local found=0 local found=0
@ -359,7 +578,8 @@ cmd_list() {
found=1 found=1
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "${mf}" 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 done
if [[ "${found}" -eq 0 ]]; then if [[ "${found}" -eq 0 ]]; then
@ -381,7 +601,7 @@ main() {
case "${cmd}" in case "${cmd}" in
init) 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}" cmd_init "$2" "$3" "$4" "${5:-true}"
;; ;;
render-route) render-route)
@ -392,14 +612,42 @@ main() {
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>" [[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
cmd_deploy "$2" cmd_deploy "$2"
;; ;;
restart)
[[ $# -eq 2 ]] || fail "usage: panelctl restart <name>"
cmd_restart "$2"
;;
stop) stop)
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>" [[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
cmd_stop "$2" 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) remove)
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]" [[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
cmd_remove "$2" "${3:-}" 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) list)
[[ $# -eq 1 ]] || fail "usage: panelctl list" [[ $# -eq 1 ]] || fail "usage: panelctl list"
cmd_list cmd_list