1177 lines
43 KiB
HTML
1177 lines
43 KiB
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: #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 { margin: 0 auto; padding: 24px 24px 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; }
|
|
|
|
/* ── Route table ── */
|
|
.route-row {
|
|
display: flex; gap: 6px; align-items: center;
|
|
margin-top: 6px;
|
|
}
|
|
.route-row input {
|
|
flex: 1;
|
|
padding: 6px 8px;
|
|
font-size: .85rem;
|
|
}
|
|
.route-row input:first-child { flex: 3; }
|
|
.route-row button { flex-shrink: 0; }
|
|
|
|
/* ── 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>Routes <span style="font-weight:400;color:var(--muted);font-size:.78rem;">domain | upstream</span></label>
|
|
<div id="routeTable"></div>
|
|
<div class="btn-group" style="margin-top:6px;">
|
|
<button class="btn-sm" id="addRouteBtn">+ Add Route</button>
|
|
</div>
|
|
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
|
|
Supports wildcards: <code>*.example.com</code>. Upstream: <code>127.0.0.1:PORT</code>
|
|
</p>
|
|
|
|
<div>
|
|
<label for="auth">Protected</label>
|
|
<select id="auth">
|
|
<option value="true" selected>true</option>
|
|
<option value="false">false</option>
|
|
</select>
|
|
</div>
|
|
|
|
<label for="createSource">App Source</label>
|
|
<select id="createSource">
|
|
<option value="default" selected>Default (whoami)</option>
|
|
<option value="raw">Raw Compose</option>
|
|
<option value="github">GitHub Repository</option>
|
|
</select>
|
|
|
|
<div id="sourceRaw" style="display:none; margin-top:10px;">
|
|
<label for="createCompose">Compose YAML</label>
|
|
<textarea id="createCompose" rows="8" placeholder="services:\n app:\n image: nginx\n ports:\n - '127.0.0.1:18080:80'"></textarea>
|
|
</div>
|
|
|
|
<div id="sourceGithub" style="display:none; margin-top:10px;">
|
|
<label for="createGithubUrl">Repository URL</label>
|
|
<input id="createGithubUrl" placeholder="https://github.com/user/repo" />
|
|
|
|
<div class="row" style="margin-top:8px;">
|
|
<div>
|
|
<label for="createGithubBranch">Branch</label>
|
|
<input id="createGithubBranch" placeholder="main" value="main" />
|
|
</div>
|
|
<div>
|
|
<label for="createGithubPat">PAT (Optional)</label>
|
|
<input id="createGithubPat" type="password" placeholder="ghp_..." autocomplete="new-password" />
|
|
</div>
|
|
</div>
|
|
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
|
|
Requires <code>compose.yaml</code> at the root.
|
|
</p>
|
|
</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}`),
|
|
getRoutes: (n) => api.request(`/apps/${n}/routes`),
|
|
setRoutes: (n, r) => api.request(`/apps/${n}/routes`, "POST", { routes: r }),
|
|
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"),
|
|
repoPull: (n) => api.request(`/apps/${n}/repo-pull`, "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"),
|
|
clearVolume: (n) => api.request(`/apps/${n}/volume-clear`, "POST"),
|
|
getVolumes: (n) => api.request(`/apps/${n}/volumes`),
|
|
getVolumeFiles: (n, p, v) => api.request(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`),
|
|
deleteFile: (n, p, v) => api.request(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`, "DELETE"),
|
|
uploadFile: async (n, p, v, f) => {
|
|
const res = await fetch(`/apps/${n}/volume/files?vol=${encodeURIComponent(v || "default")}&path=${encodeURIComponent(p)}`, {
|
|
method: "PUT",
|
|
body: f,
|
|
headers: { "Content-Length": f.size.toString() }
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "upload failed");
|
|
return data;
|
|
},
|
|
};
|
|
|
|
// ─── State ───
|
|
let apps = [];
|
|
let expandedApp = null;
|
|
let activeTab = {}; // { appName: tabName }
|
|
let volumeLocations = {}; // { appName: currentPathString }
|
|
let selectedVolumes = {}; // { appName: currentVolumeString }
|
|
|
|
// ─── Status bar ───
|
|
const statusEl = document.getElementById("status");
|
|
function setStatus(msg, isError = false) {
|
|
statusEl.textContent = msg;
|
|
statusEl.className = "status-bar" + (isError ? " err" : msg !== "Ready." ? " ok" : "");
|
|
}
|
|
|
|
// ─── Route table input ───
|
|
let createRoutes = [{ domain: "", upstream: "" }];
|
|
|
|
function renderRouteTable() {
|
|
const table = document.getElementById("routeTable");
|
|
table.innerHTML = "";
|
|
createRoutes.forEach((r, i) => {
|
|
const row = document.createElement("div");
|
|
row.className = "route-row";
|
|
row.innerHTML = `
|
|
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
|
<span style="color:var(--muted);font-weight:600;">→</span>
|
|
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
|
${createRoutes.length > 1 ? `<button class="btn-sm btn-danger" data-ridx="${i}">×</button>` : ""}
|
|
`;
|
|
row.querySelector(".route-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; };
|
|
row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = e.target.value; };
|
|
const delBtn = row.querySelector("[data-ridx]");
|
|
if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); };
|
|
table.appendChild(row);
|
|
});
|
|
}
|
|
|
|
document.getElementById("addRouteBtn").onclick = () => {
|
|
createRoutes.push({ domain: "", upstream: "" });
|
|
renderRouteTable();
|
|
};
|
|
|
|
const sourceSelect = document.getElementById("createSource");
|
|
const sourceRaw = document.getElementById("sourceRaw");
|
|
const sourceGithub = document.getElementById("sourceGithub");
|
|
|
|
sourceSelect.addEventListener("change", () => {
|
|
sourceRaw.style.display = sourceSelect.value === "raw" ? "block" : "none";
|
|
sourceGithub.style.display = sourceSelect.value === "github" ? "block" : "none";
|
|
});
|
|
|
|
document.getElementById("createBtn").onclick = async () => {
|
|
const name = document.getElementById("name").value.trim();
|
|
const auth = document.getElementById("auth").value === "true";
|
|
const source_type = sourceSelect.value;
|
|
const compose_content = document.getElementById("createCompose").value;
|
|
const github_url = document.getElementById("createGithubUrl").value.trim();
|
|
const github_branch = document.getElementById("createGithubBranch").value.trim();
|
|
const github_pat = document.getElementById("createGithubPat").value.trim();
|
|
|
|
if (!name) { setStatus("Name is required.", true); return; }
|
|
|
|
// Validate routes
|
|
const validRoutes = createRoutes.filter(r => r.domain.trim() && r.upstream.trim());
|
|
if (!validRoutes.length) { setStatus("At least one route with domain and upstream is required.", true); return; }
|
|
for (const r of validRoutes) {
|
|
const port = r.upstream.split(":").pop();
|
|
const pnum = Number(port);
|
|
if (!port || isNaN(pnum) || pnum < 1024 || pnum > 65535) {
|
|
setStatus(`Upstream port must be 1024-65535 in "${r.upstream}"`, true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (source_type === "raw" && !compose_content.trim()) { setStatus("Compose YAML is required for raw source.", true); return; }
|
|
if (source_type === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; }
|
|
|
|
try {
|
|
setStatus(`Creating ${name}...`);
|
|
await api.init({ name, routes: validRoutes.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })), auth, source_type, compose_content, github_url, github_branch, github_pat });
|
|
setStatus(`Created ${name}.`);
|
|
document.getElementById("name").value = "";
|
|
document.getElementById("createCompose").value = "";
|
|
document.getElementById("createGithubUrl").value = "";
|
|
document.getElementById("createGithubPat").value = "";
|
|
createRoutes = [{ domain: "", upstream: "" }];
|
|
renderRouteTable();
|
|
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;
|
|
}
|
|
|
|
// ─── App card rendering ───
|
|
function renderAppCard(app) {
|
|
const card = document.createElement("div");
|
|
card.className = "app-card fade-in";
|
|
card.dataset.name = app.name;
|
|
|
|
// Parse first_route for display
|
|
let firstDomain = app.first_route || app.domain || "";
|
|
let firstUpstream = app.upstream || "";
|
|
if (app.first_route && app.first_route.includes("|")) {
|
|
const parts = app.first_route.split("|");
|
|
firstDomain = parts[0];
|
|
firstUpstream = parts[1];
|
|
}
|
|
const routeCount = parseInt(app.route_count || "1", 10);
|
|
const extraLabel = routeCount > 1 ? ` +${routeCount - 1} more route${routeCount > 2 ? "s" : ""}` : "";
|
|
|
|
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">${escHtml(firstDomain)} → ${escHtml(firstUpstream)}${escHtml(extraLabel)} ${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>
|
|
${app.repo_url ? `<button class="btn-sm" data-action="repo-pull" data-app="${app.name}">Git Pull</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="volumes" data-app="${app.name}">Volumes</button>
|
|
<button class="tab" data-tab="backups" data-app="${app.name}">Backups</button>
|
|
<button class="tab" data-tab="routing" data-app="${app.name}">Routing</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-volumes-${app.name}">
|
|
<div class="btn-group" style="margin-bottom:10px; align-items:center;">
|
|
<select id="volume-select-${app.name}" style="width:140px;padding:4px 8px;font-size:0.8rem;"></select>
|
|
<button class="btn-sm btn-primary" data-action="volume-refresh" data-app="${app.name}">Refresh Files</button>
|
|
<button class="btn-sm" data-action="volume-upload" data-app="${app.name}">Upload</button>
|
|
<button class="btn-sm btn-danger" data-action="volume-clear" data-app="${app.name}" id="btn-vol-clear-${app.name}">Clear Bind</button>
|
|
</div>
|
|
<input type="file" id="volume-upload-input-${app.name}" style="display:none;" />
|
|
<div style="margin-bottom:8px;font-size:0.85rem;" id="volume-path-${app.name}">/</div>
|
|
<div class="backup-list" id="volumes-${app.name}">
|
|
<div style="color:var(--muted);font-size:.85rem;">Click "Refresh Files" to load volume browser.</div>
|
|
</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 class="tab-panel" id="tab-routing-${app.name}">
|
|
<div class="btn-group" style="margin-bottom:10px;">
|
|
<button class="btn-sm btn-primary" data-action="fetch-routing" data-app="${app.name}">Edit Routes</button>
|
|
</div>
|
|
<div id="routing-editor-${app.name}">
|
|
<div id="route-edit-table-${app.name}"></div>
|
|
<div class="btn-group" style="margin-top:8px;">
|
|
<button class="btn-sm" data-action="add-route-edit" data-app="${app.name}">+ Add Route</button>
|
|
<button class="btn-sm btn-primary" data-action="save-routes" data-app="${app.name}">Save Routes</button>
|
|
</div>
|
|
<p style="font-size:.75rem;color:var(--muted);margin-top:6px;">Saving updates the Caddy route file (hot reload). Edit the compose file separately if new ports need exposing.</p>
|
|
<div class="backup-list" style="margin-top:8px;" id="routing-info-${app.name}"></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-volumes-${app}`).classList.toggle("active", tabName === "volumes");
|
|
document.getElementById(`tab-backups-${app}`).classList.toggle("active", tabName === "backups");
|
|
document.getElementById(`tab-routing-${app}`).classList.toggle("active", tabName === "routing");
|
|
|
|
if (tabName === "logs") loadLogs(app);
|
|
if (tabName === "backups") loadBackups(app);
|
|
if (tabName === "volumes") initVolumesTab(app);
|
|
if (tabName === "routing") loadRouteEditor(app);
|
|
};
|
|
});
|
|
|
|
// Action buttons
|
|
document.querySelectorAll("[data-action]").forEach(btn => {
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
handleAction(btn.dataset.action, btn.dataset.app, btn);
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Actions ───
|
|
async function handleAction(action, name, btnElement) {
|
|
const originalWidth = btnElement ? btnElement.offsetWidth : null;
|
|
const originalHtml = btnElement ? btnElement.innerHTML : null;
|
|
|
|
if (btnElement) {
|
|
btnElement.disabled = true;
|
|
if (originalWidth) btnElement.style.width = `${originalWidth}px`;
|
|
btnElement.innerHTML = `<span class="spinner" style="vertical-align:middle; width:12px; height:12px; margin: 2px;"></span>`;
|
|
}
|
|
|
|
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 "repo-pull":
|
|
setStatus(`Pulling repo for ${name}...`);
|
|
await api.repoPull(name);
|
|
setStatus(`Repo pulled for ${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;
|
|
case "volume-refresh":
|
|
const curPath = volumeLocations[name] || "";
|
|
await loadVolume(name, curPath, selectedVolumes[name]);
|
|
break;
|
|
case "volume-clear":
|
|
if (selectedVolumes[name] && selectedVolumes[name] !== "default") {
|
|
setStatus("Clear is only supported for the 'default' managed local bind right now.", true);
|
|
break;
|
|
}
|
|
if (confirm("Are you sure? This deletes ALL data inside the default volume right now!")) {
|
|
setStatus(`Clearing volume for ${name}...`);
|
|
await api.clearVolume(name);
|
|
setStatus(`Volume cleared for ${name}.`);
|
|
await loadVolume(name, "", selectedVolumes[name]);
|
|
}
|
|
break;
|
|
case "volume-upload":
|
|
const uploadInput = document.getElementById(`volume-upload-input-${name}`);
|
|
uploadInput.onchange = async (e) => {
|
|
if (!e.target.files.length) return;
|
|
const file = e.target.files[0];
|
|
const uploadPath = (volumeLocations[name] ? volumeLocations[name] + "/" : "") + file.name;
|
|
try {
|
|
setStatus(`Uploading ${file.name} to ${name}...`);
|
|
await api.uploadFile(name, uploadPath, selectedVolumes[name], file);
|
|
setStatus(`Uploaded ${file.name} successfully.`);
|
|
await loadVolume(name, volumeLocations[name] || "", selectedVolumes[name]);
|
|
} catch(uploadErr) {
|
|
setStatus(`Upload failed: ${uploadErr.message}`, true);
|
|
}
|
|
uploadInput.value = "";
|
|
};
|
|
uploadInput.click();
|
|
break;
|
|
case "fetch-routing":
|
|
await loadRouteEditor(name);
|
|
break;
|
|
case "save-routes":
|
|
await saveRouteEditor(name);
|
|
break;
|
|
case "add-route-edit":
|
|
await addRouteEditRow(name);
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
setStatus(`${action} failed for ${name}: ${err.message}`, true);
|
|
} finally {
|
|
if (btnElement && originalHtml) {
|
|
btnElement.disabled = false;
|
|
btnElement.innerHTML = originalHtml;
|
|
btnElement.style.width = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 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}`;
|
|
}
|
|
}
|
|
|
|
// ─── Route editor (routing tab) ───
|
|
let routeEditState = {}; // { appName: [{ domain, upstream }, ...] }
|
|
|
|
async function loadRouteEditor(name) {
|
|
try {
|
|
const data = await api.getRoutes(name);
|
|
routeEditState[name] = data.routes || [];
|
|
renderRouteEditor(name);
|
|
setStatus(`Loaded ${data.routes.length} route(s) for ${name}.`);
|
|
} catch (err) {
|
|
const info = document.getElementById(`routing-info-${name}`);
|
|
if (info) info.innerHTML = `<span style="color:var(--warn);">Failed: ${err.message}</span>`;
|
|
}
|
|
}
|
|
|
|
function renderRouteEditor(name) {
|
|
const table = document.getElementById(`route-edit-table-${name}`);
|
|
const routes = routeEditState[name] || [];
|
|
if (!table) return;
|
|
table.innerHTML = "";
|
|
routes.forEach((r, i) => {
|
|
const row = document.createElement("div");
|
|
row.className = "route-row";
|
|
row.innerHTML = `
|
|
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
|
<span style="color:var(--muted);font-weight:600;">→</span>
|
|
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
|
<button class="btn-sm btn-danger" data-ridx="${i}">×</button>
|
|
`;
|
|
row.querySelector(".route-domain").oninput = (e) => { routeEditState[name][i].domain = e.target.value; };
|
|
row.querySelector(".route-upstream").oninput = (e) => { routeEditState[name][i].upstream = e.target.value; };
|
|
row.querySelector("[data-ridx]").onclick = () => {
|
|
routeEditState[name].splice(i, 1);
|
|
renderRouteEditor(name);
|
|
};
|
|
table.appendChild(row);
|
|
});
|
|
if (!routes.length) {
|
|
table.innerHTML = '<div style="color:var(--muted);font-size:.85rem;padding:8px 0;">No routes defined.</div>';
|
|
}
|
|
}
|
|
|
|
async function addRouteEditRow(name) {
|
|
if (!routeEditState[name]) routeEditState[name] = [];
|
|
routeEditState[name].push({ domain: "", upstream: "" });
|
|
renderRouteEditor(name);
|
|
}
|
|
|
|
async function saveRouteEditor(name) {
|
|
const routes = routeEditState[name] || [];
|
|
const valid = routes.filter(r => r.domain.trim() && r.upstream.trim());
|
|
if (!valid.length) {
|
|
setStatus("At least one valid route is required.", true);
|
|
return;
|
|
}
|
|
try {
|
|
setStatus(`Saving routes for ${name}...`);
|
|
const cleanRoutes = valid.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() }));
|
|
await api.setRoutes(name, cleanRoutes);
|
|
routeEditState[name] = cleanRoutes;
|
|
renderRouteEditor(name);
|
|
setStatus(`Routes saved for ${name}. Caddy reloaded.`);
|
|
} catch (err) {
|
|
setStatus(`Save routes failed: ${err.message}`, true);
|
|
}
|
|
}
|
|
|
|
window.api = api; // Expose for inline html onclicks
|
|
|
|
async function initVolumesTab(name) {
|
|
if (!selectedVolumes[name]) {
|
|
try {
|
|
const vdata = await api.getVolumes(name);
|
|
const sel = document.getElementById(`volume-select-${name}`);
|
|
sel.innerHTML = "";
|
|
Object.keys(vdata.volumes).forEach(vKey => {
|
|
const opt = document.createElement("option");
|
|
opt.value = vKey;
|
|
opt.textContent = vKey;
|
|
sel.appendChild(opt);
|
|
});
|
|
selectedVolumes[name] = sel.value || "default";
|
|
|
|
sel.onchange = () => {
|
|
selectedVolumes[name] = sel.value;
|
|
const btnClear = document.getElementById(`btn-vol-clear-${name}`);
|
|
if(btnClear) btnClear.style.display = sel.value === "default" ? "inline-block" : "none";
|
|
loadVolume(name, "", sel.value);
|
|
};
|
|
|
|
const btnClear = document.getElementById(`btn-vol-clear-${name}`);
|
|
if(btnClear) btnClear.style.display = selectedVolumes[name] === "default" ? "inline-block" : "none";
|
|
} catch(err) {
|
|
console.error(err);
|
|
selectedVolumes[name] = "default";
|
|
}
|
|
}
|
|
loadVolume(name, "", selectedVolumes[name]);
|
|
}
|
|
|
|
async function loadVolume(name, currentPath, volName) {
|
|
volumeLocations[name] = currentPath;
|
|
const pathLabel = currentPath ? currentPath : "/";
|
|
document.getElementById(`volume-path-${name}`).innerHTML = `
|
|
<span>${escHtml(pathLabel)}</span>
|
|
`;
|
|
|
|
try {
|
|
const data = await api.getVolumeFiles(name, currentPath, volName);
|
|
const container = document.getElementById(`volumes-${name}`);
|
|
container.innerHTML = "";
|
|
|
|
if (currentPath) {
|
|
const upPath = currentPath.split("/").slice(0, -1).join("/");
|
|
const item = document.createElement("div");
|
|
item.className = "backup-item";
|
|
item.style.cursor = "pointer";
|
|
item.onclick = () => loadVolume(name, upPath, volName);
|
|
item.innerHTML = `
|
|
<div style="flex:1;color:var(--accent);font-weight:bold;">
|
|
📁 ..
|
|
</div>
|
|
`;
|
|
container.appendChild(item);
|
|
}
|
|
|
|
if (!data.files || data.files.length === 0) {
|
|
const msg = document.createElement("div");
|
|
msg.style.color = "var(--muted)";
|
|
msg.style.padding = "10px";
|
|
msg.textContent = "Directory is empty.";
|
|
container.appendChild(msg);
|
|
return;
|
|
}
|
|
|
|
data.files.forEach(f => {
|
|
const fullPath = currentPath ? currentPath + "/" + f.name : f.name;
|
|
const item = document.createElement("div");
|
|
item.className = "backup-item";
|
|
item.style.gap = "8px";
|
|
|
|
const icon = f.is_dir ? "📁" : "📄";
|
|
const sizeStr = f.is_dir ? "" : (f.size > 1024 * 1024 ? (f.size / 1024 / 1024).toFixed(1) + "MB" : (f.size / 1024).toFixed(1) + "KB");
|
|
|
|
const titleDiv = document.createElement("div");
|
|
titleDiv.style.flex = "1";
|
|
titleDiv.style.display = "flex";
|
|
titleDiv.style.alignItems = "center";
|
|
titleDiv.style.minWidth = "0";
|
|
|
|
const spanIcon = document.createElement("span");
|
|
spanIcon.style.marginRight = "8px";
|
|
spanIcon.style.fontSize = "1.2rem";
|
|
spanIcon.textContent = icon;
|
|
|
|
const spanName = document.createElement("span");
|
|
spanName.style.fontFamily = "monospace";
|
|
spanName.style.fontSize = "0.85rem";
|
|
spanName.style.whiteSpace = "nowrap";
|
|
spanName.style.overflow = "hidden";
|
|
spanName.style.textOverflow = "ellipsis";
|
|
spanName.textContent = f.name;
|
|
if (f.is_dir) {
|
|
spanName.style.cursor = "pointer";
|
|
spanName.style.color = "var(--accent)";
|
|
spanName.onclick = () => loadVolume(name, fullPath, volName);
|
|
}
|
|
|
|
titleDiv.appendChild(spanIcon);
|
|
titleDiv.appendChild(spanName);
|
|
|
|
const sizeDiv = document.createElement("div");
|
|
sizeDiv.style.color = "var(--muted)";
|
|
sizeDiv.style.fontSize = "0.8rem";
|
|
sizeDiv.style.whiteSpace = "nowrap";
|
|
sizeDiv.style.width = "60px";
|
|
sizeDiv.style.textAlign = "right";
|
|
sizeDiv.textContent = sizeStr;
|
|
|
|
const actionsDiv = document.createElement("div");
|
|
actionsDiv.style.display = "flex";
|
|
actionsDiv.style.gap = "4px";
|
|
|
|
if (!f.is_dir) {
|
|
const downloadBtn = document.createElement("button");
|
|
downloadBtn.className = "btn-sm";
|
|
downloadBtn.style.padding = "2px 6px";
|
|
downloadBtn.style.fontSize = "0.75rem";
|
|
downloadBtn.textContent = "⬇️";
|
|
downloadBtn.onclick = () => window.open(`/apps/${name}/volume/download?vol=${encodeURIComponent(volName)}&path=${encodeURIComponent(fullPath)}`);
|
|
actionsDiv.appendChild(downloadBtn);
|
|
}
|
|
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "btn-sm btn-danger";
|
|
delBtn.style.padding = "2px 6px";
|
|
delBtn.style.fontSize = "0.75rem";
|
|
delBtn.textContent = "🗑️";
|
|
delBtn.onclick = async () => {
|
|
if (confirm(`Delete ${f.name}?`)) {
|
|
await window.api.deleteFile(name, fullPath, volName);
|
|
loadVolume(name, currentPath, volName);
|
|
}
|
|
};
|
|
actionsDiv.appendChild(delBtn);
|
|
|
|
item.appendChild(titleDiv);
|
|
item.appendChild(sizeDiv);
|
|
item.appendChild(actionsDiv);
|
|
|
|
container.appendChild(item);
|
|
});
|
|
} catch (err) {
|
|
document.getElementById(`volumes-${name}`).textContent = "Failed to load files: " + 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 ───
|
|
renderRouteTable();
|
|
loadApps();
|
|
|
|
// Auto-refresh every 15s
|
|
setInterval(() => {
|
|
apps.forEach(app => fetchStatus(app.name));
|
|
}, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|