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