nixos/panel/frontend/index.html
agent 76a1e09913 panel: redesign web UI and make syncing responsive
Web UI (panel/frontend/index.html) rewritten:
- Cards update in place from a single /status poll instead of being rebuilt
  on every action, so open tabs, unsaved compose/route edits, logs and the
  file browser position survive refreshes. Polling speeds up while an
  operation runs and pauses in background tabs; a header indicator shows
  when the panel last synced and detects an expired Authelia session.
- New-app dialog (starter / compose / git), suggested port and domain,
  proper confirm dialogs (the old "OK = keep volumes" remove prompt is gone),
  toasts, an activity drawer with operation output, overflow menu, search,
  status filters, keyboard shortcuts, deep links, dark mode and mobile layout.
- Tabs: overview (containers + routes), compose editor (dirty tracking,
  Ctrl+S), logs with follow, validated routes editor, file browser with
  drag-and-drop upload, backups, and a git source tab (deployed commit,
  check for updates, sync & deploy).

API (panel/panel-api.py):
- ThreadingHTTPServer so a long deploy no longer blocks every other request.
- Per-app operation lock; concurrent writes to a busy app return 409.
- GET /status: all apps, routes and container status in one request
  (statuses gathered in parallel); status reports running/partial/stopped.
- Git sync is fetch + hard reset instead of pull-or-reclone, keeps the stored
  token, reports before/after commits; GET /apps/<name>/repo[?fetch=1].
- Any http(s) git host (e.g. Forgejo), default branch detection, git
  timeouts, no credential prompts, tokens redacted from errors, and manifest
  values validated before being written into the bash-sourced manifest.

panelctl:
- flock around routes.caddy rewrites (util-linux added to the service path).
- deploy returns compose output so failures are visible in the UI.
- inspect-volumes no longer fails for apps without named podman volumes,
  which broke the file browser.

Docs: README/API.md updated; fixed outdated panelctl init examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-26 22:17:07 +00:00

