panel/frontend/index.html
agent db46c5e793 panel: Forgejo integration, ssh deploy key and per-app environment variables
Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
  from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
  /api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
  dropdown; private repos are cloned over https with the stored token, or
  over ssh with the deploy key when no token is connected. The app name and
  domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
  the provider ("Forgejo · main").

Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
  ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
  (BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
  kept; git errors now report the meaningful line instead of git's advice.

Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
  interpolation works; by default deploy/restart also generate a compose
  override listing the keys under every service's environment (values are
  read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
  import, hidden values, validation of names (reserved podman/compose vars
  rejected), hints for ${VAR}s the compose file uses but aren't set, and
  Save / Save & deploy. Removing an app deletes its variables.

The API still accepts the old source_type "github" / github_* fields.

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

2877 lines
127 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); }
/* ── 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; }
}
</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">Routes</span>
<div id="naRoutes"></div>
<button type="button" class="btn ghost sm" id="naAddRoute"></button>
<p class="hint">Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. <code>/api/*</code>. Wildcard domains need a DNS challenge.</p>
</div>
<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 repositories (and your user).
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"/>',
};
function icon(name, cls = "") {
const span = document.createElement("span");
span.className = "icon " + cls;
span.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ""}</svg>`;
return span;
}
function btn(label, iconName, onclick, variant = "", title = "") {
return h("button", { type: "button", class: `btn sm ${variant}`, title: title || null, onclick },
iconName ? icon(iconName) : null, label ? h("span", null, label) : null);
}
// ─── Formatting ────────────────────────────────────────────────────────────
function fmtBytes(n) {
if (n == null || isNaN(n)) return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
function ago(ms) {
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const hr = Math.round(m / 60);
if (hr < 24) return `${hr}h ago`;
const d = Math.round(hr / 24);
if (d < 30) return `${d}d ago`;
return new Date(ms).toLocaleDateString();
}
const fmtDate = (ms) => new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
const stripUrl = (u) => String(u || "").replace(/^https?:\/\//, "").replace(/\.git$/, "");
function summarizeError(text) {
const lines = String(text || "").split("\n").map((l) => l.trim()).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
if (/^error:/i.test(lines[i])) return lines[i].replace(/^error:\s*/i, "");
}
return lines[lines.length - 1] || "";
}
const outputOf = (res) => [res?.stdout, res?.stderr].filter(Boolean).join("\n").trim();
// ─── API client ────────────────────────────────────────────────────────────
class ApiError extends Error {
constructor(message, { status = 0, detail = "", busy = null } = {}) {
super(message);
this.status = status;
this.detail = detail;
this.busy = busy;
}
}
const api = {
async request(path, { method = "GET", json, body } = {}) {
const opts = { method, headers: { Accept: "application/json" }, redirect: "manual", credentials: "same-origin" };
if (json !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(json);
} else if (body !== undefined) {
opts.body = body;
}
let res;
try {
res = await fetch(path, opts);
} catch {
throw new ApiError("Can't reach the panel — check your connection.");
}
// Authelia answers an expired session with a redirect (or 401).
if (res.type === "opaqueredirect" || res.status === 401) {
session.expire();
throw new ApiError("Login session expired", { status: 401 });
}
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
throw new ApiError(`Unexpected response from the server (HTTP ${res.status})`, { status: res.status });
}
if (!res.ok || data.ok === false) {
const message = data.error || summarizeError(data.stderr) || summarizeError(data.stdout) || `HTTP ${res.status}`;
throw new ApiError(message, {
status: res.status,
detail: [data.stderr, data.stdout].filter(Boolean).join("\n").trim(),
busy: data.busy || null,
});
}
return data;
},
post: (path, json) => api.request(path, { method: "POST", json }),
overview: () => api.request("/status"),
getCompose: (n) => api.request(`/apps/${enc(n)}/compose`),
saveCompose: (n, content) => api.post(`/apps/${enc(n)}/compose`, { content }),
validate: (n) => api.post(`/apps/${enc(n)}/validate-compose`),
deploy: (n) => api.post(`/apps/${enc(n)}/deploy`),
restart: (n) => api.post(`/apps/${enc(n)}/restart`),
stop: (n) => api.post(`/apps/${enc(n)}/stop`),
repoPull: (n) => api.post(`/apps/${enc(n)}/repo-pull`),
getRepo: (n, fetchRemote) => api.request(`/apps/${enc(n)}/repo${fetchRemote ? "?fetch=1" : ""}`),
remove: (n, keepVolumes) => api.post(`/apps/${enc(n)}/remove`, { keepVolumes }),
getLogs: (n, tail) => api.request(`/apps/${enc(n)}/logs?tail=${enc(tail)}`),
setRoutes: (n, routes) => api.post(`/apps/${enc(n)}/routes`, { routes }),
getBackups: (n) => api.request(`/apps/${enc(n)}/backups`),
backup: (n) => api.post(`/apps/${enc(n)}/backup`),
restore: (n, file) => api.post(`/apps/${enc(n)}/restore`, { file }),
clearVolume: (n) => api.post(`/apps/${enc(n)}/volume-clear`),
getVolumes: (n) => api.request(`/apps/${enc(n)}/volumes`),
fileUrl: (n, path, vol, kind = "files") => `/apps/${enc(n)}/volume/${kind}?vol=${enc(vol || "default")}&path=${enc(path)}`,
getFiles: (n, path, vol) => api.request(api.fileUrl(n, path, vol)),
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
init: (payload) => api.post("/apps/init", payload),
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 || null;
function appState(app) {
const busy = busyOf(app);
if (busy) return { key: "busy", label: busyLabel(busy) };
const s = app.status || {};
if (s.state === "running") return { key: "running", label: "Running" };
if (s.state === "partial") return { key: "partial", label: `${s.running_count}/${s.total} running` };
if (s.state === "stopped") return { key: "stopped", label: "Stopped" };
return { key: "unknown", label: "Unknown" };
}
// ─── Polling / sync ────────────────────────────────────────────────────────
const poller = {
timer: null,
inflight: null,
again: false,
lastOk: 0,
failed: false,
error: "",
stopped: false,
schedule(ms) {
clearTimeout(this.timer);
if (this.stopped) return;
this.timer = setTimeout(() => this.tick(), ms);
},
async tick() {
if (document.hidden) return; // resumed by visibilitychange
await this.now();
const anyBusy = [...state.apps.values()].some((a) => busyOf(a));
this.schedule(anyBusy ? 2500 : 8000);
},
// Refresh now. Calls made while a request is in flight queue exactly one
// follow-up, so callers always see state from after their action finished.
now() {
if (this.stopped) return Promise.resolve();
if (this.inflight) { this.again = true; return this.inflight; }
this.inflight = (async () => {
renderSync();
try {
applyOverview(await api.overview());
this.lastOk = Date.now();
this.failed = false;
} catch (e) {
this.failed = true;
this.error = e.message;
if (!state.loaded && e.status !== 401) showLoadError(e);
} finally {
this.inflight = null;
renderSync();
if (this.again) { this.again = false; this.now(); }
}
})();
return this.inflight;
},
stop() { this.stopped = true; clearTimeout(this.timer); },
};
document.addEventListener("visibilitychange", () => {
if (document.hidden) return;
poller.schedule(0);
const card = cards.get(state.expanded);
card?.panels?.[card.activeTab]?.resume?.();
});
function renderSync() {
const el = $("#syncBtn");
let cls = "pending", label = "Connecting…", title = "Status refreshes automatically. Click to refresh now.";
if (session.expired) {
cls = "err"; label = "Signed out"; title = "Your login expired. Reload to sign in.";
} else if (poller.failed) {
cls = "err";
label = poller.lastOk ? `Offline · ${ago(poller.lastOk)}` : "Can't connect";
title = `Last error: ${poller.error}. Click to retry.`;
} else if (poller.lastOk) {
cls = "ok"; label = `Live · ${ago(poller.lastOk)}`;
}
el.className = `sync ${cls}${poller.inflight ? " syncing" : ""}`;
el.title = title;
$("#syncText").textContent = label;
}
function applyOverview(data) {
state.apps = new Map((data.apps || []).map((a) => [a.name, a]));
state.loaded = true;
if (state.expanded && !state.apps.has(state.expanded)) {
state.expanded = null;
setHash();
}
renderList();
applyHashOnce();
}
function showLoadError(e) {
fill($("#appList"), h("div", { class: "empty" },
h("h3", null, "Couldn't load apps"),
h("p", null, e.message),
h("button", { type: "button", class: "btn", onclick: () => poller.now() }, icon("rotate"), "Try again")));
}
// ─── App list ──────────────────────────────────────────────────────────────
function matchesFilter(app) {
const st = app.status?.state;
if (state.filter === "running" && st !== "running") return false;
if (state.filter === "stopped" && st !== "stopped") return false;
if (state.filter === "attention" && !(st === "partial" || st === "unknown" || !st)) return false;
const q = state.query.trim().toLowerCase();
if (!q) return true;
return app.name.includes(q)
|| app.routes.some((r) => r.domain.toLowerCase().includes(q) || r.upstream.includes(q))
|| (app.repo_url || "").toLowerCase().includes(q);
}
function renderFilters() {
const all = [...state.apps.values()];
const counts = {
all: all.length,
running: all.filter((a) => a.status?.state === "running").length,
stopped: all.filter((a) => a.status?.state === "stopped").length,
attention: all.filter((a) => ["partial", "unknown", undefined].includes(a.status?.state)).length,
};
const defs = [["all", "All"], ["running", "Running"], ["stopped", "Stopped"], ["attention", "Needs attention"]];
fill($("#filters"), ...defs
.filter(([k]) => k !== "attention" || counts.attention || state.filter === k)
.map(([k, label]) => h("button", {
type: "button",
class: `chip${state.filter === k ? " active" : ""}`,
onclick: () => { state.filter = k; renderList(); },
}, label, h("span", { class: "chip-count" }, counts[k]))));
}
function renderList() {
renderFilters();
if (!state.loaded) return;
const list = $("#appList");
const apps = [...state.apps.values()].sort((a, b) => a.name.localeCompare(b.name));
for (const [name, card] of cards) {
if (!state.apps.has(name)) { card.destroy(); cards.delete(name); }
}
const visible = apps.filter(matchesFilter);
if (!visible.length) {
fill(list, apps.length
? h("div", { class: "empty" },
h("h3", null, "No matching apps"),
h("p", null, "Try a different search or filter."),
h("button", { type: "button", class: "btn", onclick: clearFilters }, "Clear filters"))
: h("div", { class: "empty" },
h("h3", null, "No apps yet"),
h("p", null, "Create an app from a compose file or a git repository — the panel sets up its domain and HTTPS for you."),
h("button", { type: "button", class: "btn primary", onclick: () => newApp.open() }, icon("plus"), "Create your first app")));
return;
}
const wanted = visible.map((app) => {
let card = cards.get(app.name);
if (!card) { card = new AppCard(app.name); cards.set(app.name, card); }
card.update(app);
return card.root;
});
for (const child of [...list.children]) {
if (!wanted.includes(child)) child.remove();
}
wanted.forEach((el, i) => {
if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null);
});
}
function clearFilters() {
state.filter = "all";
state.query = "";
$("#search").value = "";
renderList();
}
function refreshCard(name) {
const app = state.apps.get(name);
if (app) cards.get(name)?.update(app);
}
function domainLinks(routes, max = Infinity) {
const domains = [...new Set(routes.map((r) => r.domain))];
const out = domains.slice(0, max).map((d) => d.startsWith("*.")
? h("span", { class: "domain", title: d }, d)
: h("a", { class: "domain", href: `https://${d}`, target: "_blank", rel: "noopener", title: `Open ${d}` }, d));
if (domains.length > max) out.push(h("span", { class: "more" }, `+${domains.length - max} more`));
return out;
}
// ─── Expand / hash ─────────────────────────────────────────────────────────
function toggleExpand(name, tab) {
const card = cards.get(name);
if (!card) return;
if (state.expanded === name && !tab) {
card.collapse();
state.expanded = null;
setHash();
return;
}
if (state.expanded && state.expanded !== name) cards.get(state.expanded)?.collapse();
state.expanded = name;
if (tab) card.activeTab = tab;
card.expand();
}
function setHash(name, tab) {
const url = name ? `#/${name}/${tab || "overview"}` : location.pathname + location.search;
history.replaceState(null, "", url);
}
function applyHash() {
const m = /^#\/([a-z0-9-]+)(?:\/([a-z]+))?/.exec(location.hash);
if (m && cards.has(m[1])) {
toggleExpand(m[1], m[2] || "overview");
requestAnimationFrame(() => cards.get(m[1])?.root.scrollIntoView({ block: "start" }));
}
}
function applyHashOnce() {
if (state.hashApplied) return;
state.hashApplied = true;
applyHash();
}
// setHash uses replaceState, which doesn't fire this — only pasted links / back-forward do.
window.addEventListener("hashchange", applyHash);
// ─── App card ──────────────────────────────────────────────────────────────
const TABS = [
["overview", "Overview"], ["compose", "Compose"], ["env", "Environment"], ["logs", "Logs"], ["routes", "Routes"],
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
];
class AppCard {
constructor(name) {
this.name = name;
this.panels = null;
this.activeTab = state.tabs[name] || "overview";
this.pill = h("span", { class: "pill" });
this.sub = h("div", { class: "app-sub" });
this.deployBtn = h("button", {
type: "button", class: "btn primary sm", title: "Deploy — pull images, (re)create containers and reload routes",
onclick: () => actions.deploy(name),
}, icon("play"), h("span", { class: "hide-sm" }, "Deploy"));
this.menuBtn = h("button", {
type: "button", class: "btn ghost sm icon-only", title: "More actions", "aria-label": "More actions", "aria-haspopup": "menu",
onclick: () => openAppMenu(this.menuBtn, name),
}, icon("more"));
this.head = h("header", {
class: "app-head", tabindex: "0", role: "button", "aria-expanded": "false",
onclick: (e) => { if (!e.target.closest("button, a")) toggleExpand(name); },
onkeydown: (e) => {
if ((e.key === "Enter" || e.key === " ") && e.target === this.head) { e.preventDefault(); toggleExpand(name); }
},
},
icon("chevron", "chev"),
this.pill,
h("div", { class: "app-title" }, h("h3", { class: "app-name" }, name), this.sub),
h("div", { class: "app-actions" }, this.deployBtn, this.menuBtn));
this.body = h("div", { class: "app-body", hidden: true });
this.root = h("article", { class: "app", dataset: { name } }, this.head, this.body);
}
get app() { return state.apps.get(this.name); }
update(app) {
const st = appState(app);
const pillSig = st.key + st.label;
if (pillSig !== this.pillSig) {
this.pillSig = pillSig;
this.pill.className = `pill pill-${st.key}`;
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
}
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count]);
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.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null);
}
this.deployBtn.disabled = st.key === "busy";
if (this.panels) {
this.syncTabs(app);
for (const p of Object.values(this.panels)) p.update?.(app);
}
}
build() {
this.panels = {
overview: new OverviewPanel(this, "overview"),
compose: new ComposePanel(this, "compose"),
env: new EnvPanel(this, "env"),
logs: new LogsPanel(this, "logs"),
routes: new RoutesPanel(this, "routes"),
files: new FilesPanel(this, "files"),
backups: new BackupsPanel(this, "backups"),
source: new SourcePanel(this, "source"),
};
this.tabBtns = {};
const tabbar = h("div", { class: "tabs", role: "tablist" });
for (const [key, label] of TABS) {
const tabBtn = h("button", { type: "button", class: "tab", role: "tab", onclick: () => this.showTab(key) },
label, h("span", { class: "tab-dot", hidden: true, title: "Unsaved changes" }));
this.tabBtns[key] = tabBtn;
tabbar.append(tabBtn);
}
this.body.append(tabbar, ...Object.values(this.panels).map((p) => p.el));
const app = this.app;
this.syncTabs(app);
for (const p of Object.values(this.panels)) p.update?.(app);
}
syncTabs(app) {
this.tabBtns.source.hidden = !app.repo_url;
if (!app.repo_url && this.activeTab === "source") this.showTab("overview");
}
expand() {
if (!this.panels) this.build();
this.body.hidden = false;
this.root.classList.add("open");
this.head.setAttribute("aria-expanded", "true");
this.showTab(this.activeTab, true);
}
collapse() {
if (this.panels) this.panels[this.activeTab]?.hide?.();
this.body.hidden = true;
this.root.classList.remove("open");
this.head.setAttribute("aria-expanded", "false");
}
showTab(key, force = false) {
if (!this.panels[key] || (key === "source" && !this.app?.repo_url)) key = "overview";
if (key === this.activeTab && !force) return;
if (key !== this.activeTab) this.panels[this.activeTab]?.hide?.();
this.activeTab = key;
state.tabs[this.name] = key;
for (const [k, b] of Object.entries(this.tabBtns)) {
b.classList.toggle("active", k === key);
b.setAttribute("aria-selected", String(k === key));
}
for (const [k, p] of Object.entries(this.panels)) p.el.hidden = k !== key;
this.panels[key].show?.();
if (state.expanded === this.name) setHash(this.name, key);
}
setDirty(key, dirty) {
const id = `${this.name}:${key}`;
if (dirty) state.dirty.add(id); else state.dirty.delete(id);
const dot = this.tabBtns?.[key]?.querySelector(".tab-dot");
if (dot) dot.hidden = !dirty;
}
destroy() {
this.collapse();
this.root.remove();
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 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]);
if (sig === this.sig) return;
this.sig = sig;
const containerBox = h("div", { class: "box" }, h("h4", null, "Containers"),
containers.length
? h("table", { class: "table" },
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "State"), h("th", { class: "hide-sm" }, "Image"))),
h("tbody", null, containers.map((c) => h("tr", null,
h("td", { class: "mono" }, c.name),
h("td", null, h("span", { class: `pill sm pill-${c.running ? "running" : "stopped"}` }, h("span", { class: "dot" }), c.status || c.state)),
h("td", { class: "mono muted hide-sm" }, c.image)))))
: h("p", { class: "muted" }, s.raw ? s.raw : "No containers. ",
s.raw ? null : h("button", { type: "button", class: "link", onclick: () => actions.deploy(this.name) }, "Deploy"),
s.raw ? null : " to start the app."));
const infoBox = h("div", { class: "box" }, h("h4", null, "Routes"),
h("ul", { class: "route-list" }, app.routes.map((r) => h("li", null,
domainLinks([r])[0],
h("span", { class: "arrow" }, "→"),
h("span", { class: "mono" }, r.upstream),
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
h("dl", { class: "facts" },
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
h("dt", null, "Source"), h("dd", null, app.repo_url
? [`${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"}`]
: "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.tail = h("select", { class: "select sm", onchange: () => this.load(true) },
[100, 300, 1000, 3000].map((n) => h("option", { value: String(n) }, `${n} lines`)));
this.tail.value = "300";
this.follow = h("input", { type: "checkbox", checked: true, onchange: () => this.schedule() });
this.meta = h("span", { class: "muted small" });
this.out = h("pre", { class: "logs", tabindex: "0" }, "Loading…");
this.el.append(
h("div", { class: "row-actions" },
h("label", { class: "inline" }, "Show", this.tail),
h("label", { class: "toggle", title: "Refresh every few seconds while this tab is open" }, this.follow, "Follow"),
btn("Refresh", "rotate", () => this.load(), "ghost"),
btn("Copy", "copy", () => this.copy(), "ghost"),
h("span", { class: "spacer" }),
this.meta),
this.out);
}
show() { this.load(); }
resume() { if (this.visible) this.load(); }
hide() { clearTimeout(this.timer); this.timer = null; }
schedule() {
clearTimeout(this.timer);
if (this.follow.checked && this.visible) this.timer = setTimeout(() => this.load(), 4000);
}
async load(scrollToEnd = false) {
clearTimeout(this.timer);
if (this.loading) return;
this.loading = true;
const out = this.out;
const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
try {
const d = await api.getLogs(this.name, this.tail.value);
const text = d.logs || "No log output yet.";
if (text !== out.textContent) {
out.textContent = text;
if (atBottom || scrollToEnd || !this.hasLoaded) out.scrollTop = out.scrollHeight;
}
this.hasLoaded = true;
this.meta.textContent = `Updated ${new Date().toLocaleTimeString()}`;
} catch (e) {
this.meta.textContent = `Couldn't load logs: ${e.message}`;
} finally {
this.loading = false;
this.schedule();
}
}
copy() {
navigator.clipboard?.writeText(this.out.textContent)
.then(() => toast("success", "Logs copied to clipboard"), () => toast("error", "Couldn't copy logs"));
}
}
// ── Routes ──
function normRoutes(routes) {
return routes
.map((r) => ({ domain: (r.domain || "").trim(), upstream: (r.upstream || "").trim(), path: (r.path || "").trim() }))
.filter((r) => r.domain || r.upstream || r.path)
.map((r) => (r.path ? r : { domain: r.domain, upstream: r.upstream }));
}
function validateDomain(v) {
if (!v) return "Every route needs a domain.";
const core = v.startsWith("*.") ? v.slice(2) : v;
if (!/^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(core) || !v.includes(".")) return `"${v}" isn't a valid domain.`;
return null;
}
function validateUpstream(v) {
if (!v) return "Every route needs an upstream (host:port).";
const m = /^[^\s:|,]+:(\d+)$/.exec(v);
if (!m) return `Upstream "${v}" should look like 127.0.0.1:18080.`;
const port = Number(m[1]);
if (port < 1024 || port > 65535) return `Port ${port} must be between 1024 and 65535.`;
return null;
}
function routeEditor({ onChange = () => {} } = {}) {
const rows = h("div");
const el = h("div", null,
h("div", { class: "route-row route-head" }, h("span", null, "Domain"), h("span"), h("span", null, "Upstream"), h("span", null, "Path"), h("span")),
rows);
function changed() {
for (const row of rows.children) for (const i of Object.values(row._inputs)) i.classList.remove("invalid");
onChange();
}
function addRow(r = {}, focus = false) {
const d = h("input", { class: "input", placeholder: "app.example.com", value: r.domain || "", oninput: changed, "aria-label": "Domain", spellcheck: "false" });
const u = h("input", { class: "input", placeholder: "127.0.0.1:18080", value: r.upstream || "", oninput: changed, "aria-label": "Upstream", spellcheck: "false" });
const p = h("input", { class: "input route-path", placeholder: "/path/* (optional)", value: r.path || "", oninput: changed, "aria-label": "Path (optional)", spellcheck: "false" });
const row = h("div", { class: "route-row" }, d, h("span", { class: "arrow" }, "→"), u, p,
h("button", {
type: "button", class: "btn ghost sm icon-only", title: "Remove route", "aria-label": "Remove route",
onclick: () => { row.remove(); if (!rows.children.length) addRow(); changed(); },
}, icon("x")));
row._inputs = { d, u, p };
rows.append(row);
if (focus) d.focus();
}
return {
el,
add() { addRow({}, true); changed(); },
set(routes) { fill(rows, ); (routes.length ? routes : [{}]).forEach((r) => addRow(r)); },
get() { return [...rows.children].map((r) => ({ domain: r._inputs.d.value, upstream: r._inputs.u.value, path: r._inputs.p.value })); },
first() { return rows.children[0]?._inputs; },
validate() {
let first = null, count = 0;
for (const row of rows.children) {
const { d, u, p } = row._inputs;
const dv = d.value.trim(), uv = u.value.trim(), pv = p.value.trim();
if (!dv && !uv && !pv) continue;
count++;
const de = validateDomain(dv);
if (de) { d.classList.add("invalid"); first ??= de; }
const ue = validateUpstream(uv);
if (ue) { u.classList.add("invalid"); first ??= ue; }
if (pv && (!pv.startsWith("/") || /[\s|,]/.test(pv))) { p.classList.add("invalid"); first ??= `Path "${pv}" must start with / and contain no spaces, commas or pipes.`; }
}
return count ? first : "Add at least one route.";
},
};
}
class RoutesPanel extends Panel {
constructor(card, key) {
super(card, key);
this.original = "";
this.dirty = false;
this.editor = routeEditor({ onChange: () => this.onChange() });
this.err = h("p", { class: "form-error", hidden: true });
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
h("div", { class: "notice" }, icon("layers"),
h("span", null, "Changes apply immediately: Caddy reloads on its own and containers keep running. If you route to a new port, publish it in the compose file too.")),
this.editor.el,
this.err,
h("div", { class: "row-actions" },
btn("Add route", "plus", () => this.editor.add(), "ghost"),
btn("Save routes", "save", () => this.save(), "primary"),
btn("Revert", null, () => this.revert(), "ghost"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
update(app) {
const sig = JSON.stringify(normRoutes(app.routes));
if (!this.dirty && sig !== this.original) {
this.original = sig;
this.editor.set(app.routes);
}
}
onChange() {
this.dirty = JSON.stringify(normRoutes(this.editor.get())) !== this.original;
this.dirtyLabel.hidden = !this.dirty;
this.err.hidden = true;
this.card.setDirty("routes", this.dirty);
}
revert() {
this.dirty = false;
this.original = "";
this.update(this.app);
this.onChange();
}
async save() {
const error = this.editor.validate();
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
const routes = normRoutes(this.editor.get());
const res = await runOp(this.name, "routes", `Update routes · ${this.name}`, () => api.setRoutes(this.name, routes),
{ success: "Routes updated", successDetail: () => "Caddy is reloading with the new routes." });
if (!res) return;
this.original = JSON.stringify(routes);
const app = this.app;
if (app) app.routes = routes;
this.onChange();
}
}
// ── 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);
}
}
// ── 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)}` : ""}`));
}
class SourcePanel extends Panel {
constructor(card, key) {
super(card, key);
this.info = h("div", null, h("p", { class: "muted" }, "Loading…"));
this.check = h("div", { class: "update-box", hidden: true });
this.el.append(
this.info,
this.check,
h("div", { class: "row-actions", style: "margin-top:14px" },
btn("Check for updates", "rotate", () => this.load(true)),
btn("Sync & deploy", "git", () => actions.sync(this.name), "primary", "Fetch the branch, reset to it and redeploy")));
}
show() { this.load(false); }
async load(fetchRemote) {
if (fetchRemote) {
this.check.hidden = false;
fill(this.check, h("span", { class: "spinner" }), "Checking the remote…");
}
try {
this.render(await api.getRepo(this.name, fetchRemote), fetchRemote);
} catch (e) {
const target = fetchRemote ? this.check : this.info;
target.hidden = false;
fill(target, h("span", { class: "error" }, e.message));
}
}
render(d, fetched) {
const 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);
}
}
}
// ─── Operations ────────────────────────────────────────────────────────────
async function runOp(name, kind, label, fn, opts = {}) {
if (name && kind && state.localBusy.has(name)) {
toast("info", `${name} is busy`, { detail: `Wait for "${busyLabel(state.localBusy.get(name))}" to finish.` });
return null;
}
const entry = activity.start(label);
if (name && kind) { state.localBusy.set(name, kind); refreshCard(name); }
try {
const res = (await fn()) || {};
entry.finish(true, outputOf(res));
if (opts.success !== null) {
toast("success", opts.success || label, { detail: opts.successDetail ? opts.successDetail(res) : null });
}
return res;
} catch (e) {
entry.finish(false, e.detail || e.message);
if (e.status !== 401) {
toast("error", `${label} failed`, {
detail: e.message,
action: e.detail ? { label: "Show output", run: () => activity.open(entry.id) } : null,
timeout: 12000,
});
}
return null;
} finally {
if (name && kind) { state.localBusy.delete(name); refreshCard(name); }
if (opts.refresh !== false) poller.now();
}
}
const actions = {
deploy(name) {
return runOp(name, "deploy", `Deploy · ${name}`, () => api.deploy(name), { success: `${name} deployed` });
},
restart(name) {
return runOp(name, "restart", `Restart · ${name}`, () => api.restart(name), { success: `${name} restarted` });
},
async stop(name) {
const ok = await confirmDialog({
title: `Stop ${name}?`,
body: [h("p", null, "Its containers are stopped and removed; data folders are kept. The app's domains return errors until you deploy again.")],
confirmLabel: "Stop app",
});
if (ok) return runOp(name, "stop", `Stop · ${name}`, () => api.stop(name), { success: `${name} stopped` });
},
async sync(name) {
const res = await runOp(name, "repo-pull", `Sync from git · ${name}`, () => api.repoPull(name), {
success: `${name} synced`,
successDetail: (r) => r.after
? (r.changed ? `Now at ${r.after.short}: ${r.after.subject}` : `Already at ${r.after.short} — redeployed.`)
: "Redeployed.",
});
const card = cards.get(name);
if (res && card?.panels) {
card.panels.compose.reloadIfClean();
if (card.activeTab === "source") card.panels.source.load(false);
}
return res;
},
async remove(name) {
const choice = await confirmDialog({
title: `Remove ${name}?`,
body: [h("p", null, "Stops the app and removes its routes and compose stack. Backups are kept.")],
confirmLabel: "Remove app", danger: true,
checkbox: { label: "Also permanently delete its data folder", checked: false },
typeWhenChecked: name,
});
if (!choice) return;
const res = await runOp(name, "remove", `Remove · ${name}`, () => api.remove(name, !choice.checked), {
success: `${name} removed`,
successDetail: () => (choice.checked ? "Its data was deleted." : "Its data folder was kept."),
});
if (res && state.expanded === name) { state.expanded = null; setHash(); }
},
};
// ─── Menu ──────────────────────────────────────────────────────────────────
const menu = {
anchor: null,
open(anchor, items) {
if (this.anchor === anchor) { this.close(); return; }
this.close();
const el = $("#menu");
fill(el, ...items.map((it) => it === "-"
? h("div", { class: "menu-sep" })
: h("button", {
type: "button", role: "menuitem", class: `menu-item ${it.variant || ""}`, disabled: it.disabled,
onclick: () => { this.close(); it.run(); },
}, icon(it.icon), it.label)));
el.hidden = false;
const r = anchor.getBoundingClientRect();
const left = Math.max(8, Math.min(r.right - el.offsetWidth, innerWidth - el.offsetWidth - 8));
let top = r.bottom + 6;
if (top + el.offsetHeight > innerHeight - 8) top = Math.max(8, r.top - el.offsetHeight - 6);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
this.anchor = anchor;
el.querySelector("button:not(:disabled)")?.focus();
},
close() {
$("#menu").hidden = true;
this.anchor = null;
},
};
function openAppMenu(anchor, name) {
const app = state.apps.get(name);
if (!app) return;
const busy = !!busyOf(app);
menu.open(anchor, [
{ label: "Restart", icon: "rotate", run: () => actions.restart(name), disabled: busy },
{ label: "Stop", icon: "stop", run: () => actions.stop(name), disabled: busy },
app.repo_url ? { label: "Sync from git", icon: "git", run: () => actions.sync(name), disabled: busy } : null,
{ label: "View logs", icon: "logs", run: () => toggleExpand(name, "logs") },
{ label: "Edit compose", icon: "file", run: () => toggleExpand(name, "compose") },
"-",
{ label: "Remove…", icon: "trash", run: () => actions.remove(name), variant: "danger", disabled: busy },
].filter(Boolean));
}
document.addEventListener("click", (e) => {
if ($("#menu").hidden) return;
if (e.target.closest("#menu") || menu.anchor?.contains(e.target)) return;
menu.close();
});
window.addEventListener("resize", () => menu.close());
window.addEventListener("scroll", () => menu.close(), { passive: true });
// ─── Toasts ────────────────────────────────────────────────────────────────
function toast(kind, title, { detail, action, timeout } = {}) {
const box = $("#toasts");
let timer;
const dismiss = () => {
clearTimeout(timer);
t.classList.add("leaving");
setTimeout(() => t.remove(), 180);
};
const t = h("div", { class: `toast toast-${kind}`, role: kind === "error" ? "alert" : "status" },
icon(kind === "success" ? "check" : kind === "error" ? "alert" : "activity"),
h("div", { class: "toast-body" },
h("div", { class: "toast-title" }, title),
detail ? h("div", { class: "toast-detail" }, detail) : null,
action ? h("button", { type: "button", class: "link", onclick: () => { action.run(); dismiss(); } }, action.label) : null),
h("button", { type: "button", class: "toast-close", "aria-label": "Dismiss", onclick: dismiss }, icon("x")));
box.append(t);
while (box.children.length > 4) box.firstChild.remove();
const ms = timeout ?? (kind === "error" ? 9000 : 4000);
timer = setTimeout(dismiss, ms);
t.addEventListener("mouseenter", () => clearTimeout(timer));
t.addEventListener("mouseleave", () => { timer = setTimeout(dismiss, 2500); });
}
// ─── Activity drawer ───────────────────────────────────────────────────────
const activity = {
entries: [],
seq: 0,
start(label) {
const entry = { id: ++this.seq, label, started: Date.now(), status: "running", output: "", open: false };
entry.finish = (ok, output) => {
entry.status = ok ? "ok" : "error";
entry.output = output || "";
entry.ended = Date.now();
this.render();
};
this.entries.unshift(entry);
if (this.entries.length > 50) this.entries.length = 50;
this.render();
return entry;
},
running() { return this.entries.filter((e) => e.status === "running").length; },
render() {
const n = this.running();
$("#activityCount").hidden = !n;
$("#activityCount").textContent = n;
if ($("#activity").hidden) return;
const list = $("#activityList");
if (!this.entries.length) {
fill(list, h("div", { class: "empty-sm" }, "Nothing yet. Deploys, syncs and other operations you run appear here together with their output."));
return;
}
fill(list, ...this.entries.map((e) => {
const duration = e.ended ? `${((e.ended - e.started) / 1000).toFixed(1)}s` : `${Math.round((Date.now() - e.started) / 1000)}s…`;
const head = h("div", { class: "act-head" },
e.status === "running" ? h("span", { class: "spinner" }) : icon(e.status === "ok" ? "check" : "alert", `act-${e.status}`),
h("span", { class: "act-label", title: e.label }, e.label),
h("span", { class: "muted small" }, `${new Date(e.started).toLocaleTimeString()} · ${duration}`));
if (!e.output) return h("div", { class: "act" }, head);
return h("details", { class: "act", open: e.open, ontoggle: (ev) => { e.open = ev.target.open; } },
h("summary", null, head), h("pre", { class: "logs sm" }, e.output));
}));
},
open(id) {
const entry = this.entries.find((e) => e.id === id);
if (entry) entry.open = true;
$("#activity").hidden = false;
this.render();
},
toggle() {
if ($("#activity").hidden) this.open(); else this.close();
},
close() { $("#activity").hidden = true; },
};
// ─── Confirm dialog ────────────────────────────────────────────────────────
function confirmDialog({ title, body = [], confirmLabel = "Confirm", danger = false, checkbox = null, typeToConfirm = null, typeWhenChecked = null }) {
const dlg = $("#confirmDlg");
const cb = $("#cfCheck"), typed = $("#cfType"), ok = $("#cfOk");
$("#cfTitle").textContent = title;
fill($("#cfBody"), ...body);
$("#cfCheckWrap").hidden = !checkbox;
cb.checked = !!checkbox?.checked;
$("#cfCheckLabel").textContent = checkbox?.label || "";
ok.textContent = confirmLabel;
ok.className = `btn ${danger ? "danger" : "primary"}`;
typed.value = "";
const needed = () => typeToConfirm || (typeWhenChecked && cb.checked ? typeWhenChecked : null);
const refresh = () => {
const n = needed();
$("#cfTypeWrap").hidden = !n;
$("#cfTypeName").textContent = n || "";
ok.disabled = !!n && typed.value.trim() !== n;
};
cb.onchange = refresh;
typed.oninput = refresh;
typed.onkeydown = (e) => {
if (e.key === "Enter") { e.preventDefault(); if (!ok.disabled) dlg.close("ok"); }
};
refresh();
dlg.returnValue = "";
dlg.showModal();
(needed() ? typed : danger ? $("#cfCancel") : ok).focus();
return new Promise((resolve) => {
dlg.onclose = () => {
// Don't leave focus on a control inside the closed dialog (it would swallow shortcuts).
if (dlg.contains(document.activeElement)) document.activeElement.blur();
resolve(dlg.returnValue === "ok" ? { checked: cb.checked } : null);
};
});
}
// ─── New app dialog ────────────────────────────────────────────────────────
function suggestPort() {
let max = 18079;
for (const app of state.apps.values()) {
for (const r of app.routes) {
const port = Number(String(r.upstream).split(":").pop());
if (port >= 18000 && port < 20000 && port > max) max = port;
}
}
return max + 1;
}
function suggestBaseDomain() {
const counts = new Map();
for (const app of state.apps.values()) {
for (const r of app.routes) {
const labels = r.domain.replace(/^\*\./, "").split(".");
if (labels.length >= 2) {
const base = labels.slice(-2).join(".");
counts.set(base, (counts.get(base) || 0) + 1);
}
}
}
let best = null, bestCount = 0;
for (const [base, c] of counts) if (c > bestCount) { best = base; bestCount = c; }
if (best) return best;
const host = location.hostname.split(".");
return host.length > 2 ? host.slice(1).join(".") : (location.hostname || "example.com");
}
const composeTemplate = (port) => `services:
app:
image: docker.io/library/nginx:alpine
restart: unless-stopped
ports:
# Publish on localhost only; Caddy routes the domain to this port.
- "127.0.0.1:${port}:80"
`;
const SOURCE_HINTS = {
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.",
git: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
};
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; } });
$("#naRoutes").append(this.routes.el);
this.env = envEditor({ onChange: () => this.updateEnvCount() });
$("#naEnv").append(this.env.el);
$("#naAddRoute").append(icon("plus"), "Add route");
$("#naAddRoute").onclick = () => this.routes.add();
$("#naSource").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setSource(b.dataset.v);
};
$("#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;
});
$("#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.selectedRepo = null;
this.reposLoaded = false;
this.port = suggestPort();
this.base = suggestBaseDomain();
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
this.routes.first().d.placeholder = `app.${this.base}`;
this.setSource("default");
this.dlg.showModal();
$("#naName").focus();
},
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()) {
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
$("#naCompose").value = composeTemplate(port);
}
if (v === "git") this.initGit();
},
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>