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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue