panel/frontend/index.html
Jakub Dorfman 009b8016bb feat: add support for app source selection with GitHub and raw compose options
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:39:34 +02:00

1081 lines
39 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; }
/* ── 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>
<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}`),
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"),
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" : "");
}
// ─── Domain tag input ───
const domainTags = [];
const domainTagsEl = document.getElementById("domainTags");
const domainInput = document.getElementById("domainInput");
function renderDomainTags() {
domainTagsEl.innerHTML = "";
domainTags.forEach((d, i) => {
const tag = document.createElement("span");
tag.className = "domain-tag";
tag.innerHTML = `${escHtml(d)} <button data-i="${i}">&times;</button>`;
tag.querySelector("button").onclick = () => { domainTags.splice(i, 1); renderDomainTags(); };
domainTagsEl.appendChild(tag);
});
}
document.getElementById("addDomainBtn").onclick = () => {
const v = domainInput.value.trim();
if (v && !domainTags.includes(v)) {
domainTags.push(v);
domainInput.value = "";
renderDomainTags();
}
};
domainInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); }
});
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 port = Number(document.getElementById("port").value);
const auth = document.getElementById("auth").value === "true";
const domains = [...domainTags];
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; }
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; }
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, domain: domains.join(","), port, 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 = "";
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="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 &amp; Deploy</button>
</div>
</div>
<div class="tab-panel" id="tab-logs-${app.name}">
<div class="btn-group" style="margin-bottom:8px;">
<button class="btn-sm" data-action="refresh-logs" data-app="${app.name}">Refresh Logs</button>
<select id="log-tail-${app.name}" style="width:auto;padding:4px 8px;font-size:.82rem;">
<option value="50">50 lines</option>
<option value="100" selected>100 lines</option>
<option value="300">300 lines</option>
<option value="1000">1000 lines</option>
</select>
</div>
<div class="log-output" id="logs-${app.name}">Click "Refresh Logs" to load.</div>
</div>
<div class="tab-panel" id="tab-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}">Load App Variables</button>
</div>
<div class="log-output" id="routing-${app.name}">Click "Load App Variables" to view routing details & directories.</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);
};
});
// 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 "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":
setStatus(`Fetching routing details for ${name}...`);
const appRes = await api.getApp(name);
if (appRes.app) {
const info = appRes.app;
document.getElementById(`routing-${name}`).innerHTML = `
<b>Primary Domain:</b> ${escHtml(info.APP_DOMAIN || "-")}
<b>All Domains:</b> ${escHtml(info.APP_DOMAINS || "-")}
<b>Upstream Target:</b> ${escHtml(info.APP_UPSTREAM || "-")}
<b>Protected:</b> ${escHtml(info.APP_AUTH_PROTECTED || "-")}
<b>Port:</b> ${escHtml(info.APP_PORT || "-")}
<b>Volumes Directory:</b> ${escHtml(info.APP_VOLUME_DIR || "-")}
<b>Stack Directory:</b> ${escHtml(info.APP_STACK_DIR || "-")}
<b>Caddy Route Block:</b> ${escHtml(info.APP_ROUTE_FILE || "-")}
`.trim();
}
setStatus(`Loaded routing for ${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}`;
}
}
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 ───
loadApps();
// Auto-refresh every 15s
setInterval(() => {
apps.forEach(app => fetchStatus(app.name));
}, 15000);
</script>
</body>
</html>