panel/frontend/index.html
agent 71fed39f17 Deployments, auto deploy, service routes, live logs, metrics and a terminal
Deployments: deploy, restart and git sync now run in the background, one at
a time per app (a newer request replaces a queued one). Each run is recorded
in SQLite with its log, streamed to the UI while it runs, and can be
cancelled. Any earlier deployment can be deployed again, which rolls back to
its commit, or to its saved compose file for compose apps.

Auto deploy: POST /hooks/<app>, verified with the app's secret (Forgejo,
Gitea and GitHub HMAC signatures, or the secret as a token for CI). With a
Forgejo token stored, the panel adds the webhook to the repository itself.
The NixOS module routes /hooks/* past Authelia. Caddy matches the cleaned
path but forwards the original, so the panel refuses dot segments and only
accepts webhook deliveries from that route (tagged with X-Panel-Hook).

Domains: a route can point at a compose service's container port
("web:8080"). The panel picks a free 127.0.0.1 port and panelctl publishes
it through a generated .panel-ports.yaml override, so compose files need no
ports: section. Existing host:port upstreams keep working.

Logs stream live over server-sent events, with service and text filters.
A sampler keeps an hour of CPU and memory per container for the new
Monitoring tab. The Terminal tab opens `podman exec` in a container over a
WebSocket, using xterm.js bundled by the Nix package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-27 19:05:19 +00:00

3755 lines
170 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); }
/* ── Repo picker ── */
.picker-list {
margin-top: 6px; max-height: 232px; overflow: auto;
border: 1px solid var(--border); border-radius: 8px;
}
.picker-item {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-top: 1px solid var(--border);
padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit;
}
.picker-item:first-child { border-top: 0; }
.picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; }
.picker-item.selected { background: var(--accent-soft); }
.picker-item .icon { color: var(--muted); }
.picker-name { font-weight: 600; font-size: 13.5px; }
.picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-main { flex: 1; min-width: 0; }
/* ── Settings ── */
.settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
.settings-section h3 { font-size: 14px; margin-bottom: 4px; }
.keybox {
margin-top: 10px; display: flex; gap: 8px; align-items: flex-start;
background: var(--surface-2); border-radius: 8px; padding: 10px 12px;
font: 12px/1.5 var(--mono); word-break: break-all;
}
.keybox code { flex: 1; background: none; padding: 0; }
/* ── Environment editor ── */
.env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; }
.env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); }
.env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.env-details summary { cursor: pointer; list-style: none; }
.env-details summary::-webkit-details-marker { display: none; }
.env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; }
.env-details[open] summary::before { transform: rotate(90deg); }
.missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); }
/* ── Deployments ── */
.dep-list { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.dep { border-top: 1px solid var(--border); }
.dep:first-child { border-top: 0; }
.dep-row { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 12px; cursor: pointer; }
.dep-row:hover { background: var(--surface-2); }
.dep.open .dep-row { background: var(--surface-2); }
.dep-icon { display: grid; place-items: center; }
.dep-icon.success { color: var(--ok); }
.dep-icon.failed { color: var(--danger); }
.dep-icon.cancelled, .dep-icon.queued { color: var(--muted); }
.dep-icon.running { color: var(--accent); }
.dep-title { font-weight: 600; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dep-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12px; color: var(--muted); margin-top: 1px; }
.dep-meta code { font-size: 11.5px; }
.dep-error { color: var(--danger); font-size: 12.5px; margin-top: 2px; word-break: break-word; }
.dep-actions { display: flex; gap: 4px; align-items: center; }
.dep-body { padding: 0 12px 12px; }
.dep-toggle svg { transition: transform .15s; }
.dep.open .dep-toggle svg { transform: rotate(90deg); }
.badge.current { background: var(--ok-soft); color: var(--ok); }
.badge.trigger-webhook { background: var(--accent-soft); color: var(--accent); }
.badge.trigger-rollback { background: var(--warn-soft); color: var(--warn); }
.logs .step { color: #7cc7ff; font-weight: 600; }
.logs .err { color: #ff8f87; }
.logs .ok { color: #7ee2a8; font-weight: 600; }
.logs mark { background: #6b5a00; color: inherit; border-radius: 2px; }
.last-dep { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; white-space: nowrap; }
.last-dep .icon svg { width: 13px; height: 13px; }
.last-dep.failed { color: var(--danger); }
.last-dep.success { color: var(--muted); }
/* ── Monitoring ── */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
.stat-card { border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
.stat-card h4 { font: 600 13px var(--mono); margin-bottom: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat-nums { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.stat-num .v { font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; }
.stat-num .l { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.chart { display: block; width: 100%; height: 70px; }
.chart-label { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--muted); margin: 8px 0 2px; }
.chart .area-cpu { fill: color-mix(in srgb, var(--accent) 18%, transparent); }
.chart .line-cpu { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.chart .area-mem { fill: color-mix(in srgb, #8b5cf6 18%, transparent); }
.chart .line-mem { fill: none; stroke: #8b5cf6; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.chart .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; }
/* ── Terminal ── */
.term-wrap { background: #0d1117; border-radius: 8px; padding: 8px; height: 440px; overflow: hidden; }
.term-wrap .xterm-viewport { background: #0d1117 !important; }
.term-wrap .xterm { height: 100%; }
.term-status { font-size: 12.5px; color: var(--muted); }
.term-status.ok { color: var(--ok); }
.term-status.err { color: var(--danger); }
/* ── Auto deploy ── */
.hook-box { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 14px; }
.hook-box h4 { font-size: 13.5px; margin-bottom: 4px; }
.copy-field { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
.copy-field .input { font: 12.5px var(--mono); height: 30px; }
.route-port { font-size: 11.5px; color: var(--muted); font-family: var(--mono); white-space: nowrap; }
/* ── 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; }
.env-row { grid-template-columns: minmax(0, 1fr) 28px; }
.env-row > :nth-child(2) { grid-column: 1; }
.env-row > :nth-child(3) { grid-column: 2; grid-row: 1; }
.env-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; }
.dep-row { grid-template-columns: 22px minmax(0, 1fr); }
.dep-actions { grid-column: 2; }
.stat-grid { grid-template-columns: 1fr; }
.term-wrap { height: 360px; }
}
</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="settingsBtn" class="btn ghost icon-only" type="button" title="Settings — Forgejo and deploy key" aria-label="Settings"></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="git">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" id="naProviderField">
<div class="segmented" id="naProvider">
<button type="button" data-v="forgejo" id="naProviderForgejo">Forgejo</button>
<button type="button" data-v="url">Any git URL</button>
</div>
</div>
<div id="naForgejo">
<div class="field">
<label for="naRepoSearch">Repository</label>
<div class="picker">
<input id="naRepoSearch" class="input" placeholder="Search repositories…" autocomplete="off" spellcheck="false" />
<div class="picker-list" id="naRepoList" role="listbox"></div>
</div>
<p class="hint" id="naForgejoHint"></p>
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naForgejoBranch">Branch</label>
<select id="naForgejoBranch" class="select" style="width:100%" disabled><option value="">Pick a repository first</option></select>
</div>
<div class="field">
<span class="label">Clone with</span>
<p class="muted small" id="naCloneVia" style="padding-top:7px">—</p>
</div>
</div>
</div>
<div id="naUrlMode" hidden>
<div class="field">
<label for="naUrl">Repository URL</label>
<input id="naUrl" class="input" placeholder="https://github.com/user/repo or ssh://git@host:port/user/repo.git" 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" id="naPatField">
<label for="naPat">Access token <span class="muted">(private https repos)</span></label>
<input id="naPat" class="input" type="password" autocomplete="new-password" />
</div>
</div>
<p class="hint" style="margin:-8px 0 12px">
https, <code>ssh://</code> and <code>git@host:owner/repo</code> URLs all work. A token is kept in the clone's git config, so use a read-only one.
</p>
</div>
<div class="notice" id="naDeployKey" hidden></div>
<p class="hint" style="margin:0 0 16px">The repository needs a <code>compose.yaml</code> (or <code>docker-compose.yml</code>) at its root.</p>
</div>
<div class="field">
<span class="label">Domains</span>
<div id="naRoutes"></div>
<button type="button" class="btn ghost sm" id="naAddRoute"></button>
<p class="hint" id="naRouteHint">Domain → the container port it serves, e.g. <code>8080</code>, or <code>web:8080</code> when the compose file has several services. The panel publishes that port on a free local port for Caddy, so the compose file needs no <code>ports:</code>. A <code>host:port</code> such as <code>127.0.0.1:8081</code> points at something outside the app. Path is optional, e.g. <code>/api/*</code>.</p>
<datalist id="naTargets"></datalist>
</div>
<details class="field env-details" id="naEnvDetails">
<summary><span class="label" style="display:inline">Environment variables</span> <span class="muted small" id="naEnvCount">optional</span></summary>
<div id="naEnv" style="margin-top:8px"></div>
</details>
<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="settingsDlg">
<form id="settingsForm" novalidate>
<header class="dialog-head">
<h2>Settings</h2>
<button type="button" class="btn ghost icon-only" data-close aria-label="Close"></button>
</header>
<div class="dialog-body">
<section class="settings-section" id="setForgejo">
<h3>Forgejo</h3>
<p class="muted small" id="setForgejoStatus">Loading…</p>
<div class="field" id="setTokenField" style="margin-top:10px">
<label for="setToken">Access token</label>
<div style="display:flex;gap:8px">
<input id="setToken" class="input" type="password" autocomplete="new-password" placeholder="Paste a Forgejo access token" />
<button type="submit" class="btn primary" id="setTokenSave">Connect</button>
</div>
<p class="hint">Create one in Forgejo under <em>Settings → Applications</em> with <strong>read</strong> access to your user and repositories —
or <strong>read and write</strong> on repositories so the panel can add auto-deploy webhooks for you.
It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.</p>
</div>
<button type="button" class="btn ghost danger sm" id="setTokenRemove" hidden>Disconnect</button>
</section>
<section class="settings-section">
<h3>SSH deploy key</h3>
<p class="muted small">Used for repositories cloned over SSH (e.g. <code id="setSshExample">ssh://git@host/owner/repo.git</code>). Add it as a <strong>read-only deploy key</strong> in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.</p>
<div id="setSshKey" class="muted small">Loading…</div>
</section>
</div>
</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"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
key: '<circle cx="7.5" cy="15.5" r="4.5"/><path d="M10.7 12.3L21 2"/><path d="M16 7l3 3"/><path d="M19 4l2 2"/>',
external: '<path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
braces: '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>',
rocket: '<path d="M5 15c-1.5 1-2 3.5-2 6 2.5 0 5-.5 6-2"/><path d="M9 15l-3-3a13 13 0 0 1 12-9 13 13 0 0 1-9 12z"/><circle cx="14.5" cy="9.5" r="1.5"/>',
terminal: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3"/><path d="M12 15h5"/>',
chart: '<path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/>',
zap: '<path d="M13 2L4 14h7l-1 8 9-12h-7z"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
pause: '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
};
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`),
deployments: (n, limit = 30) => api.request(`/apps/${enc(n)}/deployments?limit=${limit}`),
deployment: (id) => api.request(`/deployments/${id}`),
deploymentLog: (id) => api.request(`/deployments/${id}/log`),
cancelDeployment: (id) => api.post(`/deployments/${id}/cancel`),
redeploy: (id) => api.post(`/deployments/${id}/redeploy`),
services: (n) => api.request(`/apps/${enc(n)}/services`),
inspectCompose: (content) => api.post("/compose/inspect", { content }),
stats: (n) => api.request(`/apps/${enc(n)}/stats`),
autodeploy: (n) => api.request(`/apps/${enc(n)}/autodeploy`),
setAutodeploy: (n, body) => api.post(`/apps/${enc(n)}/autodeploy`, body),
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),
getEnv: (n) => api.request(`/apps/${enc(n)}/env`),
saveEnv: (n, vars, inject) => api.post(`/apps/${enc(n)}/env`, { vars, inject }),
integrations: () => api.request("/integrations"),
saveForgejoToken: (token) => api.post("/integrations/forgejo", { token }),
forgejoRepos: (q) => api.request(`/forgejo/repos?q=${enc(q)}`),
forgejoBranches: (repo) => api.request(`/forgejo/branches?repo=${enc(repo)}`),
};
// Forgejo connection + deploy key, loaded on demand and cached.
const integrations = {
data: null,
pending: null,
load(force = false) {
if (this.data && !force) return Promise.resolve(this.data);
if (!this.pending) {
this.pending = api.integrations()
.then((d) => (this.data = d))
.finally(() => { this.pending = null; });
}
return this.pending;
},
};
const PROVIDER_NAMES = { forgejo: "Forgejo", github: "GitHub", git: "Git" };
function copyText(text, what = "Copied") {
navigator.clipboard?.writeText(text)
.then(() => toast("success", what), () => toast("error", "Couldn't copy to the clipboard"));
}
function keyBox(publicKey) {
return h("div", { class: "keybox" }, h("code", null, publicKey),
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Copy public key", "aria-label": "Copy public key", onclick: () => copyText(publicKey, "Public key copied") }, icon("copy")));
}
const isGitUrl = (u) => /^(https?:\/\/\S+|ssh:\/\/\S+|[\w.-]+@[\w.-]+:\S+)$/.test(u);
const isSshUrl = (u) => !!u && !/^https?:\/\//.test(u);
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…", env: "Saving variables…", "volume-clear": "Clearing…", "render-route": "Rendering…",
};
const busyLabel = (b) => BUSY_LABELS[b] || "Working…";
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || (app.queued ? "queued" : null);
BUSY_LABELS.queued = "Queued…";
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) {
const before = state.apps;
state.apps = new Map((data.apps || []).map((a) => [a.name, a]));
// Deployments this browser didn't start (webhooks, other tabs): tell the user when they end.
if (state.loaded) {
for (const app of state.apps.values()) {
const dep = app.last_deployment, prev = before.get(app.name)?.last_deployment;
if (!dep || deployments.watched.has(dep.id)) continue;
const changed = !prev || prev.id !== dep.id || prev.status !== dep.status;
if (changed) cards.get(app.name)?.panels?.deployments?.refresh();
if (changed && FINAL.has(dep.status) && (!prev || prev.id !== dep.id || !FINAL.has(prev.status))) {
deployments.notify(app.name, dep);
}
}
}
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) || routeTarget(r).toLowerCase().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"], ["deployments", "Deployments"], ["logs", "Logs"], ["monitoring", "Monitoring"],
["terminal", "Terminal"], ["routes", "Domains"], ["env", "Environment"], ["compose", "Compose"],
["files", "Files"], ["backups", "Backups"], ["source", "Git"],
];
function lastDeployBadge(dep, onclick) {
if (!dep) return null;
const when = ago(1000 * (dep.finished || dep.started || dep.created));
const [cls, ic, text] = {
success: ["success", "check", `Deployed ${when}`],
failed: ["failed", "alert", `Deploy failed ${when}`],
cancelled: ["success", "x", `Deploy cancelled ${when}`],
running: ["", null, "Deploying…"],
queued: ["", "clock", "Deploy queued"],
}[dep.status] || ["", null, dep.status];
return h("button", { type: "button", class: `link last-dep ${cls} hide-sm`, title: `${dep.title} — open deployments`, onclick },
ic ? icon(ic) : h("span", { class: "spinner" }), text);
}
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 dep = app.last_deployment;
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count, app.autodeploy,
dep && [dep.id, dep.status, Math.floor((Date.now() / 1000 - (dep.finished || dep.created)) / 60)]]);
if (subSig !== this.subSig) {
this.subSig = subSig;
const provider = app.repo_provider === "forgejo" || app.repo_provider === "github" ? `${PROVIDER_NAMES[app.repo_provider]} · ` : "";
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_web_url || app.repo_url) }, icon("git"), `${provider}${app.repo_branch || "default"}`) : null,
app.autodeploy ? h("span", { class: "badge hide-sm", title: "Deploys automatically on push" }, icon("zap"), "Auto") : null,
app.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null,
lastDeployBadge(dep, () => toggleExpand(name, "deployments")));
}
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"),
deployments: new DeploymentsPanel(this, "deployments"),
logs: new LogsPanel(this, "logs"),
monitoring: new MonitoringPanel(this, "monitoring"),
terminal: new TerminalPanel(this, "terminal"),
routes: new RoutesPanel(this, "routes"),
env: new EnvPanel(this, "env"),
compose: new ComposePanel(this, "compose"),
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();
if (this.panels) for (const p of Object.values(this.panels)) p.destroy?.();
this.root.remove();
for (const key of ["compose", "routes", "env"]) state.dirty.delete(`${this.name}:${key}`);
}
}
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 dep = app.last_deployment;
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state, app.env_count, app.env_inject,
app.autodeploy, dep && [dep.id, dep.status]]);
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", title: r.port ? `Published on ${r.upstream}` : null }, routeTarget(r)),
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
h("dl", { class: "facts" },
h("dt", null, "Last deploy"), h("dd", null, dep
? h("button", { type: "button", class: "link", onclick: () => this.card.showTab("deployments") },
`${DEP_STATUS[dep.status] || dep.status} · ${ago(1000 * (dep.finished || dep.created))}`,
dep.commit_short ? ` · ${dep.commit_short}` : "")
: h("span", { class: "muted" }, "Never")),
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
h("dt", null, "Source"), h("dd", null, app.repo_url
? [`${PROVIDER_NAMES[app.repo_provider] || "Git"}: `,
app.repo_web_url ? h("a", { href: app.repo_web_url, target: "_blank", rel: "noopener" }, stripUrl(app.repo_web_url)) : stripUrl(app.repo_url),
` @ ${app.repo_branch || "default"}`,
app.autodeploy ? h("span", { class: "muted" }, " · deploys on push") : null]
: "Compose file"),
h("dt", null, "Environment"), h("dd", null,
h("button", { type: "button", class: "link", onclick: () => this.card.showTab("env") },
app.env_count ? `${app.env_count} variable${app.env_count === 1 ? "" : "s"}` : "None set"),
app.env_count && !app.env_inject ? h("span", { class: "muted" }, " (compose file only)") : null),
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.lines = [];
this.max = 5000;
this.tail = h("select", { class: "select sm", onchange: () => this.start(), "aria-label": "Lines" },
[100, 300, 1000, 3000].map((n) => h("option", { value: String(n) }, `Last ${n}`)));
this.tail.value = "300";
this.service = h("select", { class: "select sm", onchange: () => this.start(), "aria-label": "Service" },
h("option", { value: "" }, "All services"));
this.follow = h("input", { type: "checkbox", checked: true, onchange: () => this.start() });
this.filter = h("input", {
class: "input", type: "search", placeholder: "Filter…", style: "height:28px;width:160px;font-size:12.5px",
oninput: () => this.render(), "aria-label": "Filter lines",
});
this.meta = h("span", { class: "muted small" });
this.out = h("pre", { class: "logs", tabindex: "0" }, "Loading…");
this.el.append(
h("div", { class: "row-actions" },
this.service, this.tail,
h("label", { class: "toggle", title: "Stream new lines as they are written" }, this.follow, "Live"),
this.filter,
h("span", { class: "spacer" }),
this.meta,
btn(null, "rotate", () => this.start(), "ghost icon-only", "Reload"),
btn(null, "copy", () => this.copy(), "ghost icon-only", "Copy"),
btn(null, "download", () => this.download(), "ghost icon-only", "Download")),
this.out);
}
show() { this.loadServices(); this.start(); }
resume() { if (this.visible) this.start(); }
hide() { this.stop(); }
destroy() { this.stop(); }
stop() {
clearTimeout(this.retry);
this.es?.close();
this.es = null;
}
async loadServices() {
try {
const { services } = await api.services(this.name);
const current = this.service.value;
fill(this.service, h("option", { value: "" }, "All services"), services.map((s) => h("option", { value: s.name }, s.name)));
this.service.value = services.some((s) => s.name === current) ? current : "";
this.service.hidden = services.length < 2;
} catch {
// Keep "All services".
}
}
start() {
this.stop();
this.lines = [];
this.stuck = false;
this.out.textContent = "Loading…";
if (this.follow.checked) this.stream(); else this.loadOnce();
}
stream() {
const q = new URLSearchParams({ tail: this.tail.value });
if (this.service.value) q.set("service", this.service.value);
const es = new EventSource(`/apps/${enc(this.name)}/logs/stream?${q}`);
this.es = es;
this.setMeta("Connecting…");
es.onopen = () => this.setMeta("● Live", "ok");
es.addEventListener("lines", (e) => {
if (this.es !== es) return;
this.setMeta("● Live", "ok");
this.add(JSON.parse(e.data).lines);
});
es.addEventListener("end", () => {
if (this.es !== es) return;
es.close();
this.setMeta("Stream ended (containers stopped?) — retrying…");
this.retry = setTimeout(() => this.visible && this.start(), 5000);
});
es.onerror = () => {
if (this.es !== es) return;
es.close();
this.setMeta("Disconnected — reconnecting…");
this.retry = setTimeout(() => this.visible && this.start(), 3000);
};
}
async loadOnce() {
try {
const d = await api.getLogs(this.name, this.tail.value);
this.lines = [];
this.add((d.logs || "").split("\n"));
this.setMeta(`Loaded ${new Date().toLocaleTimeString()}`);
} catch (e) {
this.setMeta(`Couldn't load logs: ${e.message}`);
}
}
setMeta(text, cls = "") {
this.meta.textContent = text;
this.meta.style.color = cls === "ok" ? "var(--ok)" : "";
}
add(lines) {
this.lines.push(...lines);
if (this.lines.length > this.max) this.lines.splice(0, this.lines.length - this.max);
if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = null; this.render(); });
}
render() {
const out = this.out;
const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
const q = this.filter.value.trim().toLowerCase();
const shown = q ? this.lines.filter((l) => l.toLowerCase().includes(q)) : this.lines;
out.textContent = shown.length ? shown.join("\n") : (q ? "No lines match the filter." : "No log output yet.");
if (atBottom || !this.stuck) out.scrollTop = out.scrollHeight;
this.stuck = !atBottom && this.lines.length > 0;
}
copy() { copyText(this.out.textContent, "Logs copied to clipboard"); }
download() {
const url = URL.createObjectURL(new Blob([this.lines.join("\n") + "\n"], { type: "text/plain" }));
const a = h("a", { href: url, download: `${this.name}-${new Date().toISOString().slice(0, 19).replace(/:/g, "")}.log` });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}
// ── Domains (routes) ──
// What a route points at, as the user writes it: 8080, web:8080 or host:port.
function routeTarget(r) {
if (r.port) return r.service ? `${r.service}:${r.port}` : String(r.port);
return r.upstream;
}
function normRoutes(routes) {
return routes
.map((r) => ({ domain: (r.domain || "").trim(), target: String(r.target ?? routeTarget(r) ?? "").trim(), path: (r.path || "").trim() }))
.filter((r) => r.domain || r.target || r.path)
.map((r) => (r.path ? r : { domain: r.domain, target: r.target }));
}
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 validateTarget(v) {
const portError = (p) => (p < 1 || p > 65535 ? `Port ${p} must be between 1 and 65535.` : null);
if (!v) return "Every route needs a target: the container port (8080) or service:port (web:8080).";
if (/^\d+$/.test(v)) return portError(Number(v));
const m = /^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9._-]+):(\d+)$/.exec(v);
if (!m) return `"${v}" should be a port (8080), service:port (web:8080) or host:port (127.0.0.1:8081).`;
return portError(Number(m[2]));
}
// Suggestions for the target field from a compose file's services.
function targetOptions(services) {
const out = [];
for (const s of services || []) {
for (const p of s.ports) out.push(services.length === 1 ? String(p) : `${s.name}:${p}`);
if (!s.ports.length && services.length > 1) out.push(`${s.name}:`);
}
return out;
}
function routeEditor({ onChange = () => {}, datalist = null } = {}) {
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, "Target"), 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: "8080 or web:8080", value: r.target ?? routeTarget(r) ?? "", oninput: changed,
"aria-label": "Target (container port, service:port or host:port)", spellcheck: "false", list: datalist,
title: r.port ? `Published on ${r.upstream} for Caddy` : "Container port, service:port, or host:port for something outside the app",
});
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, target: 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 = validateTarget(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.listId = `targets-${this.name}`;
this.datalist = h("datalist", { id: this.listId });
this.editor = routeEditor({ onChange: () => this.onChange(), datalist: this.listId });
this.err = h("p", { class: "form-error", hidden: true });
this.pending = h("div", { class: "notice warn", hidden: true }, icon("alert"),
h("span", null, "A route now points at a port that isn't published yet. ",
h("button", { type: "button", class: "link", onclick: () => actions.deploy(this.name) }, "Deploy"),
" to publish it."));
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
h("div", { class: "notice" }, icon("globe"),
h("span", null, "Point each domain at the container port it serves, e.g. ",
h("code", null, "8080"), " — or ", h("code", null, "web:8080"),
" when there are several services. The panel publishes it on a free local port for Caddy, so the compose file needs no ",
h("code", null, "ports:"), ". Domain changes apply immediately; a new port takes effect on the next deploy.")),
this.pending,
this.editor.el,
this.datalist,
this.err,
h("div", { class: "row-actions" },
btn("Add domain", "plus", () => this.editor.add(), "ghost"),
btn("Save", "save", () => this.save(), "primary"),
btn("Save & deploy", "play", () => this.save(true)),
btn("Revert", null, () => this.revert(), "ghost"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
show() { this.loadServices(); }
async loadServices() {
try {
const { services } = await api.services(this.name);
fill(this.datalist, targetOptions(services).map((v) => h("option", { value: v })));
} catch {
// Suggestions only.
}
}
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(deploy = false) {
const error = this.editor.validate();
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
const routes = normRoutes(this.editor.get());
if (this.dirty) {
const res = await runOp(this.name, "routes", `Update domains · ${this.name}`, () => api.setRoutes(this.name, routes),
{ success: deploy ? null : "Domains updated", successDetail: (r) => (r.needs_deploy ? "Deploy to publish the new port." : "Caddy is reloading with the new routes.") });
if (!res) return;
const app = this.app;
if (app && res.routes) app.routes = res.routes;
this.dirty = false;
this.original = "";
if (app) this.update(app);
this.onChange();
this.pending.hidden = !res.needs_deploy || deploy;
}
if (deploy) {
this.pending.hidden = true;
actions.deploy(this.name);
}
}
}
// ── Environment variables ──
const RESERVED_ENV = new Set(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"]);
const RESERVED_ENV_PREFIXES = ["XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", "PODMAN_", "BUILDAH_", "LD_", "BASH_"];
function envKeyError(key) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return `"${key || "(empty)"}" isn't a valid variable name (letters, digits and _, not starting with a digit).`;
if (RESERVED_ENV.has(key) || RESERVED_ENV_PREFIXES.some((p) => key.startsWith(p))) return `${key} is reserved — it would change how podman/compose run.`;
return null;
}
// Parse KEY=value lines as found in .env files (comments, `export`, quotes).
function parseDotenv(text) {
const out = [];
for (let line of String(text || "").split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith("#")) continue;
line = line.replace(/^export\s+/, "");
const i = line.indexOf("=");
if (i < 1) continue;
const key = line.slice(0, i).trim();
let value = line.slice(i + 1).trim();
const q = value[0];
if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
value = value.slice(1, -1);
if (q === '"') value = value.replace(/\\(["\\$])/g, "$1");
} else {
const comment = value.search(/\s#/);
if (comment >= 0) value = value.slice(0, comment).trim();
}
out.push({ key, value });
}
return out;
}
// Variables a compose file references: name -> true when every use has a default (${X:-y}).
function composeVarRefs(text) {
const refs = new Map();
const src = String(text || "")
.split("\n").map((l) => l.replace(/(^|\s)#.*$/, "")).join("\n")
.replace(/\$\$/g, "");
for (const m of src.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?[-?+])?[^}]*\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
const name = m[1] || m[3];
const hasDefault = !!m[2] && m[2].endsWith("-");
refs.set(name, (refs.get(name) ?? true) && hasDefault);
}
return refs;
}
function envEditor({ onChange = () => {} } = {}) {
let reveal = false;
const rows = h("div");
const revealToggle = h("input", {
type: "checkbox",
onchange: () => {
reveal = revealToggle.checked;
for (const r of rows.children) r._inputs.v.type = reveal ? "text" : "password";
},
});
const pasteArea = h("textarea", { class: "code", rows: "6", spellcheck: "false", style: "min-height:120px", placeholder: "KEY=value\n# comments, export and quotes are fine\nDATABASE_URL=postgres://…" });
const pasteBox = h("div", { hidden: true, style: "margin:8px 0" }, pasteArea,
h("div", { class: "row-actions" },
btn("Import", "check", () => { importText(pasteArea.value); pasteArea.value = ""; pasteBox.hidden = true; }, "primary"),
btn("Cancel", null, () => { pasteBox.hidden = true; }, "ghost")));
const empty = h("p", { class: "muted small", style: "margin:4px 0 8px" }, "No variables yet.");
const el = h("div", null,
h("div", { class: "env-row env-head" }, h("span", null, "Name"), h("span", null, "Value"), h("span")),
empty,
rows,
h("div", { class: "row-actions" },
btn("Add variable", "plus", () => { addRow({}, true); changed(); }, "ghost"),
btn("Paste .env", "file", () => { pasteBox.hidden = false; pasteArea.focus(); }, "ghost"),
h("label", { class: "toggle" }, revealToggle, "Show values")),
pasteBox);
function changed() {
for (const r of rows.children) { r._inputs.k.classList.remove("invalid"); r._inputs.v.classList.remove("invalid"); }
empty.hidden = rows.children.length > 0;
onChange();
}
function addRow(v = {}, focus = false) {
const k = h("input", {
class: "input", placeholder: "NAME", value: v.key || "", spellcheck: "false", autocomplete: "off", "aria-label": "Variable name",
oninput: (e) => {
const cleaned = e.target.value.replace(/[^A-Za-z0-9_]/g, "_");
if (cleaned !== e.target.value) e.target.value = cleaned;
changed();
},
});
const val = h("input", {
class: "input", type: reveal ? "text" : "password", placeholder: "value", value: v.value || "",
spellcheck: "false", autocomplete: "new-password", "aria-label": "Value", oninput: changed,
onpaste: (e) => {
// Pasting several KEY=value lines into a value field imports them all.
const text = e.clipboardData?.getData("text") || "";
if (text.includes("\n") && parseDotenv(text).length > 1) { e.preventDefault(); row.remove(); importText(text); }
},
});
const row = h("div", { class: "env-row" }, k, val,
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Remove", "aria-label": "Remove variable", onclick: () => { row.remove(); changed(); } }, icon("x")));
row._inputs = { k, v: val };
rows.append(row);
empty.hidden = true;
if (focus) k.focus();
return row;
}
function importText(text) {
const parsed = parseDotenv(text);
for (const { key, value } of parsed) {
const existing = [...rows.children].find((r) => r._inputs.k.value === key);
if (existing) existing._inputs.v.value = value; else addRow({ key, value });
}
for (const r of [...rows.children]) if (!r._inputs.k.value && !r._inputs.v.value) r.remove();
changed();
toast(parsed.length ? "success" : "info", parsed.length ? `Imported ${parsed.length} variable${parsed.length === 1 ? "" : "s"}` : "No KEY=value lines found");
}
return {
el,
set(vars) { fill(rows); vars.forEach((v) => addRow(v)); empty.hidden = vars.length > 0; },
get() {
return [...rows.children]
.map((r) => ({ key: r._inputs.k.value.trim(), value: r._inputs.v.value }))
.filter((v) => v.key || v.value);
},
keys() { return new Set(this.get().map((v) => v.key)); },
add(key) { const r = addRow({ key }); r._inputs.v.focus(); changed(); },
validate() {
const seen = new Set();
for (const r of rows.children) {
const { k, v } = r._inputs;
const key = k.value.trim();
if (!key && !v.value) continue;
const err = envKeyError(key) || (seen.has(key) ? `${key} is set more than once.` : null);
if (err) { k.classList.add("invalid"); k.focus(); return err; }
seen.add(key);
}
return null;
},
};
}
class EnvPanel extends Panel {
constructor(card, key) {
super(card, key);
this.loaded = false;
this.original = "";
this.dirty = false;
this.refs = null;
this.editor = envEditor({ onChange: () => this.onChange() });
this.inject = h("input", { type: "checkbox", checked: true, onchange: () => this.onChange() });
this.missing = h("div", { class: "notice warn", hidden: true });
this.err = h("p", { class: "form-error", hidden: true });
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
h("p", { class: "muted small", style: "margin-bottom:10px" },
"Available as ", h("code", null, "${NAME}"), " in the compose file and applied on the next deploy. ",
"Stored on the server outside the repository, readable only by the panel."),
this.missing,
this.editor.el,
h("label", { class: "check" }, this.inject,
h("span", null, h("strong", null, "Pass to every container"), h("br"),
h("span", { class: "muted small" }, "Otherwise they are only used to fill in ${…} references in the compose file."))),
this.err,
h("div", { class: "row-actions" },
btn("Save & deploy", "play", () => this.save(true), "primary"),
btn("Save", "save", () => this.save(false)),
btn("Revert", null, () => this.revert(), "ghost"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
show() {
if (!this.loaded) this.load();
this.checkMissing();
}
sig() { return JSON.stringify([this.editor.get(), this.inject.checked]); }
async load() {
try {
const d = await api.getEnv(this.name);
this.editor.set(d.vars || []);
this.inject.checked = d.inject !== false;
this.loaded = true;
this.original = this.sig();
this.onChange();
} catch (e) {
this.err.textContent = `Couldn't load variables: ${e.message}`;
this.err.hidden = false;
}
}
onChange() {
this.dirty = this.loaded && this.sig() !== this.original;
this.dirtyLabel.hidden = !this.dirty;
this.err.hidden = true;
this.card.setDirty("env", this.dirty);
this.renderMissing();
}
async checkMissing() {
try {
this.refs = composeVarRefs((await api.getCompose(this.name)).content);
this.renderMissing();
} catch {
// Hints only.
}
}
renderMissing() {
if (!this.refs || !this.loaded) return;
const keys = this.editor.keys();
const missing = [...this.refs].filter(([k, hasDefault]) => !hasDefault && !keys.has(k)).map(([k]) => k);
this.missing.hidden = !missing.length;
if (missing.length) {
fill(this.missing, icon("alert"), h("div", { class: "missing-vars" },
h("span", null, "The compose file uses variables that aren't set:"),
missing.map((k) => h("button", { type: "button", class: "chip sm", title: `Add ${k}`, onclick: () => this.editor.add(k) }, `+ ${k}`))));
}
}
async revert() {
this.loaded = false;
await this.load();
}
async save(deploy) {
const error = this.editor.validate();
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
const vars = this.editor.get();
const res = await runOp(this.name, "env", `Save variables · ${this.name}`,
() => api.saveEnv(this.name, vars, this.inject.checked),
{ success: deploy ? null : "Variables saved", successDetail: () => "They apply on the next deploy." });
if (!res) return;
this.original = this.sig();
this.onChange();
if (deploy) actions.deploy(this.name);
}
}
// ── 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);
}
}
// ── Deployments ──
const FINAL = new Set(["success", "failed", "cancelled"]);
const DEP_STATUS = { queued: "Queued", running: "Running", success: "Deployed", failed: "Failed", cancelled: "Cancelled" };
const DEP_TRIGGER = { manual: "Manual", webhook: "Push", rollback: "Redeploy" };
const DEP_KIND = { sync: "Git sync", restart: "Restart" };
function fmtDuration(s) {
if (s == null) return "";
if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`;
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Append log text with steps, errors and the final summary highlighted.
function appendLog(pre, text) {
const frag = document.createDocumentFragment();
for (const line of text.split(/(?<=\n)/)) {
let cls = null;
if (line.startsWith("==> ")) cls = /failed|cancelled/i.test(line) ? "step err" : /finished/.test(line) ? "ok" : "step";
else if (/^(error|fatal)\b|\berror:|\bfailed\b/i.test(line)) cls = "err";
frag.append(cls ? h("span", { class: cls }, line) : line);
}
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
pre.append(frag);
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
// The log of one deployment: loaded once when it's over, streamed while it runs.
class LogView {
constructor(dep, onStatus) {
this.id = dep.id;
this.onStatus = onStatus;
this.pre = h("pre", { class: "logs", tabindex: "0", style: "height:auto;min-height:90px;max-height:460px" });
this.el = h("div", { class: "dep-body" }, this.pre);
}
open(dep) {
this.close();
this.pre.textContent = "";
if (FINAL.has(dep.status)) {
this.pre.textContent = "Loading…";
api.deploymentLog(this.id).then((d) => {
this.pre.textContent = "";
if (d.truncated) appendLog(this.pre, "[earlier output trimmed]\n");
appendLog(this.pre, d.log || "No output.");
this.pre.scrollTop = this.pre.scrollHeight;
}, (e) => { this.pre.textContent = `Couldn't load the log: ${e.message}`; });
return;
}
const es = new EventSource(`/deployments/${this.id}/stream`);
this.es = es;
if (dep.status === "queued") this.pre.textContent = "Waiting for the app's current operation to finish…\n";
es.addEventListener("log", (e) => {
if (this.pre.textContent.startsWith("Waiting for")) this.pre.textContent = "";
appendLog(this.pre, JSON.parse(e.data).text);
});
es.addEventListener("status", (e) => this.onStatus(JSON.parse(e.data).deployment));
es.addEventListener("done", (e) => { es.close(); this.onStatus(JSON.parse(e.data).deployment); });
es.onerror = () => {
es.close();
if (this.es !== es) return;
// Reconnect from scratch after a hiccup (the server replays the log).
setTimeout(() => { if (this.es === es) api.deployment(this.id).then((d) => this.open(d.deployment), () => {}); }, 2000);
};
}
close() { this.es?.close(); this.es = null; }
}
class DeploymentsPanel extends Panel {
constructor(card, key) {
super(card, key);
this.rows = new Map(); // id -> {el, sig, view}
this.expanded = new Set();
this.syncBtn = btn("Sync & deploy", "git", () => actions.sync(this.name), "", "Fetch the branch, reset to it and deploy");
this.hint = h("span", { class: "muted small" });
this.list = h("div", { class: "dep-list" }, h("div", { class: "empty-sm" }, "Loading…"));
this.el.append(
h("div", { class: "row-actions" },
btn("Deploy", "rocket", () => actions.deploy(this.name), "primary", "Pull images, (re)create containers and reload routes"),
this.syncBtn,
btn("Restart", "rotate", () => actions.restart(this.name), "ghost", "compose down, then up"),
h("span", { class: "spacer" }),
this.hint),
this.list);
}
update(app) {
this.syncBtn.hidden = !app.repo_url;
fill(this.hint, app.repo_url
? (app.autodeploy
? [icon("zap"), ` Deploys automatically on push to ${app.repo_branch || "the branch"}`]
: h("button", { type: "button", class: "link", onclick: () => this.card.showTab("source") }, "Deploy on every push…"))
: null);
}
show() { this.refresh(); }
hide() {
clearTimeout(this.timer);
for (const r of this.rows.values()) r.view?.close();
}
destroy() { this.hide(); }
async refresh() {
clearTimeout(this.timer);
if (!this.visible && !this.loadedOnce) return;
try {
const d = await api.deployments(this.name);
this.loadedOnce = true;
this.render(d.deployments || []);
this.active = (d.deployments || []).some((x) => !FINAL.has(x.status));
} catch (e) {
if (!this.rows.size) fill(this.list, h("div", { class: "empty-sm error" }, `Couldn't load deployments: ${e.message}`));
}
if (this.visible) this.timer = setTimeout(() => this.refresh(), this.active ? 2000 : 15000);
}
render(deps) {
if (!deps.length) {
this.rows.clear();
fill(this.list, h("div", { class: "empty-sm" }, "No deployments yet. Deploy the app to see its history here — with the full output of every run."));
return;
}
const current = deps.find((d) => d.status === "success")?.id;
const webUrl = this.app?.repo_web_url || "";
const els = deps.map((dep) => {
let row = this.rows.get(dep.id);
const sig = JSON.stringify([dep.status, dep.error, dep.commit_sha, dep.duration != null && FINAL.has(dep.status), dep.id === current,
Math.floor((Date.now() / 1000 - dep.created) / 60)]);
if (!row) {
row = { el: h("div", { class: "dep" }), sig: null, view: null };
this.rows.set(dep.id, row);
}
if (row.sig !== sig) {
row.sig = sig;
this.renderRow(row, dep, dep.id === current, webUrl);
}
return row.el;
});
for (const [id, row] of this.rows) if (!deps.some((d) => d.id === id)) { row.view?.close(); this.rows.delete(id); }
const empty = this.list.querySelector(".empty-sm");
if (empty) empty.remove();
els.forEach((el, i) => { if (this.list.children[i] !== el) this.list.insertBefore(el, this.list.children[i] || null); });
while (this.list.children.length > els.length) this.list.lastChild.remove();
}
renderRow(row, dep, isCurrent, webUrl) {
const open = this.expanded.has(dep.id);
const statusIcon = dep.status === "running" ? h("span", { class: "spinner" })
: icon({ success: "check", failed: "alert", cancelled: "x", queued: "clock" }[dep.status] || "activity");
const commit = dep.commit_short
? (webUrl ? h("a", { href: `${webUrl}/commit/${dep.commit_sha}`, target: "_blank", rel: "noopener", onclick: (e) => e.stopPropagation() }, h("code", null, dep.commit_short)) : h("code", null, dep.commit_short))
: null;
const canRedeploy = FINAL.has(dep.status) && (dep.commit_sha || dep.snapshot);
const head = h("div", { class: "dep-row", onclick: (e) => { if (!e.target.closest("button, a")) this.toggle(dep.id); } },
h("span", { class: `dep-icon ${dep.status}`, title: DEP_STATUS[dep.status] }, statusIcon),
h("div", { style: "min-width:0" },
h("div", { class: "dep-title", title: dep.title }, dep.title || DEP_KIND[dep.kind] || "Deploy"),
h("div", { class: "dep-meta" },
h("span", null, `#${dep.id}`),
h("span", { class: `badge trigger-${dep.trigger}` }, DEP_TRIGGER[dep.trigger] || dep.trigger),
commit,
h("span", { title: fmtDate(dep.created * 1000) }, ago(dep.created * 1000)),
dep.duration != null ? h("span", null, dep.status === "running" ? "running…" : fmtDuration(dep.duration)) : null,
isCurrent ? h("span", { class: "badge current" }, "Current") : null),
dep.status === "failed" && dep.error ? h("div", { class: "dep-error" }, dep.error) : null),
h("div", { class: "dep-actions" },
!FINAL.has(dep.status) ? btn("Cancel", "x", () => this.cancel(dep), "ghost danger") : null,
canRedeploy ? btn(isCurrent ? "Redeploy" : "Roll back", "rotate", () => this.redeploy(dep, isCurrent), "ghost",
dep.commit_sha ? `Deploy commit ${dep.commit_short} again` : "Deploy this deployment's compose file again") : null,
btn(null, "chevron", () => this.toggle(dep.id), "ghost icon-only dep-toggle", open ? "Hide log" : "Show log")));
row.el.classList.toggle("open", open);
if (open) {
if (!row.view) row.view = new LogView(dep, (d) => this.onStatus(d));
if (!row.view.es && (row.view.status !== dep.status)) row.view.open(dep);
row.view.status = dep.status;
fill(row.el, head, row.view.el);
} else {
fill(row.el, head);
}
}
onStatus(dep) {
const row = this.rows.get(dep.id);
if (row && FINAL.has(dep.status)) this.refresh();
else if (row) { row.sig = null; this.refresh(); }
}
toggle(id) {
const row = this.rows.get(id);
if (this.expanded.has(id)) {
this.expanded.delete(id);
row?.view?.close();
if (row?.view) row.view.status = null;
} else {
this.expanded.add(id);
}
if (row) row.sig = null;
this.refresh();
}
openDeployment(id) {
this.expanded.add(id);
const row = this.rows.get(id);
if (row) row.sig = null;
this.refresh().then(() => this.rows.get(id)?.el.scrollIntoView({ block: "nearest", behavior: "smooth" }));
}
async cancel(dep) {
const ok = await confirmDialog({
title: `Cancel deployment #${dep.id}?`,
body: [h("p", null, dep.status === "running"
? "The running command is stopped. Containers may be left half-updated until the next deploy."
: "It is removed from the queue.")],
confirmLabel: "Cancel deployment", danger: dep.status === "running",
});
if (!ok) return;
try {
await api.cancelDeployment(dep.id);
toast("info", `Deployment #${dep.id} cancelled`);
} catch (e) {
toast("error", "Couldn't cancel", { detail: e.message });
}
this.refresh();
}
async redeploy(dep, isCurrent) {
if (!isCurrent) {
const ok = await confirmDialog({
title: `Roll back ${this.name}?`,
body: [h("p", null, dep.commit_sha
? ["The app is deployed again at commit ", h("code", null, dep.commit_short), ". The next sync or push moves it forward again."]
: ["The compose file of deployment #", String(dep.id), " replaces the current one and the app is deployed with it."])],
confirmLabel: "Roll back",
});
if (!ok) return;
}
const label = dep.commit_short ? `${isCurrent ? "Redeploy" : "Roll back to"} ${dep.commit_short}` : `Redeploy #${dep.id}`;
deployments.start(this.name, "deploy", `${label} · ${this.name}`, () => api.redeploy(dep.id));
}
}
// Follows deployments started from this browser to the end, then reports.
const deployments = {
mine: new Set(),
get watched() { return this.mine; },
async start(name, kind, label, fn, { after } = {}) {
if (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);
state.localBusy.set(name, kind);
refreshCard(name);
let dep;
try {
dep = (await fn())?.deployment;
if (!dep) throw new ApiError("The panel didn't start a deployment.");
} catch (e) {
entry.finish(false, e.detail || e.message);
if (e.status !== 401) toast("error", `${label} failed`, { detail: e.message, timeout: 12000 });
state.localBusy.delete(name);
refreshCard(name);
return null;
}
this.mine.add(dep.id);
entry.deployment = { name, id: dep.id };
const panel = cards.get(name)?.panels?.deployments;
panel?.refresh();
poller.now();
const final = await this.waitFor(dep.id, () => {
// The server now reports the app as busy or queued; stop showing our own guess.
if (state.localBusy.get(name) === kind) { state.localBusy.delete(name); refreshCard(name); }
});
state.localBusy.delete(name);
refreshCard(name);
let log = "";
try { log = (await api.deploymentLog(dep.id)).log || ""; } catch { /* the toast still says what happened */ }
const ok = final?.status === "success";
entry.finish(ok, log.slice(-20000));
this.notify(name, final || dep, { mine: true });
panel?.refresh();
poller.now();
if (ok) after?.(final);
return ok ? final : null;
},
async waitFor(id, onSeen) {
let delay = 700;
for (;;) {
await sleep(delay);
delay = Math.min(delay + 300, 2000);
try {
const d = (await api.deployment(id)).deployment;
onSeen?.(d);
if (FINAL.has(d.status)) return d;
} catch (e) {
if (e.status === 401 || e.status === 404) return null;
}
}
},
notify(name, dep, { mine = false } = {}) {
const show = { label: "Show log", run: () => this.show(name, dep.id) };
const what = dep.commit_short ? `${dep.commit_short}${dep.commit_subject ? `: ${dep.commit_subject}` : ""}` : dep.title;
if (dep.status === "success") {
toast("success", mine ? `${name} deployed` : `${name} deployed · ${DEP_TRIGGER[dep.trigger] || dep.trigger}`,
{ detail: `${what} — took ${fmtDuration(dep.duration)}`, action: show });
} else if (dep.status === "failed") {
toast("error", `${name}: deployment failed`, { detail: dep.error || dep.title, action: show, timeout: 15000 });
} else if (dep.status === "cancelled" && mine) {
toast("info", `${name}: deployment cancelled`, { detail: dep.error && dep.error !== "cancelled" ? dep.error : null });
}
},
show(name, id) {
if (!cards.has(name)) return;
toggleExpand(name, "deployments");
cards.get(name).panels.deployments.openDeployment(id);
},
};
// ── Monitoring ──
function sparkline(points, { cls, max }) {
const W = 300, H = 70;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("preserveAspectRatio", "none");
svg.setAttribute("class", "chart");
const add = (tag, attrs) => {
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
svg.append(el);
};
add("line", { x1: 0, x2: W, y1: H / 2, y2: H / 2, class: "grid" });
add("line", { x1: 0, x2: W, y1: H - 0.5, y2: H - 0.5, class: "grid" });
const pts = points.filter((p) => p[1] != null);
if (pts.length < 2) return svg;
const t0 = pts[0][0], t1 = pts[pts.length - 1][0];
const span = Math.max(t1 - t0, 1);
const xy = pts.map(([t, v]) => [((t - t0) / span) * W, H - Math.min(v / max, 1) * (H - 4) - 1]);
const line = xy.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`).join("");
add("path", { d: `${line}L${W},${H}L0,${H}Z`, class: `area-${cls}` });
add("path", { d: line, class: `line-${cls}` });
return svg;
}
const fmtSpan = (s) => (s >= 3000 ? "last hour" : s >= 90 ? `last ${Math.round(s / 60)} min` : "last minute");
class MonitoringPanel extends Panel {
constructor(card, key) {
super(card, key);
this.meta = h("span", { class: "muted small" });
this.body = h("div", null, h("div", { class: "empty-sm" }, "Loading…"));
this.el.append(
h("div", { class: "row-actions" },
h("span", { class: "muted small" }, "CPU and memory of the app's containers. Sampled every few seconds while this tab is open, once a minute otherwise; the last hour is kept."),
h("span", { class: "spacer" }), this.meta),
this.body);
}
show() { this.load(); }
resume() { if (this.visible) this.load(); }
hide() { clearTimeout(this.timer); }
destroy() { this.hide(); }
async load() {
clearTimeout(this.timer);
try {
this.render(await api.stats(this.name));
} catch (e) {
fill(this.body, h("div", { class: "empty-sm error" }, `Couldn't load metrics: ${e.message}`));
}
if (this.visible) this.timer = setTimeout(() => this.load(), 5000);
}
render(d) {
this.meta.textContent = d.sampled ? `Updated ${new Date(d.sampled * 1000).toLocaleTimeString()}` : "";
const running = d.containers.filter((c) => c.current);
if (!running.length) {
fill(this.body, d.error
? h("div", { class: "notice warn" }, icon("alert"), h("span", null, `Metrics aren't available: ${d.error}`))
: h("div", { class: "empty-sm" }, d.sampled || d.containers.length
? "No running containers. Deploy the app to see its metrics."
: "Collecting the first sample…"));
return;
}
const cards = running.map((c) => {
const cur = c.current;
const cpuMax = Math.max(5, ...c.history.map((p) => p[1] || 0)) * 1.15;
const memMax = Math.max(1, ...c.history.map((p) => p[2] || 0)) * 1.15;
const span = c.history.length > 1 ? c.history[c.history.length - 1][0] - c.history[0][0] : 0;
const peakCpu = Math.max(0, ...c.history.map((p) => p[1] || 0));
const peakMem = Math.max(0, ...c.history.map((p) => p[2] || 0));
return h("div", { class: "stat-card" },
h("h4", { title: c.name }, c.name),
h("div", { class: "stat-nums" },
h("div", { class: "stat-num" }, h("div", { class: "v" }, cur.cpu == null ? "—" : `${cur.cpu.toFixed(1)}%`), h("div", { class: "l" }, "CPU")),
h("div", { class: "stat-num", title: cur.mem_limit ? `of ${fmtBytes(cur.mem_limit)}` : "" },
h("div", { class: "v" }, cur.mem == null ? "—" : fmtBytes(cur.mem)), h("div", { class: "l" }, "Memory")),
h("div", { class: "stat-num", title: "Network received / sent since the container started" },
h("div", { class: "v", style: "font-size:12.5px;line-height:1.35" },
cur.net_in == null ? "—" : [`↓ ${fmtBytes(cur.net_in)}`, h("br"), `↑ ${fmtBytes(cur.net_out)}`]),
h("div", { class: "l" }, "Network"))),
h("div", { class: "chart-label" }, h("span", null, `CPU · peak ${peakCpu.toFixed(1)}%`), h("span", null, span ? fmtSpan(span) : "")),
sparkline(c.history.map((p) => [p[0], p[1]]), { cls: "cpu", max: cpuMax }),
h("div", { class: "chart-label" }, h("span", null, `Memory · peak ${fmtBytes(peakMem)}`), h("span", null, cur.mem_limit ? `limit ${fmtBytes(cur.mem_limit)}` : "")),
sparkline(c.history.map((p) => [p[0], p[2]]), { cls: "mem", max: memMax }));
});
fill(this.body, d.error ? h("div", { class: "notice warn" }, icon("alert"), h("span", null, d.error)) : null,
h("div", { class: "stat-grid" }, cards));
}
}
// ── Terminal ──
const XTERM_CDN = "https://cdn.jsdelivr.net/npm/@xterm";
const XTERM_FILES = [
["/vendor/xterm.css", `${XTERM_CDN}/xterm@6.0.0/css/xterm.css`],
["/vendor/xterm.js", `${XTERM_CDN}/xterm@6.0.0/lib/xterm.js`],
["/vendor/addon-fit.js", `${XTERM_CDN}/addon-fit@0.11.0/lib/addon-fit.js`],
];
let xtermLoading = null;
function loadAsset(src) {
return new Promise((resolve, reject) => {
const el = src.endsWith(".css")
? h("link", { rel: "stylesheet", href: src })
: h("script", { src });
el.onload = resolve;
el.onerror = () => { el.remove(); reject(new Error(`couldn't load ${src}`)); };
document.head.append(el);
});
}
// xterm.js ships with the panel package; fall back to the CDN when running from a checkout.
function loadXterm() {
xtermLoading ??= (async () => {
for (const [local, cdn] of XTERM_FILES) {
try { await loadAsset(local); } catch { await loadAsset(cdn); }
}
if (!window.Terminal || !window.FitAddon) throw new Error("xterm.js didn't load");
})().catch((e) => { xtermLoading = null; throw e; });
return xtermLoading;
}
class TerminalPanel extends Panel {
constructor(card, key) {
super(card, key);
this.select = h("select", { class: "select sm", "aria-label": "Container" });
this.connectBtn = btn("Connect", "terminal", () => this.connect(), "primary");
this.disconnectBtn = btn("Disconnect", "x", () => this.ws?.close(), "ghost");
this.disconnectBtn.hidden = true;
this.status = h("span", { class: "term-status" });
this.wrap = h("div", { class: "term-wrap", hidden: true });
this.el.append(
h("div", { class: "row-actions" }, this.select, this.connectBtn, this.disconnectBtn, h("span", { class: "spacer" }), this.status),
h("p", { class: "muted small", style: "margin-bottom:10px" },
"A shell (bash, or sh) inside the container through ", h("code", null, "podman exec"),
". It runs as the container's user; the session ends when you disconnect or close the page."),
this.wrap);
this.resizer = new ResizeObserver(() => this.fitSoon());
this.resizer.observe(this.wrap);
}
update(app) {
const running = (app.status?.containers || []).filter((c) => c.running).map((c) => c.name);
const sig = running.join(",");
if (sig === this.sig) return;
this.sig = sig;
const current = this.select.value;
fill(this.select, running.length ? running.map((n) => h("option", { value: n }, n)) : h("option", { value: "" }, "No running containers"));
this.select.value = running.includes(current) ? current : (running[0] || "");
this.select.disabled = !running.length || !!this.ws;
this.connectBtn.disabled = !running.length || !!this.ws;
}
show() { this.fitSoon(); if (this.term && this.ws) this.term.focus(); }
destroy() { this.ws?.close(); this.term?.dispose(); this.resizer.disconnect(); }
setStatus(text, cls = "") { this.status.textContent = text; this.status.className = `term-status ${cls}`; }
fitSoon() {
if (!this.fit || this.wrap.hidden || !this.visible) return;
cancelAnimationFrame(this.fitFrame);
this.fitFrame = requestAnimationFrame(() => { try { this.fit.fit(); } catch { /* not laid out yet */ } });
}
send(msg) { if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg)); }
async connect() {
const container = this.select.value;
if (!container || this.ws) return;
this.setStatus("Loading terminal…");
try {
await loadXterm();
} catch (e) {
this.setStatus(`Couldn't load the terminal: ${e.message}`, "err");
return;
}
this.wrap.hidden = false;
if (!this.term) {
this.term = new window.Terminal({
cursorBlink: true, fontSize: 13, scrollback: 5000,
fontFamily: getComputedStyle(document.body).getPropertyValue("--mono") || "monospace",
theme: { background: "#0d1117", foreground: "#d1d7e0" },
});
this.fit = new window.FitAddon.FitAddon();
this.term.loadAddon(this.fit);
this.term.open(this.wrap);
this.term.onData((data) => this.send({ type: "input", data }));
this.term.onResize(({ cols, rows }) => this.send({ type: "resize", cols, rows }));
}
this.term.reset();
this.fitSoon();
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/apps/${enc(this.name)}/terminal?container=${enc(container)}`);
ws.binaryType = "arraybuffer";
this.ws = ws;
this.select.disabled = this.connectBtn.disabled = true;
this.disconnectBtn.hidden = false;
this.setStatus(`Connecting to ${container}…`);
let opened = false;
ws.onopen = () => {
opened = true;
this.setStatus(`● Connected to ${container}`, "ok");
try { this.fit.fit(); } catch { /* ignore */ }
this.send({ type: "resize", cols: this.term.cols, rows: this.term.rows });
this.term.focus();
};
ws.onmessage = (e) => this.term.write(typeof e.data === "string" ? e.data : new Uint8Array(e.data));
ws.onclose = (e) => {
if (this.ws !== ws) return;
this.ws = null;
this.term.write(`\r\n\x1b[2m[${opened ? "session ended" : "couldn't connect"}]\x1b[0m\r\n`);
this.setStatus(opened ? `Disconnected${e.reason ? ` — ${e.reason}` : ""}` : "Couldn't connect — is the container running?", opened ? "" : "err");
this.disconnectBtn.hidden = true;
this.sig = null;
if (this.app) this.update(this.app);
};
}
}
// ── Git source ──
function commitView(c, webUrl = "") {
const sha = h("code", null, c.short);
return h("span", { class: "commit" },
webUrl ? h("a", { href: `${webUrl}/commit/${c.sha}`, target: "_blank", rel: "noopener", title: "Open commit" }, sha) : sha,
h("span", null, c.subject),
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
}
function copyField(value, { secret = false, label = "Copy" } = {}) {
const input = h("input", { class: "input", value, readonly: true, type: secret ? "password" : "text", onfocus: (e) => e.target.select() });
return h("div", { class: "copy-field" }, input,
secret ? btn(null, "search", () => { input.type = input.type === "password" ? "text" : "password"; }, "ghost icon-only", "Show / hide") : null,
btn(null, "copy", () => copyText(value, `${label} copied`), "ghost icon-only", label));
}
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.hook = h("div", { class: "hook-box" }, h("p", { class: "muted small" }, "Loading…"));
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 deploy")),
this.hook);
}
show() { this.load(false); this.loadHook(); }
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 web = d.web_url || "";
const provider = PROVIDER_NAMES[d.provider] || "Git";
const authFailed = /permission denied|publickey|authentication failed|could not read username|403|401/i.test(d.fetch_error || "");
fill(this.info,
h("dl", { class: "facts" },
h("dt", null, "Repository"), h("dd", null,
web ? h("a", { href: web, target: "_blank", rel: "noopener" }, stripUrl(web)) : d.url,
h("span", { class: "muted" }, ` · ${provider}${d.ssh ? " over SSH" : ""}`)),
h("dt", null, "Branch"), h("dd", { class: "mono" }, d.branch || "default"),
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit, web) : 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,
d.ssh && d.public_key ? h("details", { class: "env-details", style: "margin-top:12px", open: authFailed },
h("summary", null, h("span", { class: "label", style: "display:inline" }, "Deploy key"),
h("span", { class: "muted small" }, " — add it to the repository if syncing fails with “permission denied”")),
keyBox(d.public_key)) : 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, web)) : null,
web && d.commit && d.remote ? h("a", { href: `${web}/compare/${d.commit.sha}...${d.remote.sha}`, target: "_blank", rel: "noopener" }, icon("external"), " Compare") : null);
}
}
// Auto deploy: a webhook from the repository host triggers a sync on every push.
async loadHook() {
try {
this.renderHook(await api.autodeploy(this.name));
} catch (e) {
fill(this.hook, h("span", { class: "error" }, `Couldn't load auto deploy settings: ${e.message}`));
}
}
async setHook(body, success) {
this.hookBusy = true;
try {
const d = await api.setAutodeploy(this.name, body);
this.renderHook(d);
if (success && !d.warning) toast("success", success);
poller.now();
} catch (e) {
toast("error", "Couldn't change auto deploy", { detail: e.message });
this.loadHook();
} finally {
this.hookBusy = false;
}
}
renderHook(d) {
const branch = d.branch || "the branch";
const toggle = h("input", {
type: "checkbox", checked: d.enabled,
onchange: () => this.setHook({ enabled: toggle.checked }, toggle.checked ? `Auto deploy on for ${this.name}` : "Auto deploy off"),
});
const parts = [
h("label", { class: "check", style: "margin:0" }, toggle,
h("span", null, h("strong", null, "Deploy on every push"), h("br"),
h("span", { class: "muted small" }, `A push to `, h("code", null, branch), ` syncs the app and deploys it, like pressing “Sync & deploy”.`))),
];
if (d.warning) parts.push(h("div", { class: "notice warn", style: "margin-top:10px" }, icon("alert"), h("span", null, d.warning)));
if (d.enabled) {
const f = d.forgejo || {};
if (f.hook_id) {
parts.push(h("div", { class: "notice", style: "margin-top:10px" }, icon("check"),
h("span", null, "Webhook added to ", h("code", null, f.repo), " on Forgejo. ",
f.hooks_url ? h("a", { href: f.hooks_url, target: "_blank", rel: "noopener" }, "Webhook settings") : null)));
} else {
parts.push(h("p", { class: "small", style: "margin-top:12px" },
"Add a webhook to the repository that sends ", h("strong", null, "push"), " events as ", h("code", null, "application/json"), " to:"));
parts.push(copyField(d.url, { label: "Webhook URL" }));
parts.push(h("p", { class: "small muted", style: "margin-top:8px" }, "with this secret (Forgejo, Gitea and GitHub sign each delivery with it):"));
parts.push(copyField(d.secret, { secret: true, label: "Secret" }));
if (f.can_register) {
parts.push(h("div", { class: "row-actions" },
btn(`Add to ${f.repo} on Forgejo`, "zap", () => this.setHook({ enabled: true, register: true }, "Webhook added"), "primary")));
} else if (f.repo) {
parts.push(h("p", { class: "hint" }, "Or ",
h("button", { type: "button", class: "link", onclick: () => settings.open() }, "connect a Forgejo token"),
" (with write access to the repository) and the panel adds it for you."));
}
}
parts.push(h("details", { class: "env-details", style: "margin-top:10px" },
h("summary", null, h("span", { class: "small" }, "Trigger from CI or a script")),
h("p", { class: "hint" }, "A POST with the secret as token deploys the latest commit:"),
copyField(`curl -X POST '${d.url}?token=${d.secret}'`, { secret: true, label: "Command" })));
const last = d.last;
parts.push(h("p", { class: "small muted", style: "margin-top:10px" },
last
? [`Last delivery ${ago(last.time * 1000)}: `, h("strong", null, last.result),
last.commit ? [" · ", h("code", null, last.commit)] : null,
last.pusher ? ` by ${last.pusher}` : "",
last.deployment ? [" · ", h("button", { type: "button", class: "link", onclick: () => deployments.show(this.name, last.deployment) }, `deployment #${last.deployment}`)] : null]
: "No deliveries yet — push a commit to try it."));
parts.push(h("div", { class: "row-actions" },
btn("New secret", "key", async () => {
const ok = await confirmDialog({
title: "Generate a new webhook secret?",
body: [h("p", null, f.hook_id
? "The Forgejo webhook is recreated with the new secret."
: "Deliveries signed with the old secret are rejected until you update the webhook.")],
confirmLabel: "New secret",
});
if (ok) this.setHook({ enabled: true, regenerate: true }, "New secret generated");
}, "ghost")));
}
fill(this.hook, h("h4", null, icon("zap"), " Auto deploy"), parts);
}
}
// ─── 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 deployments.start(name, "deploy", `Deploy · ${name}`, () => api.deploy(name));
},
restart(name) {
return deployments.start(name, "restart", `Restart · ${name}`, () => api.restart(name));
},
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` });
},
sync(name) {
return deployments.start(name, "repo-pull", `Sync from git · ${name}`, () => api.repoPull(name), {
after: () => {
const card = cards.get(name);
if (!card?.panels) return;
card.panels.compose.reloadIfClean();
if (card.activeTab === "source") card.panels.source.load(false);
},
});
},
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: "Deployments", icon: "rocket", run: () => toggleExpand(name, "deployments") },
{ label: "Logs", icon: "logs", run: () => toggleExpand(name, "logs") },
{ label: "Monitoring", icon: "chart", run: () => toggleExpand(name, "monitoring") },
{ label: "Terminal", icon: "terminal", run: () => toggleExpand(name, "terminal") },
{ 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 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 COMPOSE_TEMPLATE = `services:
app:
image: docker.io/library/nginx:alpine
restart: unless-stopped
# No ports: needed — the panel publishes the port your domain points at.
`;
const SOURCE_HINTS = {
default: "Starts a tiny traefik/whoami container (port 80) so you can check the domain works. Edit the compose file afterwards.",
raw: "Paste a compose file. Point the domain at the container port the app listens on; no ports: section needed.",
git: "Clones a repository and deploys its compose file. Pushes can deploy automatically (Git tab), or use “Sync & deploy”.",
};
const slugify = (s) => String(s || "").toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
const newApp = {
source: "default",
provider: "forgejo",
domainTouched: false,
selectedRepo: null,
searchTimer: null,
searchSeq: 0,
init() {
this.dlg = $("#newAppDlg");
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; }, datalist: "naTargets" });
$("#naRoutes").append(this.routes.el);
this.env = envEditor({ onChange: () => this.updateEnvCount() });
$("#naEnv").append(this.env.el);
$("#naAddRoute").append(icon("plus"), "Add domain");
$("#naAddRoute").onclick = () => this.routes.add();
$("#naSource").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setSource(b.dataset.v);
};
$("#naProvider").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setProvider(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;
if (e.target === this.routes.first()?.u) this.targetTouched = true;
});
$("#naCompose").addEventListener("input", () => {
clearTimeout(this.inspectTimer);
this.inspectTimer = setTimeout(() => this.inspectCompose(), 400);
});
$("#naRepoSearch").addEventListener("input", () => {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => this.searchRepos($("#naRepoSearch").value.trim()), 250);
});
$("#naRepoSearch").addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") { e.preventDefault(); $("#naRepoList .picker-item")?.focus(); }
});
$("#naRepoList").addEventListener("keydown", (e) => {
const items = [...$("#naRepoList").querySelectorAll(".picker-item")];
const i = items.indexOf(document.activeElement);
if (e.key === "ArrowDown" && i < items.length - 1) { e.preventDefault(); items[i + 1].focus(); }
if (e.key === "ArrowUp") { e.preventDefault(); (i > 0 ? items[i - 1] : $("#naRepoSearch")).focus(); }
});
$("#naUrl").addEventListener("input", () => this.updateUrlMode());
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 = "";
$("#naEnvDetails").open = false;
this.env.set([]);
this.updateEnvCount();
this.domainTouched = false;
this.targetTouched = false;
this.selectedRepo = null;
this.reposLoaded = false;
this.base = suggestBaseDomain();
this.routes.set([{ domain: "", target: "80" }]);
this.routes.first().d.placeholder = `app.${this.base}`;
fill($("#naTargets"));
this.setSource("default");
this.dlg.showModal();
$("#naName").focus();
},
updateEnvCount() {
const n = this.env.get().length;
$("#naEnvCount").textContent = n ? `${n} set` : "optional";
},
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 !== "git";
$("#naSourceHint").textContent = SOURCE_HINTS[v];
if (v === "raw" && !$("#naCompose").value.trim()) $("#naCompose").value = COMPOSE_TEMPLATE;
const first = this.routes.first();
if (first && !this.targetTouched) {
// The starter and the template listen on 80; a repository's port isn't known yet.
first.u.value = v === "git" ? "" : "80";
first.u.placeholder = v === "git" ? "port, or service:port" : "8080 or web:8080";
}
fill($("#naTargets"));
if (v === "raw") this.inspectCompose();
if (v === "git") this.initGit();
},
// Suggest targets from the pasted compose file (and fill in an obvious one).
async inspectCompose() {
if (this.source !== "raw") return;
let services;
try {
({ services } = await api.inspectCompose($("#naCompose").value));
} catch {
return;
}
const options = targetOptions(services);
fill($("#naTargets"), options.map((v) => h("option", { value: v })));
const first = this.routes.first();
const guess = options.find((o) => !o.endsWith(":"));
if (first && !this.targetTouched && guess) first.u.value = guess;
},
async initGit() {
let d = null;
try { d = await integrations.load(); } catch { /* fall back to URL mode */ }
const configured = !!d?.forgejo?.configured;
$("#naProviderField").hidden = !configured;
if (configured) $("#naProviderForgejo").textContent = `Forgejo · ${stripUrl(d.forgejo.url)}`;
this.setProvider(configured ? this.provider : "url");
},
setProvider(v) {
this.provider = v;
for (const b of $("#naProvider").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
$("#naForgejo").hidden = v !== "forgejo";
$("#naUrlMode").hidden = v !== "url";
if (v === "forgejo" && !this.reposLoaded) this.searchRepos("");
if (v === "forgejo") this.updateCloneVia(); else this.updateUrlMode();
},
async searchRepos(q) {
const seq = ++this.searchSeq;
const list = $("#naRepoList");
fill(list, h("div", { class: "empty-sm" }, h("span", { class: "spinner" }), " Loading repositories…"));
try {
const d = await api.forgejoRepos(q);
if (seq !== this.searchSeq) return;
this.reposLoaded = true;
const f = integrations.data?.forgejo;
fill($("#naForgejoHint"), d.authenticated ? null : [
"Showing public repositories only. ",
h("button", { type: "button", class: "link", onclick: () => settings.open() }, "Connect a Forgejo token"),
" to see private ones — or use “Any git URL” with an SSH URL and the deploy key."]);
const repos = d.repos.filter((r) => !r.archived);
if (!repos.length) {
fill(list, h("div", { class: "empty-sm" }, q ? `No repositories match “${q}”.` : "No repositories found."));
return;
}
fill(list, repos.map((r) => h("button", {
type: "button", role: "option",
class: `picker-item${this.selectedRepo?.full_name === r.full_name ? " selected" : ""}`,
onclick: () => this.selectRepo(r),
},
icon(r.private ? "lock" : "git"),
h("span", { class: "picker-main" },
h("div", { class: "picker-name" }, r.full_name, r.empty ? h("span", { class: "muted small" }, " (empty)") : null),
r.description ? h("div", { class: "picker-desc" }, r.description) : null),
h("span", { class: "muted small hide-sm" }, r.updated_at ? ago(Date.parse(r.updated_at)) : ""))));
if (f && !f.has_token && d.repos.length === 0 && !q) {
fill(list, h("div", { class: "empty-sm" }, "No public repositories. Connect a token in Settings to list private ones."));
}
} catch (e) {
if (seq !== this.searchSeq) return;
fill(list, h("div", { class: "empty-sm error" }, `Couldn't load repositories: ${e.message}`));
}
},
async selectRepo(r) {
this.selectedRepo = r;
$("#naError").hidden = true;
for (const item of $("#naRepoList").querySelectorAll(".picker-item")) {
item.classList.toggle("selected", item.querySelector(".picker-name")?.firstChild?.textContent === r.full_name);
}
if (!$("#naName").value) {
$("#naName").value = slugify(r.full_name.split("/")[1]);
this.autofill();
}
this.updateCloneVia();
const sel = $("#naForgejoBranch");
sel.disabled = true;
fill(sel, h("option", { value: "" }, "Loading branches…"));
try {
const d = await api.forgejoBranches(r.full_name);
if (this.selectedRepo !== r) return;
const branches = d.branches.length ? d.branches : [r.default_branch || "main"];
fill(sel, branches.map((b) => h("option", { value: b }, b === r.default_branch ? `${b} (default)` : b)));
sel.value = branches.includes(r.default_branch) ? r.default_branch : branches[0];
sel.disabled = false;
} catch (e) {
fill(sel, h("option", { value: r.default_branch || "" }, r.default_branch || "default branch"));
sel.disabled = false;
}
},
// How a Forgejo repository will be cloned, given the panel's credentials.
cloneVia(r) {
const f = integrations.data?.forgejo;
if (f?.has_token) return { url: r.clone_url, useToken: true, label: "HTTPS with your Forgejo token" };
if (r.private) return { url: r.ssh_url, useToken: false, ssh: true, label: "SSH with the panel's deploy key" };
return { url: r.clone_url, useToken: false, label: "HTTPS (public repository)" };
},
updateCloneVia() {
const r = this.selectedRepo;
const via = r ? this.cloneVia(r) : null;
$("#naCloneVia").textContent = via ? via.label : "—";
this.showDeployKey(!!via?.ssh);
},
updateUrlMode() {
const url = $("#naUrl").value.trim();
const ssh = !!url && isSshUrl(url);
$("#naPatField").hidden = ssh;
this.showDeployKey(ssh);
},
async showDeployKey(show) {
const box = $("#naDeployKey");
if (!show || this.source !== "git") { box.hidden = true; return; }
let key = null;
try { key = (await integrations.load()).ssh?.public_key; } catch { /* shown below */ }
box.hidden = false;
fill(box, icon("key"), h("div", { style: "flex:1;min-width:0" },
h("div", null, "Cloning over SSH uses the panel's deploy key. Add it to the repository as a ",
h("strong", null, "read-only deploy key"), " (Repository settings → Deploy keys) before creating the app."),
key ? keyBox(key) : h("div", { class: "error" }, "No deploy key available (ssh-keygen missing).")));
},
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 envError = this.env.validate();
if (envError) { $("#naEnvDetails").open = true; return fail(envError); }
const payload = {
name,
routes: normRoutes(this.routes.get()),
auth: $("#naAuth").checked,
source_type: this.source,
env: this.env.get(),
};
if (this.source === "raw") {
payload.compose_content = $("#naCompose").value;
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
}
if (this.source === "git") {
if (this.provider === "forgejo") {
const r = this.selectedRepo;
if (!r) return fail("Pick a repository.", $("#naRepoSearch"));
const via = this.cloneVia(r);
payload.repo_url = via.url;
payload.repo_branch = $("#naForgejoBranch").value;
payload.use_forgejo_token = via.useToken;
} else {
payload.repo_url = $("#naUrl").value.trim();
payload.repo_branch = $("#naBranch").value.trim();
payload.repo_token = isSshUrl(payload.repo_url) ? "" : $("#naPat").value.trim();
if (!isGitUrl(payload.repo_url)) return fail("Enter an https://, ssh:// or git@host:owner/repo URL.", $("#naUrl"));
}
}
const deploy = $("#naDeploy").checked;
const submitBtn = $("#naSubmit");
submitBtn.disabled = true;
fill(submitBtn, h("span", { class: "spinner" }), this.source === "git" ? "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);
const hint = /permission denied|publickey/i.test(e.detail || e.message) ? " — has the deploy key been added to the repository?" : "";
return fail(e.message + hint);
} 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);
},
};
// ─── Settings dialog ───────────────────────────────────────────────────────
const settings = {
init() {
this.dlg = $("#settingsDlg");
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
b.append(icon("x"));
b.onclick = () => this.dlg.close();
});
$("#settingsForm").onsubmit = (e) => { e.preventDefault(); this.saveToken(); };
$("#setTokenRemove").onclick = () => this.removeToken();
},
async open() {
if (!this.dlg.open) this.dlg.showModal();
await this.render(true);
},
async render(force) {
let d;
try {
d = await integrations.load(force);
} catch (e) {
$("#setForgejoStatus").textContent = `Couldn't load settings: ${e.message}`;
return;
}
const f = d.forgejo;
$("#setForgejo").hidden = !f.configured;
if (f.configured) {
const link = h("a", { href: f.url, target: "_blank", rel: "noopener" }, stripUrl(f.url));
fill($("#setForgejoStatus"), f.has_token
? (f.user
? [icon("check"), " Connected to ", link, " as ", h("strong", null, f.user), "."]
: ["A token is saved, but it doesn't work: ", f.error || "unknown error", ". Paste a new one below."])
: ["Instance: ", link, ". Not connected — the panel can only list public repositories."]);
$("#setTokenField").hidden = !!(f.has_token && f.user);
$("#setTokenRemove").hidden = !f.has_token;
$("#setSshExample").textContent = f.ssh_url ? `${f.ssh_url}/owner/repo.git` : "ssh://git@host/owner/repo.git";
}
fill($("#setSshKey"), d.ssh?.public_key
? keyBox(d.ssh.public_key)
: h("span", { class: "error" }, "ssh-keygen isn't available, so cloning over SSH is disabled."));
},
async saveToken() {
const token = $("#setToken").value.trim();
if (!token) { $("#setToken").focus(); return; }
const b = $("#setTokenSave");
b.disabled = true;
try {
const r = await api.saveForgejoToken(token);
$("#setToken").value = "";
toast("success", `Connected to Forgejo as ${r.user}`);
await this.render(true);
newApp.reposLoaded = false;
if (newApp.dlg.open && newApp.source === "git" && newApp.provider === "forgejo") {
newApp.searchRepos($("#naRepoSearch").value.trim());
newApp.updateCloneVia();
}
} catch (e) {
toast("error", "Couldn't connect to Forgejo", { detail: e.message });
} finally {
b.disabled = false;
}
},
async removeToken() {
const ok = await confirmDialog({
title: "Disconnect Forgejo?",
body: [h("p", null, "The stored token is deleted. Apps already cloned with it keep syncing — the token stays in their git config.")],
confirmLabel: "Disconnect", danger: true,
});
if (!ok) return;
try {
await api.saveForgejoToken("");
toast("success", "Forgejo disconnected");
} catch (e) {
toast("error", "Couldn't disconnect", { detail: e.message });
}
this.render(true);
},
};
// ─── 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();
$("#settingsBtn").append(icon("settings"));
$("#settingsBtn").onclick = () => settings.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();
settings.init();
renderFilters();
renderSync();
poller.schedule(0);
</script>
</body>
</html>