2219 lines
96 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Containers Panel</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230e8a6b'/%3E%3Cpath d='M9 11h14M9 16h14M9 21h9' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
<style>
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #f1f3f5;
--border: #e2e5e9;
--border-strong: #cdd2d8;
--text: #16181d;
--muted: #667085;
--accent: #0e8a6b;
--accent-hover: #0b7359;
--accent-soft: #e5f4ef;
--accent-text: #ffffff;
--ok: #16a34a;
--ok-soft: #e7f6ec;
--warn: #c26d05;
--warn-soft: #fdf2e1;
--danger: #d92d20;
--danger-soft: #fdecea;
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
--shadow-lg: 0 16px 40px rgba(16, 24, 40, .18);
--radius: 10px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1014;
--surface: #16191f;
--surface-2: #1d2128;
--border: #282d36;
--border-strong: #394049;
--text: #e6e8ec;
--muted: #9199a8;
--accent: #22b88f;
--accent-hover: #3ccaa1;
--accent-soft: #123229;
--accent-text: #04120d;
--ok: #34c46a;
--ok-soft: #10301c;
--warn: #f0a132;
--warn-soft: #33250f;
--danger: #f47067;
--danger-soft: #3a1816;
--shadow: 0 1px 2px rgba(0, 0, 0, .3);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, .5);
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font: 14px/1.5 var(--font);
color: var(--text);
background: var(--bg);
min-height: 100vh;
}
h1, h2, h3, h4, p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, .mono { font-family: var(--mono); font-size: .92em; }
code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
.muted { color: var(--muted); }
.small { font-size: 12.5px; }
.block { display: block; }
.right { text-align: right; }
.error { color: var(--danger); }
.spacer { flex: 1; }
/* ── Icons ── */
.icon { display: inline-flex; flex-shrink: 0; }
.icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 8px;
background: var(--surface); color: var(--text);
font: 500 13.5px/1 var(--font);
cursor: pointer; white-space: nowrap; text-decoration: none;
transition: background .12s, border-color .12s, color .12s, box-shadow .12s;
}
.btn:hover { background: var(--surface-2); text-decoration: none; }
.btn:focus-visible, .input:focus-visible, .tab:focus-visible, .app-head:focus-visible, .chip:focus-visible, .menu-item:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.btn:disabled { opacity: .55; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.danger:hover:not(:disabled) { filter: brightness(1.08); }
.btn.ghost { background: transparent; border-color: transparent; }
.btn.ghost:hover:not(:disabled) { background: var(--surface-2); }
.btn.ghost.danger { color: var(--danger); background: transparent; border-color: transparent; }
.btn.ghost.danger:hover:not(:disabled) { background: var(--danger-soft); }
.btn.sm { height: 28px; padding: 0 9px; font-size: 12.5px; }
.btn.icon-only { width: 34px; padding: 0; }
.btn.sm.icon-only { width: 28px; }
.link { background: none; border: 0; padding: 0; color: var(--accent); font: inherit; cursor: pointer; }
.link:hover { text-decoration: underline; }
/* ── Inputs ── */
.input, .select, textarea.code {
width: 100%;
border: 1px solid var(--border-strong); border-radius: 8px;
background: var(--surface); color: var(--text);
font: 14px/1.4 var(--font);
padding: 7px 10px;
}
.input:focus, .select:focus, textarea.code:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.input.invalid { border-color: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
.select { width: auto; padding-right: 28px; }
.select.sm { height: 28px; padding: 2px 26px 2px 8px; font-size: 12.5px; }
textarea.code {
font: 13px/1.55 var(--mono);
resize: vertical; min-height: 220px;
tab-size: 2; white-space: pre; overflow: auto;
}
.field { margin-bottom: 16px; }
.field > label, .label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 6px; }
.hint { color: var(--muted); font-size: 12.5px; margin-top: 6px; }
.form-error { color: var(--danger); background: var(--danger-soft); border-radius: 8px; padding: 8px 12px; font-size: 13px; margin-top: 8px; }
.check { display: flex; gap: 10px; align-items: flex-start; margin: 10px 0; cursor: pointer; }
.check input { margin-top: 3px; accent-color: var(--accent); width: 16px; height: 16px; flex-shrink: 0; }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; cursor: pointer; user-select: none; }
.toggle input { accent-color: var(--accent); }
.inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); }
/* ── Top bar ── */
.topbar {
position: sticky; top: 0; z-index: 20;
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: saturate(1.5) blur(10px);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 1120px; margin: 0 auto; padding: 10px 16px;
display: flex; align-items: center; gap: 12px;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 16px; white-space: nowrap; }
.brand-mark {
width: 26px; height: 26px; border-radius: 7px; background: var(--accent);
display: grid; place-items: center; color: var(--accent-text);
}
.brand-mark svg { width: 15px; height: 15px; stroke-width: 2.5; }
.search {
flex: 1; max-width: 420px; margin-left: 12px;
display: flex; align-items: center; gap: 8px;
height: 34px; padding: 0 10px;
border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface);
color: var(--muted);
}
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); font: inherit; }
kbd {
font: 11px var(--mono); color: var(--muted);
border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px;
}
.top-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.sync {
display: inline-flex; align-items: center; gap: 7px;
height: 30px; padding: 0 10px; border-radius: 999px;
border: 1px solid var(--border); background: var(--surface);
color: var(--muted); font: 12.5px var(--font); cursor: pointer; white-space: nowrap;
}
.sync:hover { border-color: var(--border-strong); color: var(--text); }
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.sync.ok .sync-dot { background: var(--ok); }
.sync.err .sync-dot { background: var(--danger); }
.sync.err { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.sync.syncing .sync-dot { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .3; } }
.count {
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px;
background: var(--accent); color: var(--accent-text);
font-size: 11px; font-weight: 700; display: inline-grid; place-items: center;
}
.banner {
background: var(--warn-soft); color: var(--text);
border-bottom: 1px solid color-mix(in srgb, var(--warn) 40%, transparent);
padding: 10px 16px; text-align: center; font-size: 13.5px;
}
.banner .btn { margin-left: 8px; }
/* ── Page ── */
.wrap { max-width: 1120px; margin: 0 auto; padding: 20px 16px 64px; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.filters { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px; border-radius: 999px;
border: 1px solid var(--border); background: var(--surface); color: var(--muted);
font: 500 13px var(--font); cursor: pointer;
}
.chip:hover { color: var(--text); border-color: var(--border-strong); }
.chip.active { background: var(--text); border-color: var(--text); color: var(--bg); }
.chip-count { font-size: 11.5px; opacity: .75; font-variant-numeric: tabular-nums; }
.shortcuts { margin-left: auto; color: var(--muted); font-size: 12px; }
/* ── App cards ── */
.app-list { display: flex; flex-direction: column; gap: 10px; }
.app {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow);
transition: border-color .15s, box-shadow .15s;
}
.app:hover { border-color: var(--border-strong); }
.app.open { border-color: var(--border-strong); box-shadow: var(--shadow-lg); }
.app-head {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; cursor: pointer; border-radius: var(--radius);
min-width: 0;
}
.chev { color: var(--muted); transition: transform .15s; }
.app.open .chev { transform: rotate(90deg); }
.app-title { flex: 1; min-width: 0; }
.app-name { font-size: 15px; font-weight: 650; line-height: 1.3; }
.app-sub { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; margin-top: 2px; font-size: 12.5px; color: var(--muted); min-width: 0; }
.domain { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
a.domain:hover { color: var(--accent); }
.more { font-size: 12px; }
.badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11.5px; padding: 1px 7px; border-radius: 999px;
background: var(--surface-2); color: var(--muted); white-space: nowrap;
}
.badge .icon svg { width: 12px; height: 12px; }
.app-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
height: 24px; padding: 0 9px; border-radius: 999px;
font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0;
background: var(--surface-2); color: var(--muted);
min-width: 88px;
}
.pill.sm { height: 20px; min-width: 0; font-size: 11.5px; padding: 0 7px; }
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.pill-running { background: var(--ok-soft); color: var(--ok); }
.pill-partial, .pill-unknown { background: var(--warn-soft); color: var(--warn); }
.pill-busy { background: var(--accent-soft); color: var(--accent); }
.app-body { border-top: 1px solid var(--border); }
.tabs { display: flex; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; }
.tab {
position: relative; display: inline-flex; align-items: center; gap: 6px;
background: none; border: 0; padding: 10px 10px 9px; margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--muted); font: 500 13px var(--font); cursor: pointer; white-space: nowrap;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--warn); }
.panel { padding: 14px; position: relative; }
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.row-actions:first-child { margin-top: 0; }
.dirty-label { color: var(--warn); font-size: 12.5px; font-weight: 600; }
.notice {
display: flex; gap: 8px; align-items: flex-start;
background: var(--surface-2); border-radius: 8px; padding: 9px 12px;
font-size: 13px; color: var(--muted); margin-bottom: 10px;
}
.notice .icon { margin-top: 2px; }
.notice.warn { background: var(--warn-soft); color: var(--text); }
.notice.warn .icon { color: var(--warn); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.grid-2.tight { gap: 10px; }
.box { border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
.box h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-bottom: 8px; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { text-align: left; font-weight: 500; color: var(--muted); font-size: 12px; padding: 0 8px 6px 0; }
.table td { padding: 6px 8px 6px 0; border-top: 1px solid var(--border); vertical-align: middle; word-break: break-all; }
.route-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.route-list li { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; min-width: 0; }
.route-list .domain { color: var(--accent); font-size: 12.5px; }
.arrow { color: var(--muted); }
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13px; }
.facts dt { color: var(--muted); }
.facts dd { margin: 0; min-width: 0; word-break: break-word; }
/* ── Route editor ── */
.route-row { display: grid; grid-template-columns: minmax(0, 3fr) auto minmax(0, 2fr) minmax(0, 1.4fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; }
.route-row .input { height: 32px; padding: 5px 9px; font-size: 13px; }
.route-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
/* ── Logs ── */
.logs {
margin: 0; background: #0d1117; color: #d1d7e0;
font: 12px/1.55 var(--mono);
padding: 12px 14px; border-radius: 8px;
height: 420px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
.logs.sm { height: auto; max-height: 260px; font-size: 11.5px; }
/* ── Files / backups ── */
.crumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-width: 0; font-size: 13px; }
.crumb { display: inline-flex; align-items: center; gap: 5px; background: none; border: 0; padding: 3px 6px; border-radius: 6px; color: var(--text); font: inherit; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
.crumb:hover { background: var(--surface-2); }
.crumb-sep { color: var(--muted); }
.file-list { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.file-row {
display: grid; grid-template-columns: 20px minmax(0, 1fr) 80px 90px auto;
align-items: center; gap: 10px; padding: 7px 10px;
border-top: 1px solid var(--border); font-size: 13px; min-height: 42px;
}
.file-row:first-child { border-top: 0; }
.file-row.dir { cursor: pointer; }
.file-row.dir:hover { background: var(--surface-2); }
.file-row.dir > .icon { color: var(--accent); }
.file-row > .icon { color: var(--muted); }
.file-name { font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row.backup .file-name { font-family: var(--font); font-size: 13px; white-space: normal; }
.file-actions { display: flex; gap: 2px; justify-content: flex-end; }
.empty-sm { padding: 18px; text-align: center; color: var(--muted); font-size: 13px; }
.dropzone {
position: absolute; inset: 8px; border: 2px dashed var(--accent); border-radius: 10px;
background: color-mix(in srgb, var(--accent-soft) 85%, transparent);
display: none; align-items: center; justify-content: center; gap: 8px;
color: var(--accent); font-weight: 600; pointer-events: none;
}
.panel.dragging .dropzone { display: flex; }
.commit { display: inline; }
.commit code { margin-right: 4px; }
.update-box { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-top: 12px; font-size: 13px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.update-box .icon { color: var(--ok); }
/* ── Empty / loading ── */
.empty {
text-align: center; padding: 56px 20px;
background: var(--surface); border: 1px dashed var(--border-strong); border-radius: var(--radius);
color: var(--muted);
}
.empty h3 { color: var(--text); font-size: 16px; margin-bottom: 6px; }
.empty .btn { margin-top: 14px; }
.skeleton { height: 64px; border-radius: var(--radius); background: linear-gradient(90deg, var(--surface) 0%, var(--surface-2) 50%, var(--surface) 100%); background-size: 200% 100%; animation: shimmer 1.2s linear infinite; border: 1px solid var(--border); }
@keyframes shimmer { to { background-position: -200% 0; } }
.spinner {
display: inline-block; width: 13px; height: 13px; flex-shrink: 0;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
border-top-color: currentColor; border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Menu ── */
.menu {
position: fixed; z-index: 50; min-width: 190px;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px;
box-shadow: var(--shadow-lg); padding: 5px;
}
.menu-item {
display: flex; align-items: center; gap: 9px; width: 100%;
background: none; border: 0; border-radius: 6px; padding: 7px 10px;
color: var(--text); font: 13.5px var(--font); cursor: pointer; text-align: left;
}
.menu-item:hover:not(:disabled) { background: var(--surface-2); }
.menu-item:disabled { opacity: .45; cursor: default; }
.menu-item.danger { color: var(--danger); }
.menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
/* ── Toasts ── */
.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 60; display: flex; flex-direction: column; gap: 8px; width: min(380px, calc(100vw - 32px)); }
.toast {
display: flex; gap: 10px; align-items: flex-start;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px;
box-shadow: var(--shadow-lg); padding: 11px 12px;
animation: toast-in .18s ease-out;
}
.toast.leaving { opacity: 0; transform: translateY(6px); transition: .18s; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
.toast > .icon { margin-top: 2px; }
.toast-success > .icon { color: var(--ok); }
.toast-error > .icon { color: var(--danger); }
.toast-info > .icon { color: var(--accent); }
.toast-body { flex: 1; min-width: 0; }
.toast-title { font-weight: 600; font-size: 13.5px; }
.toast-detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.toast .link { font-size: 12.5px; margin-top: 4px; }
.toast-close { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; border-radius: 4px; }
.toast-close:hover { color: var(--text); background: var(--surface-2); }
/* ── Activity drawer ── */
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; z-index: 40;
width: min(460px, 100vw);
background: var(--surface); border-left: 1px solid var(--border-strong);
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column;
animation: slide-in .18s ease-out;
}
@keyframes slide-in { from { transform: translateX(24px); opacity: 0; } }
.drawer-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.drawer-head h2 { font-size: 15px; flex: 1; }
.drawer-body { flex: 1; overflow: auto; padding: 8px; }
.act { border-radius: 8px; padding: 2px 0; }
.act + .act { border-top: 1px solid var(--border); }
.act summary { list-style: none; cursor: pointer; border-radius: 6px; }
.act summary::-webkit-details-marker { display: none; }
.act summary:hover { background: var(--surface-2); }
.act-head { display: flex; align-items: center; gap: 8px; padding: 8px; font-size: 13px; }
.act-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.act-ok { color: var(--ok); }
.act-error { color: var(--danger); }
.act pre { margin: 0 8px 8px; }
/* ── Dialogs ── */
dialog {
border: 1px solid var(--border-strong); border-radius: 14px; padding: 0;
background: var(--surface); color: var(--text);
box-shadow: var(--shadow-lg);
width: min(640px, calc(100vw - 24px)); max-height: calc(100vh - 32px);
}
dialog.sm { width: min(440px, calc(100vw - 24px)); }
dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); }
dialog form { display: flex; flex-direction: column; max-height: calc(100vh - 34px); }
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 6px; }
.dialog-head h2, dialog.sm h2 { font-size: 17px; }
dialog.sm form { padding: 18px; }
dialog.sm .dialog-body { padding: 10px 0 4px; }
.dialog-body { padding: 10px 18px; overflow: auto; }
.dialog-body p + p { margin-top: 8px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; border-top: 1px solid var(--border); }
dialog.sm .dialog-actions { padding: 14px 0 0; border: 0; }
.segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 9px; padding: 3px; gap: 2px; background: var(--surface-2); flex-wrap: wrap; }
.segmented button {
border: 0; background: none; padding: 6px 12px; border-radius: 6px;
font: 500 13px var(--font); color: var(--muted); cursor: pointer;
}
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
/* ── Responsive ── */
@media (max-width: 760px) {
.hide-sm { display: none !important; }
.topbar-inner { flex-wrap: wrap; }
.search { order: 3; max-width: none; margin-left: 0; flex-basis: 100%; }
.search kbd, .shortcuts { display: none; }
.grid-2 { grid-template-columns: 1fr; }
/* Card header: name + actions on top, status pill underneath. */
.app-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px 10px; padding: 12px; }
.app-head > .chev { grid-row: 1 / span 2; align-self: start; margin-top: 3px; }
.app-title { grid-column: 2; grid-row: 1; }
.app-actions { grid-column: 3; grid-row: 1; align-self: start; }
.app-head > .pill { grid-column: 2; grid-row: 2; justify-self: start; min-width: 0; }
.route-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px; }
.route-row .arrow { display: none; }
.route-row .route-path { grid-column: 1 / 3; }
.route-head { display: none; }
.file-row { grid-template-columns: 20px minmax(0, 1fr) auto; }
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
.logs { height: 320px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<div class="brand"><span class="brand-mark" id="brandMark"></span><span>Panel</span></div>
<label class="search">
<span id="searchIcon"></span>
<input id="search" type="search" placeholder="Search apps, domains, ports…" autocomplete="off" spellcheck="false" />
<kbd>/</kbd>
</label>
<div class="top-actions">
<button id="syncBtn" class="sync" type="button"><span class="sync-dot"></span><span id="syncText">Connecting…</span></button>
<button id="activityBtn" class="btn ghost" type="button" title="Activity — operations and their output">
<span id="activityIcon"></span><span class="hide-sm">Activity</span><span id="activityCount" class="count" hidden></span>
</button>
<button id="newAppBtn" class="btn primary" type="button" title="New app (N)"><span id="newIcon"></span><span>New app</span></button>
</div>
</div>
</header>
<div id="sessionBanner" class="banner" hidden>
Your login session has expired, so the panel can't sync.
<button class="btn sm primary" type="button" id="reloginBtn">Sign in again</button>
</div>
<main class="wrap">
<div class="toolbar">
<div class="filters" id="filters"></div>
<div class="shortcuts"><kbd>/</kbd> search · <kbd>N</kbd> new app</div>
</div>
<div id="appList" class="app-list">
<div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div>
</div>
</main>
<aside id="activity" class="drawer" hidden aria-label="Activity">
<div class="drawer-head">
<h2>Activity</h2>
<button class="btn ghost sm" type="button" id="activityClear">Clear</button>
<button class="btn ghost sm icon-only" type="button" id="activityClose" aria-label="Close"></button>
</div>
<div class="drawer-body" id="activityList"></div>
</aside>
<div id="toasts" class="toasts" aria-live="polite"></div>
<div id="menu" class="menu" role="menu" hidden></div>
<dialog id="newAppDlg">
<form id="newAppForm" novalidate>
<header class="dialog-head">
<h2>New app</h2>
<button type="button" class="btn ghost icon-only" data-close aria-label="Close"></button>
</header>
<div class="dialog-body">
<div class="field">
<label for="naName">Name</label>
<input id="naName" class="input" placeholder="whoami" autocomplete="off" spellcheck="false" />
<p class="hint">Lowercase letters, digits and dashes. Used for the compose project and data folder.</p>
</div>
<div class="field">
<span class="label">Source</span>
<div class="segmented" id="naSource">
<button type="button" data-v="default">Starter</button>
<button type="button" data-v="raw">Compose file</button>
<button type="button" data-v="github">Git repository</button>
</div>
<p class="hint" id="naSourceHint"></p>
</div>
<div class="field" id="naRaw" hidden>
<label for="naCompose">compose.yaml</label>
<textarea id="naCompose" class="code" rows="10" spellcheck="false"></textarea>
</div>
<div id="naGit" hidden>
<div class="field">
<label for="naUrl">Repository URL</label>
<input id="naUrl" class="input" placeholder="https://git.example.com/user/repo" autocomplete="off" spellcheck="false" />
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naBranch">Branch</label>
<input id="naBranch" class="input" placeholder="default branch" autocomplete="off" spellcheck="false" />
</div>
<div class="field">
<label for="naPat">Access token <span class="muted">(private repos)</span></label>
<input id="naPat" class="input" type="password" autocomplete="new-password" />
</div>
</div>
<p class="hint" style="margin:-8px 0 16px">
Works with GitHub, Forgejo/Gitea and other https hosts. Needs a <code>compose.yaml</code> at the repository root.
A token is kept in the clone's git config, so use a read-only one.
</p>
</div>
<div class="field">
<span class="label">Routes</span>
<div id="naRoutes"></div>
<button type="button" class="btn ghost sm" id="naAddRoute"></button>
<p class="hint">Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. <code>/api/*</code>. Wildcard domains need a DNS challenge.</p>
</div>
<label class="check"><input type="checkbox" id="naAuth" checked /><span><strong>Require login</strong><br><span class="muted small">Put the app behind Authelia.</span></span></label>
<label class="check"><input type="checkbox" id="naDeploy" checked /><span><strong>Deploy right away</strong><br><span class="muted small">Start the containers as soon as the app is created.</span></span></label>
<p class="form-error" id="naError" hidden></p>
</div>
<footer class="dialog-actions">
<button type="button" class="btn ghost" data-close>Cancel</button>
<button type="submit" class="btn primary" id="naSubmit">Create app</button>
</footer>
</form>
</dialog>
<dialog id="confirmDlg" class="sm">
<form method="dialog">
<h2 id="cfTitle"></h2>
<div class="dialog-body" id="cfBody"></div>
<label class="check" id="cfCheckWrap"><input type="checkbox" id="cfCheck" /><span id="cfCheckLabel"></span></label>
<div class="field" id="cfTypeWrap" style="margin:10px 0 0">
<label for="cfType">Type <code id="cfTypeName"></code> to confirm</label>
<input id="cfType" class="input" autocomplete="off" spellcheck="false" />
</div>
<div class="dialog-actions">
<button value="cancel" class="btn ghost" id="cfCancel">Cancel</button>
<button value="ok" class="btn primary" id="cfOk">Confirm</button>
</div>
</form>
</dialog>
<script>
"use strict";
// ─── DOM helpers ───────────────────────────────────────────────────────────
const $ = (sel, root = document) => root.querySelector(sel);
const enc = encodeURIComponent;
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "dataset") Object.assign(el.dataset, v);
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (k === "value") el.value = v;
else if (k === "checked" || k === "disabled" || k === "hidden") el[k] = true;
else el.setAttribute(k, v === true ? "" : v);
}
for (const kid of kids.flat(Infinity)) {
if (kid == null || kid === false) continue;
el.append(kid instanceof Node ? kid : String(kid));
}
return el;
}
// replaceChildren() turns null into the text "null"; this skips empty slots like h() does.
function fill(el, ...kids) {
el.replaceChildren(...kids.flat(Infinity).filter((k) => k != null && k !== false));
}
const ICONS = {
play: '<polygon points="7 4 20 12 7 20 7 4"/>',
rotate: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
git: '<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4.5-6 3.5-10 6"/>',
more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
trash: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="M6 7l1 13h10l1-13"/>',
lock: '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
download: '<path d="M12 4v11"/><path d="M7 10l5 5 5-5"/><path d="M5 20h14"/>',
upload: '<path d="M12 20V9"/><path d="M7 14l5-5 5 5"/><path d="M5 4h14"/>',
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>',
x: '<path d="M6 6l12 12M18 6L6 18"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
alert: '<path d="M12 3.5l9.5 16.5h-19z"/><path d="M12 10v4"/><path d="M12 17.2v.1"/>',
activity: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
chevron: '<path d="M9 6l6 6-6 6"/>',
save: '<path d="M5 3h11l3 3v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M8 3v5h7"/><path d="M8 21v-7h8v7"/>',
logs: '<path d="M4 6h16M4 12h16M4 18h10"/>',
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
};
function icon(name, cls = "") {
const span = document.createElement("span");
span.className = "icon " + cls;
span.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ""}</svg>`;
return span;
}
function btn(label, iconName, onclick, variant = "", title = "") {
return h("button", { type: "button", class: `btn sm ${variant}`, title: title || null, onclick },
iconName ? icon(iconName) : null, label ? h("span", null, label) : null);
}
// ─── Formatting ────────────────────────────────────────────────────────────
function fmtBytes(n) {
if (n == null || isNaN(n)) return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
function ago(ms) {
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const hr = Math.round(m / 60);
if (hr < 24) return `${hr}h ago`;
const d = Math.round(hr / 24);
if (d < 30) return `${d}d ago`;
return new Date(ms).toLocaleDateString();
}
const fmtDate = (ms) => new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
const stripUrl = (u) => String(u || "").replace(/^https?:\/\//, "").replace(/\.git$/, "");
function summarizeError(text) {
const lines = String(text || "").split("\n").map((l) => l.trim()).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
if (/^error:/i.test(lines[i])) return lines[i].replace(/^error:\s*/i, "");
}
return lines[lines.length - 1] || "";
}
const outputOf = (res) => [res?.stdout, res?.stderr].filter(Boolean).join("\n").trim();
// ─── API client ────────────────────────────────────────────────────────────
class ApiError extends Error {
constructor(message, { status = 0, detail = "", busy = null } = {}) {
super(message);
this.status = status;
this.detail = detail;
this.busy = busy;
}
}
const api = {
async request(path, { method = "GET", json, body } = {}) {
const opts = { method, headers: { Accept: "application/json" }, redirect: "manual", credentials: "same-origin" };
if (json !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(json);
} else if (body !== undefined) {
opts.body = body;
}
let res;
try {
res = await fetch(path, opts);
} catch {
throw new ApiError("Can't reach the panel — check your connection.");
}
// Authelia answers an expired session with a redirect (or 401).
if (res.type === "opaqueredirect" || res.status === 401) {
session.expire();
throw new ApiError("Login session expired", { status: 401 });
}
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
throw new ApiError(`Unexpected response from the server (HTTP ${res.status})`, { status: res.status });
}
if (!res.ok || data.ok === false) {
const message = data.error || summarizeError(data.stderr) || summarizeError(data.stdout) || `HTTP ${res.status}`;
throw new ApiError(message, {
status: res.status,
detail: [data.stderr, data.stdout].filter(Boolean).join("\n").trim(),
busy: data.busy || null,
});
}
return data;
},
post: (path, json) => api.request(path, { method: "POST", json }),
overview: () => api.request("/status"),
getCompose: (n) => api.request(`/apps/${enc(n)}/compose`),
saveCompose: (n, content) => api.post(`/apps/${enc(n)}/compose`, { content }),
validate: (n) => api.post(`/apps/${enc(n)}/validate-compose`),
deploy: (n) => api.post(`/apps/${enc(n)}/deploy`),
restart: (n) => api.post(`/apps/${enc(n)}/restart`),
stop: (n) => api.post(`/apps/${enc(n)}/stop`),
repoPull: (n) => api.post(`/apps/${enc(n)}/repo-pull`),
getRepo: (n, fetchRemote) => api.request(`/apps/${enc(n)}/repo${fetchRemote ? "?fetch=1" : ""}`),
remove: (n, keepVolumes) => api.post(`/apps/${enc(n)}/remove`, { keepVolumes }),
getLogs: (n, tail) => api.request(`/apps/${enc(n)}/logs?tail=${enc(tail)}`),
setRoutes: (n, routes) => api.post(`/apps/${enc(n)}/routes`, { routes }),
getBackups: (n) => api.request(`/apps/${enc(n)}/backups`),
backup: (n) => api.post(`/apps/${enc(n)}/backup`),
restore: (n, file) => api.post(`/apps/${enc(n)}/restore`, { file }),
clearVolume: (n) => api.post(`/apps/${enc(n)}/volume-clear`),
getVolumes: (n) => api.request(`/apps/${enc(n)}/volumes`),
fileUrl: (n, path, vol, kind = "files") => `/apps/${enc(n)}/volume/${kind}?vol=${enc(vol || "default")}&path=${enc(path)}`,
getFiles: (n, path, vol) => api.request(api.fileUrl(n, path, vol)),
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
init: (payload) => api.post("/apps/init", payload),
};
const session = {
expired: false,
expire() {
if (this.expired) return;
this.expired = true;
$("#sessionBanner").hidden = false;
poller.stop();
renderSync();
},
};
// ─── State ─────────────────────────────────────────────────────────────────
const state = {
apps: new Map(), // name -> app from /status
loaded: false,
filter: "all",
query: "",
expanded: null,
tabs: {}, // name -> last active tab
localBusy: new Map(), // name -> operation started from this browser
dirty: new Set(), // "name:compose" / "name:routes" with unsaved edits
hashApplied: false,
};
const cards = new Map(); // name -> AppCard
const BUSY_LABELS = {
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
"repo-pull": "Syncing…", backup: "Backing up…", restore: "Restoring…", remove: "Removing…",
compose: "Saving…", routes: "Updating routes…", "volume-clear": "Clearing…", "render-route": "Rendering…",
};
const busyLabel = (b) => BUSY_LABELS[b] || "Working…";
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
function appState(app) {
const busy = busyOf(app);
if (busy) return { key: "busy", label: busyLabel(busy) };
const s = app.status || {};
if (s.state === "running") return { key: "running", label: "Running" };
if (s.state === "partial") return { key: "partial", label: `${s.running_count}/${s.total} running` };
if (s.state === "stopped") return { key: "stopped", label: "Stopped" };
return { key: "unknown", label: "Unknown" };
}
// ─── Polling / sync ────────────────────────────────────────────────────────
const poller = {
timer: null,
inflight: null,
again: false,
lastOk: 0,
failed: false,
error: "",
stopped: false,
schedule(ms) {
clearTimeout(this.timer);
if (this.stopped) return;
this.timer = setTimeout(() => this.tick(), ms);
},
async tick() {
if (document.hidden) return; // resumed by visibilitychange
await this.now();
const anyBusy = [...state.apps.values()].some((a) => busyOf(a));
this.schedule(anyBusy ? 2500 : 8000);
},
// Refresh now. Calls made while a request is in flight queue exactly one
// follow-up, so callers always see state from after their action finished.
now() {
if (this.stopped) return Promise.resolve();
if (this.inflight) { this.again = true; return this.inflight; }
this.inflight = (async () => {
renderSync();
try {
applyOverview(await api.overview());
this.lastOk = Date.now();
this.failed = false;
} catch (e) {
this.failed = true;
this.error = e.message;
if (!state.loaded && e.status !== 401) showLoadError(e);
} finally {
this.inflight = null;
renderSync();
if (this.again) { this.again = false; this.now(); }
}
})();
return this.inflight;
},
stop() { this.stopped = true; clearTimeout(this.timer); },
};
document.addEventListener("visibilitychange", () => {
if (document.hidden) return;
poller.schedule(0);
const card = cards.get(state.expanded);
card?.panels?.[card.activeTab]?.resume?.();
});
function renderSync() {
const el = $("#syncBtn");
let cls = "pending", label = "Connecting…", title = "Status refreshes automatically. Click to refresh now.";
if (session.expired) {
cls = "err"; label = "Signed out"; title = "Your login expired. Reload to sign in.";
} else if (poller.failed) {
cls = "err";
label = poller.lastOk ? `Offline · ${ago(poller.lastOk)}` : "Can't connect";
title = `Last error: ${poller.error}. Click to retry.`;
} else if (poller.lastOk) {
cls = "ok"; label = `Live · ${ago(poller.lastOk)}`;
}
el.className = `sync ${cls}${poller.inflight ? " syncing" : ""}`;
el.title = title;
$("#syncText").textContent = label;
}
function applyOverview(data) {
state.apps = new Map((data.apps || []).map((a) => [a.name, a]));
state.loaded = true;
if (state.expanded && !state.apps.has(state.expanded)) {
state.expanded = null;
setHash();
}
renderList();
applyHashOnce();
}
function showLoadError(e) {
fill($("#appList"), h("div", { class: "empty" },
h("h3", null, "Couldn't load apps"),
h("p", null, e.message),
h("button", { type: "button", class: "btn", onclick: () => poller.now() }, icon("rotate"), "Try again")));
}
// ─── App list ──────────────────────────────────────────────────────────────
function matchesFilter(app) {
const st = app.status?.state;
if (state.filter === "running" && st !== "running") return false;
if (state.filter === "stopped" && st !== "stopped") return false;
if (state.filter === "attention" && !(st === "partial" || st === "unknown" || !st)) return false;
const q = state.query.trim().toLowerCase();
if (!q) return true;
return app.name.includes(q)
|| app.routes.some((r) => r.domain.toLowerCase().includes(q) || r.upstream.includes(q))
|| (app.repo_url || "").toLowerCase().includes(q);
}
function renderFilters() {
const all = [...state.apps.values()];
const counts = {
all: all.length,
running: all.filter((a) => a.status?.state === "running").length,
stopped: all.filter((a) => a.status?.state === "stopped").length,
attention: all.filter((a) => ["partial", "unknown", undefined].includes(a.status?.state)).length,
};
const defs = [["all", "All"], ["running", "Running"], ["stopped", "Stopped"], ["attention", "Needs attention"]];
fill($("#filters"), ...defs
.filter(([k]) => k !== "attention" || counts.attention || state.filter === k)
.map(([k, label]) => h("button", {
type: "button",
class: `chip${state.filter === k ? " active" : ""}`,
onclick: () => { state.filter = k; renderList(); },
}, label, h("span", { class: "chip-count" }, counts[k]))));
}
function renderList() {
renderFilters();
if (!state.loaded) return;
const list = $("#appList");
const apps = [...state.apps.values()].sort((a, b) => a.name.localeCompare(b.name));
for (const [name, card] of cards) {
if (!state.apps.has(name)) { card.destroy(); cards.delete(name); }
}
const visible = apps.filter(matchesFilter);
if (!visible.length) {
fill(list, apps.length
? h("div", { class: "empty" },
h("h3", null, "No matching apps"),
h("p", null, "Try a different search or filter."),
h("button", { type: "button", class: "btn", onclick: clearFilters }, "Clear filters"))
: h("div", { class: "empty" },
h("h3", null, "No apps yet"),
h("p", null, "Create an app from a compose file or a git repository — the panel sets up its domain and HTTPS for you."),
h("button", { type: "button", class: "btn primary", onclick: () => newApp.open() }, icon("plus"), "Create your first app")));
return;
}
const wanted = visible.map((app) => {
let card = cards.get(app.name);
if (!card) { card = new AppCard(app.name); cards.set(app.name, card); }
card.update(app);
return card.root;
});
for (const child of [...list.children]) {
if (!wanted.includes(child)) child.remove();
}
wanted.forEach((el, i) => {
if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null);
});
}
function clearFilters() {
state.filter = "all";
state.query = "";
$("#search").value = "";
renderList();
}
function refreshCard(name) {
const app = state.apps.get(name);
if (app) cards.get(name)?.update(app);
}
function domainLinks(routes, max = Infinity) {
const domains = [...new Set(routes.map((r) => r.domain))];
const out = domains.slice(0, max).map((d) => d.startsWith("*.")
? h("span", { class: "domain", title: d }, d)
: h("a", { class: "domain", href: `https://${d}`, target: "_blank", rel: "noopener", title: `Open ${d}` }, d));
if (domains.length > max) out.push(h("span", { class: "more" }, `+${domains.length - max} more`));
return out;
}
// ─── Expand / hash ─────────────────────────────────────────────────────────
function toggleExpand(name, tab) {
const card = cards.get(name);
if (!card) return;
if (state.expanded === name && !tab) {
card.collapse();
state.expanded = null;
setHash();
return;
}
if (state.expanded && state.expanded !== name) cards.get(state.expanded)?.collapse();
state.expanded = name;
if (tab) card.activeTab = tab;
card.expand();
}
function setHash(name, tab) {
const url = name ? `#/${name}/${tab || "overview"}` : location.pathname + location.search;
history.replaceState(null, "", url);
}
function applyHash() {
const m = /^#\/([a-z0-9-]+)(?:\/([a-z]+))?/.exec(location.hash);
if (m && cards.has(m[1])) {
toggleExpand(m[1], m[2] || "overview");
requestAnimationFrame(() => cards.get(m[1])?.root.scrollIntoView({ block: "start" }));
}
}
function applyHashOnce() {
if (state.hashApplied) return;
state.hashApplied = true;
applyHash();
}
// setHash uses replaceState, which doesn't fire this — only pasted links / back-forward do.
window.addEventListener("hashchange", applyHash);
// ─── App card ──────────────────────────────────────────────────────────────
const TABS = [
["overview", "Overview"], ["compose", "Compose"], ["logs", "Logs"], ["routes", "Routes"],
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
];
class AppCard {
constructor(name) {
this.name = name;
this.panels = null;
this.activeTab = state.tabs[name] || "overview";
this.pill = h("span", { class: "pill" });
this.sub = h("div", { class: "app-sub" });
this.deployBtn = h("button", {
type: "button", class: "btn primary sm", title: "Deploy — pull images, (re)create containers and reload routes",
onclick: () => actions.deploy(name),
}, icon("play"), h("span", { class: "hide-sm" }, "Deploy"));
this.menuBtn = h("button", {
type: "button", class: "btn ghost sm icon-only", title: "More actions", "aria-label": "More actions", "aria-haspopup": "menu",
onclick: () => openAppMenu(this.menuBtn, name),
}, icon("more"));
this.head = h("header", {
class: "app-head", tabindex: "0", role: "button", "aria-expanded": "false",
onclick: (e) => { if (!e.target.closest("button, a")) toggleExpand(name); },
onkeydown: (e) => {
if ((e.key === "Enter" || e.key === " ") && e.target === this.head) { e.preventDefault(); toggleExpand(name); }
},
},
icon("chevron", "chev"),
this.pill,
h("div", { class: "app-title" }, h("h3", { class: "app-name" }, name), this.sub),
h("div", { class: "app-actions" }, this.deployBtn, this.menuBtn));
this.body = h("div", { class: "app-body", hidden: true });
this.root = h("article", { class: "app", dataset: { name } }, this.head, this.body);
}
get app() { return state.apps.get(this.name); }
update(app) {
const st = appState(app);
const pillSig = st.key + st.label;
if (pillSig !== this.pillSig) {
this.pillSig = pillSig;
this.pill.className = `pill pill-${st.key}`;
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
}
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch]);
if (subSig !== this.subSig) {
this.subSig = subSig;
fill(this.sub,
...domainLinks(app.routes, 2),
app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null,
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_url) }, icon("git"), app.repo_branch || "git") : null);
}
this.deployBtn.disabled = st.key === "busy";
if (this.panels) {
this.syncTabs(app);
for (const p of Object.values(this.panels)) p.update?.(app);
}
}
build() {
this.panels = {
overview: new OverviewPanel(this, "overview"),
compose: new ComposePanel(this, "compose"),
logs: new LogsPanel(this, "logs"),
routes: new RoutesPanel(this, "routes"),
files: new FilesPanel(this, "files"),
backups: new BackupsPanel(this, "backups"),
source: new SourcePanel(this, "source"),
};
this.tabBtns = {};
const tabbar = h("div", { class: "tabs", role: "tablist" });
for (const [key, label] of TABS) {
const tabBtn = h("button", { type: "button", class: "tab", role: "tab", onclick: () => this.showTab(key) },
label, h("span", { class: "tab-dot", hidden: true, title: "Unsaved changes" }));
this.tabBtns[key] = tabBtn;
tabbar.append(tabBtn);
}
this.body.append(tabbar, ...Object.values(this.panels).map((p) => p.el));
const app = this.app;
this.syncTabs(app);
for (const p of Object.values(this.panels)) p.update?.(app);
}
syncTabs(app) {
this.tabBtns.source.hidden = !app.repo_url;
if (!app.repo_url && this.activeTab === "source") this.showTab("overview");
}
expand() {
if (!this.panels) this.build();
this.body.hidden = false;
this.root.classList.add("open");
this.head.setAttribute("aria-expanded", "true");
this.showTab(this.activeTab, true);
}
collapse() {
if (this.panels) this.panels[this.activeTab]?.hide?.();
this.body.hidden = true;
this.root.classList.remove("open");
this.head.setAttribute("aria-expanded", "false");
}
showTab(key, force = false) {
if (!this.panels[key] || (key === "source" && !this.app?.repo_url)) key = "overview";
if (key === this.activeTab && !force) return;
if (key !== this.activeTab) this.panels[this.activeTab]?.hide?.();
this.activeTab = key;
state.tabs[this.name] = key;
for (const [k, b] of Object.entries(this.tabBtns)) {
b.classList.toggle("active", k === key);
b.setAttribute("aria-selected", String(k === key));
}
for (const [k, p] of Object.entries(this.panels)) p.el.hidden = k !== key;
this.panels[key].show?.();
if (state.expanded === this.name) setHash(this.name, key);
}
setDirty(key, dirty) {
const id = `${this.name}:${key}`;
if (dirty) state.dirty.add(id); else state.dirty.delete(id);
const dot = this.tabBtns?.[key]?.querySelector(".tab-dot");
if (dot) dot.hidden = !dirty;
}
destroy() {
this.collapse();
this.root.remove();
state.dirty.delete(`${this.name}:compose`);
state.dirty.delete(`${this.name}:routes`);
}
}
class Panel {
constructor(card, key) {
this.card = card;
this.key = key;
this.name = card.name;
this.el = h("section", { class: "panel", role: "tabpanel", hidden: true });
}
get app() { return state.apps.get(this.name); }
get visible() { return state.expanded === this.name && this.card.activeTab === this.key && !document.hidden; }
}
// ── Overview ──
class OverviewPanel extends Panel {
update(app) {
const s = app.status || {};
const containers = s.containers || [];
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state]);
if (sig === this.sig) return;
this.sig = sig;
const containerBox = h("div", { class: "box" }, h("h4", null, "Containers"),
containers.length
? h("table", { class: "table" },
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "State"), h("th", { class: "hide-sm" }, "Image"))),
h("tbody", null, containers.map((c) => h("tr", null,
h("td", { class: "mono" }, c.name),
h("td", null, h("span", { class: `pill sm pill-${c.running ? "running" : "stopped"}` }, h("span", { class: "dot" }), c.status || c.state)),
h("td", { class: "mono muted hide-sm" }, c.image)))))
: h("p", { class: "muted" }, s.raw ? s.raw : "No containers. ",
s.raw ? null : h("button", { type: "button", class: "link", onclick: () => actions.deploy(this.name) }, "Deploy"),
s.raw ? null : " to start the app."));
const infoBox = h("div", { class: "box" }, h("h4", null, "Routes"),
h("ul", { class: "route-list" }, app.routes.map((r) => h("li", null,
domainLinks([r])[0],
h("span", { class: "arrow" }, "→"),
h("span", { class: "mono" }, r.upstream),
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
h("dl", { class: "facts" },
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
h("dt", null, "Source"), h("dd", null, app.repo_url ? `${stripUrl(app.repo_url)} @ ${app.repo_branch || "default"}` : "Compose file"),
h("dt", null, "Compose"), h("dd", { class: "mono small" }, app.compose_file || "—")));
fill(this.el, h("div", { class: "grid-2" }, containerBox, infoBox));
}
}
// ── Compose editor ──
class ComposePanel extends Panel {
constructor(card, key) {
super(card, key);
this.loaded = false;
this.original = "";
this.notice = h("div", { class: "notice", hidden: true }, icon("git"),
h("span", null, "This file comes from the git repository. Edits here are overwritten the next time the app syncs — commit changes to the repository instead."));
this.ta = h("textarea", {
class: "code", spellcheck: "false", rows: "20", placeholder: "Loading…", "aria-label": "compose.yaml",
oninput: () => this.onInput(),
onkeydown: (e) => this.onKey(e),
});
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
this.notice,
this.ta,
h("div", { class: "row-actions" },
btn("Save", "save", () => this.save(), "primary", "Save (Ctrl+S)"),
btn("Save & deploy", "play", () => this.saveAndDeploy()),
btn("Validate", "check", () => this.validate(), "", "Check the compose file for errors"),
btn("Revert", null, () => this.revert(), "ghost", "Discard edits and reload from disk"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
get isDirty() { return this.loaded && this.ta.value !== this.original; }
update(app) { this.notice.hidden = !app.repo_url; }
show() { if (!this.loaded) this.load(); }
async load() {
this.ta.placeholder = "Loading…";
try {
const d = await api.getCompose(this.name);
this.original = d.content || "";
this.ta.value = this.original;
this.loaded = true;
this.onInput();
} catch (e) {
this.ta.placeholder = `Couldn't load the compose file: ${e.message}`;
}
}
onInput() {
const dirty = this.isDirty;
this.dirtyLabel.hidden = !dirty;
this.card.setDirty("compose", dirty);
}
onKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
this.save();
} else if (e.key === "Tab" && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
this.ta.setRangeText(" ", this.ta.selectionStart, this.ta.selectionEnd, "end");
this.onInput();
}
}
async save({ quiet = false } = {}) {
if (!this.loaded) return false;
const content = this.ta.value;
if (!content.trim()) { toast("error", "The compose file can't be empty"); return false; }
const res = await runOp(this.name, "compose", `Save compose · ${this.name}`,
() => api.saveCompose(this.name, content), { success: quiet ? null : "Compose file saved" });
if (!res) return false;
this.original = content;
this.onInput();
return true;
}
async saveAndDeploy() {
if (this.isDirty && !(await this.save({ quiet: true }))) return;
actions.deploy(this.name);
}
async validate() {
if (this.isDirty && !(await this.save({ quiet: true }))) return;
await runOp(this.name, null, `Validate compose · ${this.name}`, () => api.validate(this.name),
{ success: "Compose file is valid", refresh: false });
}
async revert() {
if (this.isDirty) {
const ok = await confirmDialog({ title: "Discard your edits?", body: [h("p", null, "The compose file is reloaded from disk.")], confirmLabel: "Discard", danger: true });
if (!ok) return;
}
this.loaded = false;
await this.load();
}
reloadIfClean() {
if (this.isDirty) return;
this.loaded = false;
if (this.visible) this.load();
}
}
// ── Logs ──
class LogsPanel extends Panel {
constructor(card, key) {
super(card, key);
this.tail = h("select", { class: "select sm", onchange: () => this.load(true) },
[100, 300, 1000, 3000].map((n) => h("option", { value: String(n) }, `${n} lines`)));
this.tail.value = "300";
this.follow = h("input", { type: "checkbox", checked: true, onchange: () => this.schedule() });
this.meta = h("span", { class: "muted small" });
this.out = h("pre", { class: "logs", tabindex: "0" }, "Loading…");
this.el.append(
h("div", { class: "row-actions" },
h("label", { class: "inline" }, "Show", this.tail),
h("label", { class: "toggle", title: "Refresh every few seconds while this tab is open" }, this.follow, "Follow"),
btn("Refresh", "rotate", () => this.load(), "ghost"),
btn("Copy", "copy", () => this.copy(), "ghost"),
h("span", { class: "spacer" }),
this.meta),
this.out);
}
show() { this.load(); }
resume() { if (this.visible) this.load(); }
hide() { clearTimeout(this.timer); this.timer = null; }
schedule() {
clearTimeout(this.timer);
if (this.follow.checked && this.visible) this.timer = setTimeout(() => this.load(), 4000);
}
async load(scrollToEnd = false) {
clearTimeout(this.timer);
if (this.loading) return;
this.loading = true;
const out = this.out;
const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
try {
const d = await api.getLogs(this.name, this.tail.value);
const text = d.logs || "No log output yet.";
if (text !== out.textContent) {
out.textContent = text;
if (atBottom || scrollToEnd || !this.hasLoaded) out.scrollTop = out.scrollHeight;
}
this.hasLoaded = true;
this.meta.textContent = `Updated ${new Date().toLocaleTimeString()}`;
} catch (e) {
this.meta.textContent = `Couldn't load logs: ${e.message}`;
} finally {
this.loading = false;
this.schedule();
}
}
copy() {
navigator.clipboard?.writeText(this.out.textContent)
.then(() => toast("success", "Logs copied to clipboard"), () => toast("error", "Couldn't copy logs"));
}
}
// ── Routes ──
function normRoutes(routes) {
return routes
.map((r) => ({ domain: (r.domain || "").trim(), upstream: (r.upstream || "").trim(), path: (r.path || "").trim() }))
.filter((r) => r.domain || r.upstream || r.path)
.map((r) => (r.path ? r : { domain: r.domain, upstream: r.upstream }));
}
function validateDomain(v) {
if (!v) return "Every route needs a domain.";
const core = v.startsWith("*.") ? v.slice(2) : v;
if (!/^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(core) || !v.includes(".")) return `"${v}" isn't a valid domain.`;
return null;
}
function validateUpstream(v) {
if (!v) return "Every route needs an upstream (host:port).";
const m = /^[^\s:|,]+:(\d+)$/.exec(v);
if (!m) return `Upstream "${v}" should look like 127.0.0.1:18080.`;
const port = Number(m[1]);
if (port < 1024 || port > 65535) return `Port ${port} must be between 1024 and 65535.`;
return null;
}
function routeEditor({ onChange = () => {} } = {}) {
const rows = h("div");
const el = h("div", null,
h("div", { class: "route-row route-head" }, h("span", null, "Domain"), h("span"), h("span", null, "Upstream"), h("span", null, "Path"), h("span")),
rows);
function changed() {
for (const row of rows.children) for (const i of Object.values(row._inputs)) i.classList.remove("invalid");
onChange();
}
function addRow(r = {}, focus = false) {
const d = h("input", { class: "input", placeholder: "app.example.com", value: r.domain || "", oninput: changed, "aria-label": "Domain", spellcheck: "false" });
const u = h("input", { class: "input", placeholder: "127.0.0.1:18080", value: r.upstream || "", oninput: changed, "aria-label": "Upstream", spellcheck: "false" });
const p = h("input", { class: "input route-path", placeholder: "/path/* (optional)", value: r.path || "", oninput: changed, "aria-label": "Path (optional)", spellcheck: "false" });
const row = h("div", { class: "route-row" }, d, h("span", { class: "arrow" }, "→"), u, p,
h("button", {
type: "button", class: "btn ghost sm icon-only", title: "Remove route", "aria-label": "Remove route",
onclick: () => { row.remove(); if (!rows.children.length) addRow(); changed(); },
}, icon("x")));
row._inputs = { d, u, p };
rows.append(row);
if (focus) d.focus();
}
return {
el,
add() { addRow({}, true); changed(); },
set(routes) { fill(rows, ); (routes.length ? routes : [{}]).forEach((r) => addRow(r)); },
get() { return [...rows.children].map((r) => ({ domain: r._inputs.d.value, upstream: r._inputs.u.value, path: r._inputs.p.value })); },
first() { return rows.children[0]?._inputs; },
validate() {
let first = null, count = 0;
for (const row of rows.children) {
const { d, u, p } = row._inputs;
const dv = d.value.trim(), uv = u.value.trim(), pv = p.value.trim();
if (!dv && !uv && !pv) continue;
count++;
const de = validateDomain(dv);
if (de) { d.classList.add("invalid"); first ??= de; }
const ue = validateUpstream(uv);
if (ue) { u.classList.add("invalid"); first ??= ue; }
if (pv && (!pv.startsWith("/") || /[\s|,]/.test(pv))) { p.classList.add("invalid"); first ??= `Path "${pv}" must start with / and contain no spaces, commas or pipes.`; }
}
return count ? first : "Add at least one route.";
},
};
}
class RoutesPanel extends Panel {
constructor(card, key) {
super(card, key);
this.original = "";
this.dirty = false;
this.editor = routeEditor({ onChange: () => this.onChange() });
this.err = h("p", { class: "form-error", hidden: true });
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
h("div", { class: "notice" }, icon("layers"),
h("span", null, "Changes apply immediately: Caddy reloads on its own and containers keep running. If you route to a new port, publish it in the compose file too.")),
this.editor.el,
this.err,
h("div", { class: "row-actions" },
btn("Add route", "plus", () => this.editor.add(), "ghost"),
btn("Save routes", "save", () => this.save(), "primary"),
btn("Revert", null, () => this.revert(), "ghost"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
update(app) {
const sig = JSON.stringify(normRoutes(app.routes));
if (!this.dirty && sig !== this.original) {
this.original = sig;
this.editor.set(app.routes);
}
}
onChange() {
this.dirty = JSON.stringify(normRoutes(this.editor.get())) !== this.original;
this.dirtyLabel.hidden = !this.dirty;
this.err.hidden = true;
this.card.setDirty("routes", this.dirty);
}
revert() {
this.dirty = false;
this.original = "";
this.update(this.app);
this.onChange();
}
async save() {
const error = this.editor.validate();
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
const routes = normRoutes(this.editor.get());
const res = await runOp(this.name, "routes", `Update routes · ${this.name}`, () => api.setRoutes(this.name, routes),
{ success: "Routes updated", successDetail: () => "Caddy is reloading with the new routes." });
if (!res) return;
this.original = JSON.stringify(routes);
const app = this.app;
if (app) app.routes = routes;
this.onChange();
}
}
// ── Files ──
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
class FilesPanel extends Panel {
constructor(card, key) {
super(card, key);
this.vol = "default";
this.path = "";
this.volSel = h("select", {
class: "select sm", hidden: true, "aria-label": "Volume",
onchange: () => { this.vol = this.volSel.value; this.path = ""; this.clearBtn.hidden = this.vol !== "default"; this.load(); },
});
this.crumbs = h("nav", { class: "crumbs", "aria-label": "Folder" });
this.fileInput = h("input", { type: "file", multiple: true, hidden: true, onchange: () => { this.upload([...this.fileInput.files]); this.fileInput.value = ""; } });
this.clearBtn = btn("Clear data", "trash", () => this.clear(), "ghost danger", "Delete everything in the app's data folder");
this.list = h("div", { class: "file-list" }, h("div", { class: "empty-sm" }, "Loading…"));
this.el.append(
h("div", { class: "row-actions" },
this.volSel, this.crumbs, h("span", { class: "spacer" }),
btn("Upload", "upload", () => this.fileInput.click(), "", "Upload files (or drop them here)"),
btn("Refresh", "rotate", () => this.load(), "ghost"),
this.clearBtn),
this.fileInput,
this.list,
h("div", { class: "dropzone" }, icon("upload"), "Drop files to upload"));
let depth = 0;
this.el.addEventListener("dragenter", (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth++; this.el.classList.add("dragging"); });
this.el.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); });
this.el.addEventListener("dragleave", () => { if (--depth <= 0) { depth = 0; this.el.classList.remove("dragging"); } });
this.el.addEventListener("drop", (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth = 0;
this.el.classList.remove("dragging");
this.upload([...e.dataTransfer.files]);
});
}
async show() {
if (!this.volumesLoaded) await this.loadVolumes();
this.load();
}
async loadVolumes() {
try {
const d = await api.getVolumes(this.name);
const keys = Object.keys(d.volumes || {});
if (!keys.length) keys.push("default");
fill(this.volSel, ...keys.map((k) => h("option", { value: k }, k === "default" ? "App data" : k)));
this.vol = keys.includes(this.vol) ? this.vol : keys[0];
this.volSel.value = this.vol;
this.volSel.hidden = keys.length < 2;
this.clearBtn.hidden = this.vol !== "default";
this.volumesLoaded = true;
} catch {
// Fall back to the default volume.
}
}
go(path) { this.path = path; this.load(); }
renderCrumbs() {
const parts = this.path ? this.path.split("/") : [];
const items = [h("button", { type: "button", class: "crumb", onclick: () => this.go("") }, icon("folder"), this.vol === "default" ? "data" : this.vol)];
parts.forEach((p, i) => {
items.push(h("span", { class: "crumb-sep" }, "/"));
items.push(h("button", { type: "button", class: "crumb", onclick: () => this.go(parts.slice(0, i + 1).join("/")) }, p));
});
fill(this.crumbs, ...items);
}
async load() {
this.renderCrumbs();
const { path, vol } = this;
try {
const d = await api.getFiles(this.name, path, vol);
if (path !== this.path || vol !== this.vol) return;
const files = d.files || [];
const rows = [];
if (path) {
rows.push(h("div", { class: "file-row dir", onclick: () => this.go(path.split("/").slice(0, -1).join("/")) },
icon("folder"), h("span", { class: "file-name" }, ".."), h("span"), h("span"), h("span")));
}
for (const f of files) {
const full = path ? `${path}/${f.name}` : f.name;
rows.push(h("div", {
class: `file-row${f.is_dir ? " dir" : ""}`,
onclick: f.is_dir ? (e) => { if (!e.target.closest("button, a")) this.go(full); } : null,
},
icon(f.is_dir ? "folder" : "file"),
h("span", { class: "file-name", title: f.name }, f.name),
h("span", { class: "muted small right" }, f.is_dir ? "" : fmtBytes(f.size)),
h("span", { class: "muted small", title: f.mtime ? fmtDate(f.mtime * 1000) : "" }, f.mtime ? ago(f.mtime * 1000) : ""),
h("span", { class: "file-actions" },
f.is_dir ? null : h("a", { class: "btn ghost sm icon-only", title: "Download", "aria-label": `Download ${f.name}`, href: api.fileUrl(this.name, full, vol, "download"), download: f.name }, icon("download")),
h("button", { type: "button", class: "btn ghost danger sm icon-only", title: "Delete", "aria-label": `Delete ${f.name}`, onclick: () => this.remove(full, f) }, icon("trash")))));
}
if (!files.length) rows.push(h("div", { class: "empty-sm" }, "This folder is empty. Drop files here to upload them."));
fill(this.list, ...rows);
} catch (e) {
fill(this.list, h("div", { class: "empty-sm error" }, `Couldn't load files: ${e.message}`));
}
}
async upload(files) {
if (!files.length) return;
const dir = this.path, vol = this.vol;
const label = files.length === 1 ? `Upload ${files[0].name}` : `Upload ${files.length} files`;
await runOp(this.name, null, `${label} · ${this.name}`, async () => {
for (const f of files) await api.upload(this.name, dir ? `${dir}/${f.name}` : f.name, vol, f);
return { stdout: files.map((f) => `${f.name} (${fmtBytes(f.size)})`).join("\n") };
}, { success: files.length === 1 ? `Uploaded ${files[0].name}` : `Uploaded ${files.length} files`, refresh: false });
this.load();
}
async remove(full, f) {
const ok = await confirmDialog({
title: `Delete ${f.is_dir ? "folder" : "file"}?`,
body: [h("p", null, "This permanently deletes ", h("code", null, full), f.is_dir ? " and everything inside it." : ".")],
confirmLabel: "Delete", danger: true,
});
if (!ok) return;
await runOp(this.name, null, `Delete ${full} · ${this.name}`, () => api.deleteFile(this.name, full, this.vol),
{ success: `Deleted ${f.name}`, refresh: false });
this.load();
}
async clear() {
const ok = await confirmDialog({
title: `Clear all data for ${this.name}?`,
body: [h("p", null, "The app is stopped and everything in its data folder is permanently deleted. Consider creating a backup first.")],
confirmLabel: "Clear data", danger: true, typeToConfirm: this.name,
});
if (!ok) return;
await runOp(this.name, "volume-clear", `Clear data · ${this.name}`, () => api.clearVolume(this.name), { success: "Data cleared" });
this.path = "";
this.load();
}
}
// ── Backups ──
class BackupsPanel extends Panel {
constructor(card, key) {
super(card, key);
this.list = h("div", { class: "file-list" }, h("div", { class: "empty-sm" }, "Loading…"));
this.el.append(
h("div", { class: "row-actions" },
btn("Create backup", "plus", () => this.create(), "primary"),
btn("Refresh", "rotate", () => this.load(), "ghost"),
h("span", { class: "muted small" }, "A backup zips the data folder and compose file. The app is stopped briefly while it runs.")),
this.list);
}
show() { this.load(); }
async load() {
try {
const d = await api.getBackups(this.name);
const backups = (d.backups || []).sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
if (!backups.length) {
fill(this.list, h("div", { class: "empty-sm" }, "No backups yet."));
return;
}
fill(this.list, ...backups.map((b) => h("div", { class: "file-row backup" },
icon("file"),
h("span", { class: "file-name" },
h("strong", null, b.mtime ? fmtDate(b.mtime * 1000) : b.name),
h("span", { class: "muted small mono block" }, b.name)),
h("span", { class: "muted small right" }, b.size || ""),
h("span", { class: "muted small" }, b.mtime ? ago(b.mtime * 1000) : ""),
h("span", { class: "file-actions" },
h("a", { class: "btn ghost sm", href: `/apps/${enc(this.name)}/backups/${enc(b.name)}`, download: b.name, title: "Download" },
icon("download"), h("span", { class: "hide-sm" }, "Download")),
h("button", { type: "button", class: "btn ghost sm", title: "Restore", onclick: () => this.restore(b) },
icon("rotate"), h("span", { class: "hide-sm" }, "Restore"))))));
} catch (e) {
fill(this.list, h("div", { class: "empty-sm error" }, `Couldn't load backups: ${e.message}`));
}
}
async create() {
const res = await runOp(this.name, "backup", `Backup · ${this.name}`, () => api.backup(this.name), { success: "Backup created" });
if (res) this.load();
}
async restore(b) {
const choice = await confirmDialog({
title: `Restore ${this.name}?`,
body: [h("p", null, "The app is stopped and its data folder is replaced with the contents of ", h("code", null, b.name), ". Anything written since then is lost unless you back it up first.")],
confirmLabel: "Restore", danger: true,
checkbox: { label: "Start the app again afterwards", checked: true },
});
if (!choice) return;
const res = await runOp(this.name, "restore", `Restore ${b.name} · ${this.name}`, () => api.restore(this.name, b.name),
{ success: "Backup restored" });
if (res && choice.checked) actions.deploy(this.name);
}
}
// ── Git source ──
function commitView(c) {
return h("span", { class: "commit" },
h("code", null, c.short), h("span", null, c.subject),
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
}
class SourcePanel extends Panel {
constructor(card, key) {
super(card, key);
this.info = h("div", null, h("p", { class: "muted" }, "Loading…"));
this.check = h("div", { class: "update-box", hidden: true });
this.el.append(
this.info,
this.check,
h("div", { class: "row-actions", style: "margin-top:14px" },
btn("Check for updates", "rotate", () => this.load(true)),
btn("Sync & deploy", "git", () => actions.sync(this.name), "primary", "Fetch the branch, reset to it and redeploy")));
}
show() { this.load(false); }
async load(fetchRemote) {
if (fetchRemote) {
this.check.hidden = false;
fill(this.check, h("span", { class: "spinner" }), "Checking the remote…");
}
try {
this.render(await api.getRepo(this.name, fetchRemote), fetchRemote);
} catch (e) {
const target = fetchRemote ? this.check : this.info;
target.hidden = false;
fill(target, h("span", { class: "error" }, e.message));
}
}
render(d, fetched) {
const url = /^https?:\/\//.test(d.url) ? d.url : null;
fill(this.info,
h("dl", { class: "facts" },
h("dt", null, "Repository"), h("dd", null, url ? h("a", { href: url, target: "_blank", rel: "noopener" }, stripUrl(url)) : d.url),
h("dt", null, "Branch"), h("dd", { class: "mono" }, d.branch || "default"),
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
d.dirty ? h("div", { class: "notice warn", style: "margin-top:12px" }, icon("alert"),
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null);
if (!fetched) return;
this.check.hidden = false;
if (d.fetch_error) {
fill(this.check, h("span", { class: "error" }, `Couldn't reach the remote: ${d.fetch_error}`));
} else if (d.behind === 0) {
fill(this.check, icon("check"), "Up to date with ", h("code", null, `origin/${d.branch}`), ".");
} else {
const n = d.behind;
fill(this.check,
h("strong", null, n == null ? "New commits available" : `${n} new commit${n === 1 ? "" : "s"}`),
d.remote ? h("span", null, " — latest: ", commitView(d.remote)) : null);
}
}
}
// ─── Operations ────────────────────────────────────────────────────────────
async function runOp(name, kind, label, fn, opts = {}) {
if (name && kind && state.localBusy.has(name)) {
toast("info", `${name} is busy`, { detail: `Wait for "${busyLabel(state.localBusy.get(name))}" to finish.` });
return null;
}
const entry = activity.start(label);
if (name && kind) { state.localBusy.set(name, kind); refreshCard(name); }
try {
const res = (await fn()) || {};
entry.finish(true, outputOf(res));
if (opts.success !== null) {
toast("success", opts.success || label, { detail: opts.successDetail ? opts.successDetail(res) : null });
}
return res;
} catch (e) {
entry.finish(false, e.detail || e.message);
if (e.status !== 401) {
toast("error", `${label} failed`, {
detail: e.message,
action: e.detail ? { label: "Show output", run: () => activity.open(entry.id) } : null,
timeout: 12000,
});
}
return null;
} finally {
if (name && kind) { state.localBusy.delete(name); refreshCard(name); }
if (opts.refresh !== false) poller.now();
}
}
const actions = {
deploy(name) {
return runOp(name, "deploy", `Deploy · ${name}`, () => api.deploy(name), { success: `${name} deployed` });
},
restart(name) {
return runOp(name, "restart", `Restart · ${name}`, () => api.restart(name), { success: `${name} restarted` });
},
async stop(name) {
const ok = await confirmDialog({
title: `Stop ${name}?`,
body: [h("p", null, "Its containers are stopped and removed; data folders are kept. The app's domains return errors until you deploy again.")],
confirmLabel: "Stop app",
});
if (ok) return runOp(name, "stop", `Stop · ${name}`, () => api.stop(name), { success: `${name} stopped` });
},
async sync(name) {
const res = await runOp(name, "repo-pull", `Sync from git · ${name}`, () => api.repoPull(name), {
success: `${name} synced`,
successDetail: (r) => r.after
? (r.changed ? `Now at ${r.after.short}: ${r.after.subject}` : `Already at ${r.after.short} — redeployed.`)
: "Redeployed.",
});
const card = cards.get(name);
if (res && card?.panels) {
card.panels.compose.reloadIfClean();
if (card.activeTab === "source") card.panels.source.load(false);
}
return res;
},
async remove(name) {
const choice = await confirmDialog({
title: `Remove ${name}?`,
body: [h("p", null, "Stops the app and removes its routes and compose stack. Backups are kept.")],
confirmLabel: "Remove app", danger: true,
checkbox: { label: "Also permanently delete its data folder", checked: false },
typeWhenChecked: name,
});
if (!choice) return;
const res = await runOp(name, "remove", `Remove · ${name}`, () => api.remove(name, !choice.checked), {
success: `${name} removed`,
successDetail: () => (choice.checked ? "Its data was deleted." : "Its data folder was kept."),
});
if (res && state.expanded === name) { state.expanded = null; setHash(); }
},
};
// ─── Menu ──────────────────────────────────────────────────────────────────
const menu = {
anchor: null,
open(anchor, items) {
if (this.anchor === anchor) { this.close(); return; }
this.close();
const el = $("#menu");
fill(el, ...items.map((it) => it === "-"
? h("div", { class: "menu-sep" })
: h("button", {
type: "button", role: "menuitem", class: `menu-item ${it.variant || ""}`, disabled: it.disabled,
onclick: () => { this.close(); it.run(); },
}, icon(it.icon), it.label)));
el.hidden = false;
const r = anchor.getBoundingClientRect();
const left = Math.max(8, Math.min(r.right - el.offsetWidth, innerWidth - el.offsetWidth - 8));
let top = r.bottom + 6;
if (top + el.offsetHeight > innerHeight - 8) top = Math.max(8, r.top - el.offsetHeight - 6);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
this.anchor = anchor;
el.querySelector("button:not(:disabled)")?.focus();
},
close() {
$("#menu").hidden = true;
this.anchor = null;
},
};
function openAppMenu(anchor, name) {
const app = state.apps.get(name);
if (!app) return;
const busy = !!busyOf(app);
menu.open(anchor, [
{ label: "Restart", icon: "rotate", run: () => actions.restart(name), disabled: busy },
{ label: "Stop", icon: "stop", run: () => actions.stop(name), disabled: busy },
app.repo_url ? { label: "Sync from git", icon: "git", run: () => actions.sync(name), disabled: busy } : null,
{ label: "View logs", icon: "logs", run: () => toggleExpand(name, "logs") },
{ label: "Edit compose", icon: "file", run: () => toggleExpand(name, "compose") },
"-",
{ label: "Remove…", icon: "trash", run: () => actions.remove(name), variant: "danger", disabled: busy },
].filter(Boolean));
}
document.addEventListener("click", (e) => {
if ($("#menu").hidden) return;
if (e.target.closest("#menu") || menu.anchor?.contains(e.target)) return;
menu.close();
});
window.addEventListener("resize", () => menu.close());
window.addEventListener("scroll", () => menu.close(), { passive: true });
// ─── Toasts ────────────────────────────────────────────────────────────────
function toast(kind, title, { detail, action, timeout } = {}) {
const box = $("#toasts");
let timer;
const dismiss = () => {
clearTimeout(timer);
t.classList.add("leaving");
setTimeout(() => t.remove(), 180);
};
const t = h("div", { class: `toast toast-${kind}`, role: kind === "error" ? "alert" : "status" },
icon(kind === "success" ? "check" : kind === "error" ? "alert" : "activity"),
h("div", { class: "toast-body" },
h("div", { class: "toast-title" }, title),
detail ? h("div", { class: "toast-detail" }, detail) : null,
action ? h("button", { type: "button", class: "link", onclick: () => { action.run(); dismiss(); } }, action.label) : null),
h("button", { type: "button", class: "toast-close", "aria-label": "Dismiss", onclick: dismiss }, icon("x")));
box.append(t);
while (box.children.length > 4) box.firstChild.remove();
const ms = timeout ?? (kind === "error" ? 9000 : 4000);
timer = setTimeout(dismiss, ms);
t.addEventListener("mouseenter", () => clearTimeout(timer));
t.addEventListener("mouseleave", () => { timer = setTimeout(dismiss, 2500); });
}
// ─── Activity drawer ───────────────────────────────────────────────────────
const activity = {
entries: [],
seq: 0,
start(label) {
const entry = { id: ++this.seq, label, started: Date.now(), status: "running", output: "", open: false };
entry.finish = (ok, output) => {
entry.status = ok ? "ok" : "error";
entry.output = output || "";
entry.ended = Date.now();
this.render();
};
this.entries.unshift(entry);
if (this.entries.length > 50) this.entries.length = 50;
this.render();
return entry;
},
running() { return this.entries.filter((e) => e.status === "running").length; },
render() {
const n = this.running();
$("#activityCount").hidden = !n;
$("#activityCount").textContent = n;
if ($("#activity").hidden) return;
const list = $("#activityList");
if (!this.entries.length) {
fill(list, h("div", { class: "empty-sm" }, "Nothing yet. Deploys, syncs and other operations you run appear here together with their output."));
return;
}
fill(list, ...this.entries.map((e) => {
const duration = e.ended ? `${((e.ended - e.started) / 1000).toFixed(1)}s` : `${Math.round((Date.now() - e.started) / 1000)}s…`;
const head = h("div", { class: "act-head" },
e.status === "running" ? h("span", { class: "spinner" }) : icon(e.status === "ok" ? "check" : "alert", `act-${e.status}`),
h("span", { class: "act-label", title: e.label }, e.label),
h("span", { class: "muted small" }, `${new Date(e.started).toLocaleTimeString()} · ${duration}`));
if (!e.output) return h("div", { class: "act" }, head);
return h("details", { class: "act", open: e.open, ontoggle: (ev) => { e.open = ev.target.open; } },
h("summary", null, head), h("pre", { class: "logs sm" }, e.output));
}));
},
open(id) {
const entry = this.entries.find((e) => e.id === id);
if (entry) entry.open = true;
$("#activity").hidden = false;
this.render();
},
toggle() {
if ($("#activity").hidden) this.open(); else this.close();
},
close() { $("#activity").hidden = true; },
};
// ─── Confirm dialog ────────────────────────────────────────────────────────
function confirmDialog({ title, body = [], confirmLabel = "Confirm", danger = false, checkbox = null, typeToConfirm = null, typeWhenChecked = null }) {
const dlg = $("#confirmDlg");
const cb = $("#cfCheck"), typed = $("#cfType"), ok = $("#cfOk");
$("#cfTitle").textContent = title;
fill($("#cfBody"), ...body);
$("#cfCheckWrap").hidden = !checkbox;
cb.checked = !!checkbox?.checked;
$("#cfCheckLabel").textContent = checkbox?.label || "";
ok.textContent = confirmLabel;
ok.className = `btn ${danger ? "danger" : "primary"}`;
typed.value = "";
const needed = () => typeToConfirm || (typeWhenChecked && cb.checked ? typeWhenChecked : null);
const refresh = () => {
const n = needed();
$("#cfTypeWrap").hidden = !n;
$("#cfTypeName").textContent = n || "";
ok.disabled = !!n && typed.value.trim() !== n;
};
cb.onchange = refresh;
typed.oninput = refresh;
typed.onkeydown = (e) => {
if (e.key === "Enter") { e.preventDefault(); if (!ok.disabled) dlg.close("ok"); }
};
refresh();
dlg.returnValue = "";
dlg.showModal();
(needed() ? typed : danger ? $("#cfCancel") : ok).focus();
return new Promise((resolve) => {
dlg.onclose = () => {
// Don't leave focus on a control inside the closed dialog (it would swallow shortcuts).
if (dlg.contains(document.activeElement)) document.activeElement.blur();
resolve(dlg.returnValue === "ok" ? { checked: cb.checked } : null);
};
});
}
// ─── New app dialog ────────────────────────────────────────────────────────
function suggestPort() {
let max = 18079;
for (const app of state.apps.values()) {
for (const r of app.routes) {
const port = Number(String(r.upstream).split(":").pop());
if (port >= 18000 && port < 20000 && port > max) max = port;
}
}
return max + 1;
}
function suggestBaseDomain() {
const counts = new Map();
for (const app of state.apps.values()) {
for (const r of app.routes) {
const labels = r.domain.replace(/^\*\./, "").split(".");
if (labels.length >= 2) {
const base = labels.slice(-2).join(".");
counts.set(base, (counts.get(base) || 0) + 1);
}
}
}
let best = null, bestCount = 0;
for (const [base, c] of counts) if (c > bestCount) { best = base; bestCount = c; }
if (best) return best;
const host = location.hostname.split(".");
return host.length > 2 ? host.slice(1).join(".") : (location.hostname || "example.com");
}
const composeTemplate = (port) => `services:
app:
image: docker.io/library/nginx:alpine
restart: unless-stopped
ports:
# Publish on localhost only; Caddy routes the domain to this port.
- "127.0.0.1:${port}:80"
`;
const SOURCE_HINTS = {
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.",
github: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
};
const newApp = {
source: "default",
domainTouched: false,
init() {
this.dlg = $("#newAppDlg");
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
$("#naRoutes").append(this.routes.el);
$("#naAddRoute").append(icon("plus"), "Add route");
$("#naAddRoute").onclick = () => this.routes.add();
$("#naSource").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setSource(b.dataset.v);
};
$("#naName").addEventListener("input", (e) => {
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
if (v !== e.target.value) e.target.value = v;
this.autofill();
$("#naError").hidden = true;
});
this.routes.el.addEventListener("input", (e) => {
if (e.target === this.routes.first()?.d) this.domainTouched = true;
});
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
if (!b.textContent.trim()) b.append(icon("x"));
b.onclick = () => this.dlg.close();
});
$("#newAppForm").onsubmit = (e) => { e.preventDefault(); this.submit(); };
},
open() {
$("#newAppForm").reset();
$("#naError").hidden = true;
$("#naCompose").value = "";
this.domainTouched = false;
this.port = suggestPort();
this.base = suggestBaseDomain();
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
this.routes.first().d.placeholder = `app.${this.base}`;
this.setSource("default");
this.dlg.showModal();
$("#naName").focus();
},
autofill() {
const first = this.routes.first();
if (!first || this.domainTouched) return;
const name = $("#naName").value.replace(/^-+|-+$/g, "");
first.d.value = name ? `${name}.${this.base}` : "";
},
setSource(v) {
this.source = v;
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
$("#naRaw").hidden = v !== "raw";
$("#naGit").hidden = v !== "github";
$("#naSourceHint").textContent = SOURCE_HINTS[v];
if (v === "raw" && !$("#naCompose").value.trim()) {
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
$("#naCompose").value = composeTemplate(port);
}
},
async submit() {
const fail = (msg, focusEl) => {
$("#naError").textContent = msg;
$("#naError").hidden = false;
focusEl?.focus();
};
const name = $("#naName").value.trim();
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
return fail(name ? "Names use lowercase letters, digits and dashes, and can't start or end with a dash." : "Give the app a name.", $("#naName"));
}
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
const routeError = this.routes.validate();
if (routeError) return fail(routeError);
const payload = { name, routes: normRoutes(this.routes.get()), auth: $("#naAuth").checked, source_type: this.source };
if (this.source === "raw") {
payload.compose_content = $("#naCompose").value;
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
}
if (this.source === "github") {
payload.github_url = $("#naUrl").value.trim();
payload.github_branch = $("#naBranch").value.trim();
payload.github_pat = $("#naPat").value.trim();
if (!/^https?:\/\/\S+$/.test(payload.github_url)) return fail("Enter the repository's https:// URL.", $("#naUrl"));
}
const deploy = $("#naDeploy").checked;
const submitBtn = $("#naSubmit");
submitBtn.disabled = true;
fill(submitBtn, h("span", { class: "spinner" }), this.source === "github" ? "Cloning…" : "Creating…");
const entry = activity.start(`Create · ${name}`);
try {
const res = await api.init(payload);
entry.finish(true, outputOf(res));
} catch (e) {
entry.finish(false, e.detail || e.message);
return fail(e.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Create app";
}
this.dlg.close();
toast("success", `${name} created`, { detail: deploy ? "Deploying now…" : "Deploy it when you're ready." });
clearFilters();
await poller.now();
if (cards.has(name)) {
toggleExpand(name, "overview");
cards.get(name).root.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
if (deploy) actions.deploy(name);
},
};
// ─── Wiring ────────────────────────────────────────────────────────────────
$("#brandMark").append(icon("layers"));
$("#searchIcon").append(icon("search"));
$("#activityIcon").append(icon("activity"));
$("#newIcon").append(icon("plus"));
$("#activityClose").append(icon("x"));
$("#newAppBtn").onclick = () => newApp.open();
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
$("#reloginBtn").onclick = () => location.reload();
$("#activityBtn").onclick = () => activity.toggle();
$("#activityClose").onclick = () => activity.close();
$("#activityClear").onclick = () => {
activity.entries = activity.entries.filter((e) => e.status === "running");
activity.render();
};
const search = $("#search");
search.addEventListener("input", () => { state.query = search.value; renderList(); });
search.addEventListener("keydown", (e) => {
if (e.key === "Escape") { search.value = ""; state.query = ""; renderList(); search.blur(); }
if (e.key === "Enter") {
const first = [...state.apps.values()].filter(matchesFilter).sort((a, b) => a.name.localeCompare(b.name))[0];
if (first) { toggleExpand(first.name, state.expanded === first.name ? cards.get(first.name).activeTab : "overview"); search.blur(); }
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#menu").hidden) { menu.close(); return; }
if (!$("#activity").hidden && !document.querySelector("dialog[open]")) { activity.close(); return; }
}
const typing = e.target.closest("input, textarea, select, [contenteditable]") && !e.target.closest("dialog:not([open])");
if (typing || e.metaKey || e.ctrlKey || e.altKey) return;
if (document.querySelector("dialog[open]")) return;
if (e.key === "/") { e.preventDefault(); search.focus(); search.select(); }
else if (e.key === "n" || e.key === "N") { e.preventDefault(); newApp.open(); }
});
window.addEventListener("beforeunload", (e) => {
if (state.dirty.size) { e.preventDefault(); e.returnValue = ""; }
});
setInterval(() => {
renderSync();
if (!$("#activity").hidden && activity.running()) activity.render();
}, 1000);
newApp.init();
renderFilters();
renderSync();
poller.schedule(0);
</script>
</body>
</html>