Files
Keysat 798a698132 Add Users dashboard tab with per-user balances and credit grants
New cookie-gated "Users" tab on the operator dashboard: a sortable view
of every credit-ledger row (typed cloud/license/install) with computed
remaining/total balances, key filter, and a per-row "grant free credits"
action.

Endpoints (routes/admin.js):
- GET /admin/credits — snapshotAll() enriched with a type derived from
  the credit-key prefix and a computed balance (computeRemaining against
  live tier quotas), since the ledger stores consumed counters only.
- POST /admin/credits/grant {credit_key, amount} — adds free top-up via
  addPurchasedCredits. Grants land in the never-expires purchased bucket
  (spent after the tier allowance). Guards: positive integer, <=1,000,000,
  and the row must already exist (a typo can't spawn a ghost row).

Admin-only; no /relay/* client contract change. Tests added in
server/test/admin-credits.test.js (mount the real router over HTTP).
Version bumped 0.2.124 -> 0.2.125.
2026-06-15 16:25:14 -05:00

6859 lines
334 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recap Relay — Operator Dashboard</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--panel-2: #1e293b;
--line: #1e293b;
--line-2: #334155;
--fg: #e2e8f0;
--fg-dim: #94a3b8;
--fg-faint: #64748b;
--accent: #a5b4fc;
--good: #86efac;
--warn: #fbbf24;
--bad: #fca5a5;
--money: #4ade80;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px; min-height: 100vh;
background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px; line-height: 1.5;
}
a { color: var(--accent); }
.wrap { max-width: 1400px; margin: 0 auto; }
h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
h2 {
font-size: 14px; font-weight: 700; color: var(--fg-dim);
margin: 32px 0 12px; text-transform: uppercase; letter-spacing: 0.05em;
}
.subtitle { color: var(--fg-dim); font-size: 13px; margin: 0 0 20px; }
.controls {
display: flex; gap: 8px; align-items: center;
margin: 16px 0 24px; flex-wrap: wrap;
}
.range-btn {
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); padding: 6px 12px; border-radius: 8px;
cursor: pointer; font-size: 12px; font-weight: 600;
}
.range-btn.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.range-btn:hover:not(.active) { color: var(--fg); border-color: var(--fg-faint); }
.tiles {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.tile {
background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 16px;
}
.tile-label { font-size: 11px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.tile-value { font-size: 24px; font-weight: 700; color: var(--fg); margin-top: 6px; }
.tile-sub { font-size: 11px; color: var(--fg-dim); margin-top: 4px; }
.tile-good { color: var(--good); }
.tile-money { color: var(--money); }
table {
width: 100%; border-collapse: collapse; font-size: 12px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
}
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--line); }
th { color: var(--fg-faint); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; background: var(--panel-2); }
tr:last-child td { border-bottom: none; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
td.money { color: var(--money); font-variant-numeric: tabular-nums; }
td.dim { color: var(--fg-faint); }
.pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
}
.pill-core { background: rgba(148,163,184,0.12); color: var(--fg-dim); }
.pill-pro { background: rgba(99,102,241,0.18); color: var(--accent); }
.pill-max { background: rgba(168,85,247,0.20); color: #c4b5fd; }
.bar-container { display: flex; align-items: center; gap: 8px; }
.bar-track {
background: var(--panel-2); height: 6px; border-radius: 3px;
flex: 1; overflow: hidden; min-width: 80px;
}
.bar-fill { background: var(--accent); height: 100%; border-radius: 3px; }
.bar-fill-good { background: var(--good); }
.bar-fill-money { background: var(--money); }
.login-card {
max-width: 360px; margin: 80px auto;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 24px;
}
.login-card h1 { font-size: 18px; margin: 0 0 16px; }
.login-card input {
width: 100%; padding: 10px 12px; margin: 6px 0;
background: var(--bg); border: 1px solid var(--line-2); border-radius: 8px;
color: var(--fg); font-size: 13px; font-family: inherit;
}
.login-card button {
width: 100%; padding: 10px; margin-top: 12px;
background: var(--accent); border: none; border-radius: 8px;
color: var(--bg); font-size: 13px; font-weight: 700; cursor: pointer;
}
.login-card .error { color: var(--bad); font-size: 12px; margin-top: 8px; }
.loading, .empty { text-align: center; color: var(--fg-dim); padding: 40px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 880px) { .grid-2 { grid-template-columns: 1fr; } }
code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 11px; color: var(--fg-dim);
}
.alert-banner {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.4);
color: var(--bad);
border-radius: 10px;
padding: 14px 18px;
margin: 16px 0 0;
font-size: 13px;
font-weight: 600;
}
.alert-banner .alert-sub {
font-weight: 400; color: var(--bad); opacity: 0.85;
margin-top: 4px; font-size: 12px;
}
.csv-link {
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); padding: 6px 12px; border-radius: 8px;
font-size: 12px; font-weight: 600; margin-left: auto;
text-decoration: none; display: inline-block;
}
.csv-link:hover { color: var(--fg); border-color: var(--fg-faint); }
.status-success { color: var(--good); }
.status-error { color: var(--bad); }
.status-partial { color: var(--warn); }
.err-msg {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 11px; color: var(--bad);
max-width: 480px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
td.tight, th.tight { padding: 6px 10px; }
.margin-pos { color: var(--money); }
.margin-neg { color: var(--bad); }
/* ── Tab navigation ─────────────────────────────────────── */
.tabs {
display: flex; gap: 4px; border-bottom: 1px solid var(--line);
margin: 8px 0 20px; align-items: flex-end;
}
.tab {
background: transparent; border: 1px solid transparent;
border-bottom: none; color: var(--fg-dim);
padding: 10px 16px; border-radius: 8px 8px 0 0;
cursor: pointer; font-size: 13px; font-weight: 600;
margin-bottom: -1px;
}
.tab:hover { color: var(--fg); }
.tab.active {
background: var(--panel); color: var(--fg);
border-color: var(--line); border-bottom: 1px solid var(--panel);
}
/* ── Jobs tab ─────────────────────────────────────────────── */
.jobs-summary {
display: grid; gap: 12px; margin: 16px 0 24px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.jobs-filters {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
margin: 16px 0; padding: 12px; background: var(--panel);
border: 1px solid var(--line); border-radius: 10px;
}
.jobs-filters label {
font-size: 11px; color: var(--fg-faint);
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600;
}
.jobs-filters select, .jobs-filters input {
background: var(--bg); border: 1px solid var(--line-2);
color: var(--fg); padding: 6px 10px; border-radius: 6px;
font-size: 12px; font-family: inherit; min-width: 100px;
}
.jobs-filters input.q { flex: 1; min-width: 180px; }
.jobs-filters button {
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); padding: 6px 12px; border-radius: 6px;
cursor: pointer; font-size: 12px; font-weight: 600;
}
.jobs-filters button:hover { color: var(--fg); }
.jobs-table-wrap {
/* Internal horizontal scroll for the wide table. overflow-y
is "clip" instead of "visible" because the CSS spec forces
"visible" to be implicitly "auto" when paired with a
scrolling axis — and "auto" creates a vertical scroll
CONTAINER inside the wrap, which can trap rows inside a
shorter-than-content box. "clip" doesn't establish a
scroll context; the page scrolls naturally and the wrap
grows tall to fit all rows. Per-browser fallback to
overflow-y: visible (some older Safari).
max-height: none guards against any cascaded constraint. */
overflow-x: auto; overflow-y: clip;
max-height: none;
background: var(--panel);
border: 1px solid var(--line); border-radius: 10px;
scrollbar-width: none; /* Firefox: hide native bar */
}
.jobs-table-wrap::-webkit-scrollbar { display: none; }
/* Fixed-position horizontal scrollbar that hugs the viewport
bottom whenever the Jobs tab is open AND the table actually
overflows horizontally. position: fixed (not sticky) ensures
it's always at viewport bottom regardless of vertical page
scroll position — the operator can scroll the page down to
see more rows, and the bar stays glued to the bottom of the
browser window the whole time. JS positions its left+width
to match the table's visible area and syncs scrollLeft with
the wrap. */
/* Raised off the very bottom of the viewport so the operator has
a few px of click leeway above the OS chrome / dock. Made
noticeably thicker so the scrollbar thumb is an easier target —
the old 12px height made horizontal scrolling fiddly on a
trackpad. Visually the wider band reads as a UI element rather
than a stray window edge. */
.jobs-sticky-scroll {
position: fixed; bottom: 16px; left: 0; z-index: 50;
overflow-x: scroll; overflow-y: hidden;
height: 22px;
background: var(--bg);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
display: none; /* JS toggles to "block" when relevant */
}
.jobs-sticky-scroll::-webkit-scrollbar { height: 20px; }
.jobs-sticky-scroll::-webkit-scrollbar-track { background: var(--bg); }
.jobs-sticky-scroll::-webkit-scrollbar-thumb {
background: var(--line-2); border-radius: 10px;
border: 4px solid var(--bg);
}
.jobs-sticky-scroll::-webkit-scrollbar-thumb:hover { background: var(--fg-faint); }
.jobs-sticky-scroll-inner { height: 1px; }
/* Global in-flight job indicator. Fixed at top-right of the
viewport so it persists across tab changes (each tab rewrites
#root, so this lives outside the root). Compact pill with a
pulsing dot — the operator gets a peripheral "something is
running" signal without it stealing focus from the current
tab's content. Hidden via display:none when state.activeJobs is
empty. */
#global-inflight-bar {
position: fixed;
top: 16px;
right: 18px;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px 8px 11px;
background: rgba(15, 23, 42, 0.92);
border: 1px solid var(--line-2);
border-radius: 999px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
font-size: 11.5px;
color: var(--fg);
cursor: pointer;
backdrop-filter: blur(6px);
transition: border-color 0.15s ease, background 0.15s ease;
}
#global-inflight-bar:hover {
border-color: var(--accent);
background: rgba(15, 23, 42, 0.98);
}
#global-inflight-bar .gb-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
animation: breadcrumb-pulse 1.2s infinite;
}
#global-inflight-bar .gb-stage {
color: var(--fg-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-left: 4px;
}
#global-inflight-bar .gb-jobcount {
font-weight: 700;
color: var(--fg);
}
/* "Active jobs" callout at the top of the Jobs tab. Sits ABOVE the
test-run panel as a dedicated section so it's the first thing
the operator sees on Jobs-tab entry — regardless of whether the
in-flight job came from the test-run panel or a Recap-submitted
summarize. */
.inflight-callout {
margin-bottom: 14px;
padding: 14px 16px;
background: var(--panel);
border: 1px solid var(--line-2);
border-left: 3px solid var(--accent);
border-radius: 6px;
}
.inflight-callout .ic-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-dim);
font-weight: 700;
}
.inflight-callout .ic-header .ic-count {
color: var(--accent);
font-weight: 700;
}
.inflight-callout .ic-jobrow {
padding: 10px 12px;
background: var(--panel-2);
border: 1px solid var(--line-2);
border-radius: 6px;
margin-bottom: 8px;
}
.inflight-callout .ic-jobrow:last-child {
margin-bottom: 0;
}
.inflight-callout .ic-jobrow-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 11px;
color: var(--fg-dim);
}
.inflight-callout .ic-jobrow-header .ic-jobid {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-faint);
}
.inflight-callout .ic-jobrow-header .ic-source {
padding: 2px 8px;
border-radius: 999px;
background: rgba(165,180,252,0.12);
color: var(--accent);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inflight-callout .ic-jobrow-header .ic-source.ic-source-testrun {
background: rgba(74,222,128,0.10);
color: var(--good);
}
.inflight-callout .ic-jobrow-header .ic-elapsed {
font-variant-numeric: tabular-nums;
color: var(--fg-faint);
}
/* Pizza-tracker breadcrumb pulse for the currently-active stage. */
@keyframes breadcrumb-pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
/* Generic pulse used by the hardware-queue chip's active dot. */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.45; }
100% { opacity: 1; }
}
.jobs-table {
width: 100%; min-width: 1600px; border-collapse: collapse;
font-size: 11px; border: none; border-radius: 0;
/* table-layout: auto so columns size naturally on first paint;
user-set explicit widths via the resize handles override.
Cells use overflow:hidden + ellipsis so a narrowed column
truncates instead of forcing the column wider. */
}
.jobs-table th {
cursor: pointer; user-select: none;
position: sticky; top: 0; background: var(--panel-2); z-index: 1;
position: relative; /* anchor for the resize handle */
vertical-align: middle;
padding: 6px 14px 6px 8px; /* 14px right = handle (8px) + 6px buffer */
/* TH itself stays display: table-cell so the table layout works.
The actual wrap+clamp lives on the inner .th-label span below
(which uses display: -webkit-box for line-clamp support —
that display value is incompatible with table-cell). */
white-space: nowrap;
}
.jobs-table th .th-label {
/* Wrap header text at word boundaries (spaces, slashes, dashes
when natural break-points exist). NEVER break inside a word
like "TX" or "CHUNKS" — that produced the letter-per-line
vertical stack of doom we just fixed. Capped at 2 lines via
-webkit-line-clamp so a too-narrow column truncates with
ellipsis instead of growing the header row arbitrarily tall. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-break: normal;
overflow-wrap: break-word; /* fallback ONLY if a single word can't fit */
line-height: 1.2;
max-height: calc(2 * 1.2em);
}
.jobs-table th:hover { color: var(--fg); }
.jobs-table th.sort-active { color: var(--accent); }
.jobs-table th .sort-ind { font-size: 9px; margin-left: 4px; opacity: 0.7; }
/* Vertical hit-strip at the right edge of each header for column
width resizing. Wider hit-strip (8px) for easier mouse target
— narrow columns still leave the strip reachable since the TH
has 14px right-padding. The visual is only 2px wide (centered)
so it doesn't intrude on the header label; the rest of the
hit-strip is transparent. */
.col-resize-handle {
position: absolute; right: 0; top: 0; bottom: 0; width: 8px;
cursor: col-resize; user-select: none;
}
.col-resize-handle::after {
content: ""; position: absolute;
right: 3px; top: 6px; bottom: 6px; width: 2px;
background: transparent;
}
.col-resize-handle:hover::after { background: var(--accent); }
body.col-resizing { cursor: col-resize !important; }
body.col-resizing * { user-select: none !important; pointer-events: none; }
body.col-resizing .col-resize-handle { pointer-events: auto; }
.jobs-table td {
white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
vertical-align: top;
/* min-width: 0 lets auto-layout columns shrink BELOW their
natural content width when the user resizes them narrower.
Without this, cell content forces a hard floor on the
column width even with overflow: hidden — so resizing the
YouTube URL column smaller does nothing until the user-set
width exceeds the cell's intrinsic width. The actual
max-width per cell is stamped inline by renderJobsBody when
the column has a defaultWidth or user-set width. */
min-width: 0;
}
.jobs-table tr:hover td { background: rgba(165,180,252,0.04); }
.jobs-table td.title-cell a { color: var(--accent); text-decoration: none; }
.jobs-table td.title-cell a:hover { text-decoration: underline; }
/* Errors column: single-line by default with a chevron that
expands JUST that one cell to wrap. Other cells in the same row
are unaffected, so the row collapses back to single-line as soon
as the expand chevron is toggled off. The TD's max-width is
applied INLINE per-cell (see the errors cellRenderer below) so
it tracks the column's effective width — catalog default OR
user resize. Without an inline max-width, auto-layout tables
grow the column to fit the unwrapped error string, blowing out
the table's horizontal scroll. */
.jobs-table td.errors-cell-td {
position: relative; padding-right: 28px;
overflow: hidden; text-overflow: ellipsis;
}
.jobs-table td.errors-cell-td.expanded { white-space: normal; vertical-align: top; }
.jobs-table td.errors-cell-td .err-text {
display: inline-block; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; vertical-align: middle;
}
.jobs-table td.errors-cell-td.expanded .err-text {
overflow: visible; text-overflow: clip; white-space: normal;
}
.jobs-table td.errors-cell-td .err-expand {
position: absolute; right: 8px; top: 50%;
transform: translateY(-50%);
width: 18px; height: 18px; border-radius: 4px;
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); font-size: 10px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
padding: 0; line-height: 1;
}
.jobs-table td.errors-cell-td .err-expand:hover { color: var(--accent); border-color: var(--accent); }
.jobs-table td.errors-cell-td.expanded .err-expand {
top: 4px; transform: none;
}
/* Copy-to-clipboard button. Sits just left of .err-expand so both
are reachable without overlapping the wrapped/expanded text.
Hidden by default, fades in on cell hover (operator-only
affordance — keeps the table dense for normal scanning). */
.jobs-table td.errors-cell-td .err-copy {
position: absolute; right: 30px; top: 50%;
transform: translateY(-50%);
width: 18px; height: 18px; border-radius: 4px;
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); font-size: 9px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
padding: 0; line-height: 1;
opacity: 0; transition: opacity 0.12s ease;
}
.jobs-table td.errors-cell-td:hover .err-copy { opacity: 1; }
.jobs-table td.errors-cell-td .err-copy:hover { color: var(--accent); border-color: var(--accent); }
.jobs-table td.errors-cell-td .err-copy.copied {
color: var(--good); border-color: var(--good); opacity: 1;
}
.jobs-table td.errors-cell-td.expanded .err-copy {
top: 4px; transform: none;
}
.status-pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 700; text-transform: uppercase;
}
.status-success { background: rgba(74,222,128,0.12); color: var(--good); }
.status-partial { background: rgba(251,191,36,0.14); color: var(--warn); }
.status-failed { background: rgba(252,165,165,0.14); color: var(--bad); }
/* "Inspect" button + per-job detail sub-row that drops in beneath
the main Jobs-table row when the operator clicks 🔍. The detail
row visually nests inside the main row (slight indent, subdued
background) and shows a compact per-audit-entry table for
diagnosing partial/failed runs without shell access. */
.job-detail-btn {
background: transparent; border: 0; cursor: pointer;
color: var(--fg-dim); font-size: 14px; padding: 2px 4px;
border-radius: 4px;
}
.job-detail-btn:hover { color: var(--accent); background: rgba(165,180,252,0.08); }
.jobs-table tr.job-detail-row td.job-detail-cell {
background: rgba(165,180,252,0.04);
border-top: 1px solid var(--line-2);
padding: 12px 18px 14px 32px;
white-space: normal;
vertical-align: top;
}
.job-detail-summary {
font-size: 11.5px; color: var(--fg-dim); margin-bottom: 8px;
}
.job-detail-summary b { color: var(--fg); font-weight: 700; }
.job-detail-warn { color: var(--warn); }
.job-detail-bad { color: var(--bad); font-weight: 600; }
.job-detail-table {
width: 100%; max-width: 1100px;
font-size: 11.5px;
border-collapse: collapse;
}
.job-detail-table th {
text-align: left; padding: 4px 8px;
color: var(--fg-faint); font-weight: 600;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em;
border-bottom: 1px solid var(--line-2);
}
.job-detail-table td {
padding: 5px 8px;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: top;
}
.job-detail-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.job-detail-table td.dim { color: var(--fg-faint); }
.job-detail-table td.job-detail-err {
max-width: 480px;
color: var(--fg-dim);
word-break: break-word;
}
.job-detail-loading, .job-detail-empty {
color: var(--fg-dim); font-size: 12px; font-style: italic;
}
.job-detail-error {
color: var(--bad); font-size: 12px;
}
.jobs-pagination {
display: flex; gap: 8px; align-items: center; justify-content: center;
margin: 16px 0; color: var(--fg-dim); font-size: 12px;
}
.jobs-pagination button {
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); padding: 6px 12px; border-radius: 6px;
cursor: pointer; font-size: 12px; font-weight: 600;
}
.jobs-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.jobs-pagination button:not(:disabled):hover { color: var(--fg); }
.jobs-empty {
padding: 40px 20px; text-align: center; color: var(--fg-faint); font-size: 13px;
}
.errors-cell { color: var(--bad); font-size: 10px; max-width: 360px; white-space: normal; }
/* Column drag visual feedback */
.jobs-table th[draggable="true"] { cursor: grab; }
.jobs-table th[draggable="true"]:active { cursor: grabbing; }
.jobs-table th.col-drag-over {
border-left: 2px solid var(--accent);
}
/* Floating right-click context menu */
.col-context-menu {
position: fixed; z-index: 1000;
background: var(--panel); border: 1px solid var(--line-2);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 180px; padding: 4px; font-size: 12px;
}
.col-context-menu .menu-label {
padding: 8px 12px 4px; font-size: 10px; color: var(--fg-faint);
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600;
}
.col-context-menu button {
display: block; width: 100%; text-align: left;
background: transparent; border: none; color: var(--fg);
padding: 8px 12px; cursor: pointer; border-radius: 6px;
font-size: 12px; font-family: inherit;
}
.col-context-menu button:hover:not(:disabled) { background: var(--panel-2); color: var(--accent); }
.col-context-menu button:disabled { color: var(--fg-faint); cursor: not-allowed; }
.col-context-menu button.has-submenu { display: flex; justify-content: space-between; align-items: center; }
.col-context-menu button.back-btn { color: var(--fg-dim); font-size: 11px; }
.col-context-menu hr {
border: none; border-top: 1px solid var(--line); margin: 4px 0;
}
.col-context-overlay {
position: fixed; inset: 0; z-index: 999; background: transparent;
}
/* ── Test-run panel ───────────────────────────────────────── */
.tr-label {
display: block; font-size: 11px; color: var(--fg-faint);
text-transform: uppercase; letter-spacing: 0.04em;
font-weight: 600; margin-bottom: 4px;
}
.tr-input {
width: 100%; padding: 7px 10px;
background: var(--bg); border: 1px solid var(--line-2);
color: var(--fg); border-radius: 6px; font-size: 12px;
font-family: inherit; box-sizing: border-box;
}
.tr-input:focus { border-color: var(--accent); outline: none; }
.tr-input:disabled { opacity: 0.5; cursor: not-allowed; }
.tr-toggle {
display: inline-flex; gap: 0;
background: var(--bg); border: 1px solid var(--line-2);
border-radius: 6px; overflow: hidden;
}
.tr-toggle button {
background: transparent; border: none;
color: var(--fg-dim); padding: 7px 14px;
cursor: pointer; font-size: 12px; font-weight: 600;
font-family: inherit;
}
.tr-toggle button:hover:not(.active):not(:disabled) { color: var(--fg); }
.tr-toggle button.active { background: var(--accent); color: var(--bg); }
.tr-toggle button:disabled { opacity: 0.5; cursor: not-allowed; }
.tr-btn {
background: var(--panel-2); border: 1px solid var(--line-2);
color: var(--fg-dim); padding: 8px 16px; border-radius: 6px;
cursor: pointer; font-size: 12px; font-weight: 600;
font-family: inherit;
}
.tr-btn:hover:not(:disabled) { color: var(--fg); border-color: var(--fg-faint); }
.tr-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tr-btn-primary {
background: var(--accent); color: var(--bg); border-color: var(--accent);
}
.tr-btn-primary:hover:not(:disabled) { background: #c4b5fd; color: var(--bg); }
/* ─── Settings tab ─────────────────────────────────────────── */
/* Visual goal: feel like an extension of the Overview + Jobs
tabs — same panel/border/spacing language, same accent for
interactive state, same compact information density. The old
layout used native dropdowns + number spinners which looked
like a generic form; this rewrite uses pills (toggle-buttons)
and slider+number combos that read as "control panel" instead. */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 900px) {
.settings-grid { grid-template-columns: 1fr; }
}
.settings-panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px 16px;
}
.settings-panel-title {
font-size: 11px; font-weight: 700;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-dim);
margin-bottom: 12px;
display: flex; align-items: center; gap: 8px;
}
.settings-panel-title .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent);
}
.settings-panel-title.gemini .dot { background: #4a9eff; }
.settings-panel-title.hardware .dot { background: #ff9b3a; }
.settings-panel-title.routing .dot { background: #c084fc; }
.settings-panel-title.shared .dot { background: var(--fg-faint); }
.settings-panel-title.output .dot { background: var(--good); }
.settings-panel-title.prompts .dot { background: #7dd3fc; }
/* Collapsible settings panels (Grant's compact view).
Click a panel title to collapse its body; chevron rotates.
State persists to localStorage under recaps:settings:collapsed
so a reload doesn't pop everything back open. Applies to the
top-level settings panels AND each individual LLM-prompt
block inside the prompts panel. */
.settings-panel-title,
.prompt-block-header {
cursor: pointer; user-select: none;
}
.settings-panel .panel-chevron,
.prompt-block-header .panel-chevron {
margin-left: auto;
font-size: 16px;
line-height: 1;
color: var(--fg-dim);
transition: transform 0.15s ease;
padding: 0 2px;
}
.settings-panel.collapsed .panel-chevron,
.prompt-block.collapsed .panel-chevron {
transform: rotate(-90deg);
}
.settings-panel.collapsed .settings-panel-body { display: none; }
.settings-panel.collapsed .settings-panel-title { margin-bottom: 0; }
.prompt-block.collapsed .prompt-block-body { display: none; }
.prompt-block.collapsed .prompt-block-header { margin-bottom: 0; }
.prompt-block {
margin-top: 12px;
}
.prompt-block-header {
display: flex; align-items: center; gap: 8px;
font-size: 11px; font-weight: 600;
color: var(--fg);
margin-bottom: 6px;
padding: 2px 0;
}
.prompt-block-header:hover { color: var(--accent); }
.prompt-block-header:hover .panel-chevron,
.settings-panel-title:hover .panel-chevron { color: var(--fg-dim); }
/* Settings row: label on left, control on right, compact spacing. */
.settings-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 12px;
}
.settings-row:last-child { border-bottom: none; }
/* Compact single-line text input row used in Endpoints & credentials.
Three columns: status-dot + label (fixed width), input (flex),
and a small help-tooltip icon. One row = one field, no inline
help text — the description is hidden behind the ? icon. */
.settings-text-row {
display: flex; align-items: center; gap: 10px;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 12px;
}
.settings-text-row:last-child { border-bottom: none; }
.settings-text-row .settings-text-label {
flex: 0 0 auto; width: 190px;
color: var(--fg-dim); font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.settings-text-row input[type="text"],
.settings-text-row input[type="password"] {
flex: 1 1 auto; min-width: 0;
padding: 5px 9px;
background: var(--bg); border: 1px solid var(--line-2);
border-radius: 5px; color: var(--fg);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11.5px;
}
.settings-text-row input:focus { border-color: var(--accent); outline: none; }
/* Tooltip ? icon next to text inputs. Reveals the full help text
on hover/focus. Positioned via CSS so the tooltip pops above the
row and doesn't push siblings around. */
.settings-help-anchor {
position: relative;
flex: 0 0 auto;
width: 18px; height: 18px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(165,180,252,0.12);
color: var(--accent);
border-radius: 50%;
font-size: 11px; font-weight: 700;
cursor: help; user-select: none;
}
.settings-help-anchor:hover,
.settings-help-anchor:focus { background: rgba(165,180,252,0.22); outline: none; }
.settings-help-tooltip {
position: absolute;
bottom: calc(100% + 8px);
right: -4px;
width: 320px;
padding: 10px 12px;
background: #0b1220;
color: var(--fg);
border: 1px solid var(--line-2);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
font-size: 11.5px; font-weight: 400;
line-height: 1.5;
white-space: normal;
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 0.12s ease, transform 0.12s ease;
z-index: 50;
}
.settings-help-tooltip::after {
content: "";
position: absolute;
top: 100%;
right: 7px;
border: 6px solid transparent;
border-top-color: var(--line-2);
}
.settings-help-anchor:hover .settings-help-tooltip,
.settings-help-anchor:focus .settings-help-tooltip {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* Discovery health line — small status row under the Service
Discovery URL field. Shows whether the last fetch succeeded
and what services were returned (or the error message). */
.discovery-status {
margin: 4px 0 6px 200px; /* aligns under the input column */
font-size: 10.5px; line-height: 1.5;
color: var(--fg-dim);
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.discovery-status.ok { color: #86efac; }
.discovery-status.err { color: #fca5a5; }
.discovery-status.dim { color: var(--fg-faint); }
/* Collapsible "Advanced: manual overrides" accordion inside the
Endpoints & credentials panel. Closed by default. */
details.settings-advanced {
margin-top: 6px;
border-top: 1px dashed rgba(255,255,255,0.08);
padding-top: 8px;
}
details.settings-advanced > summary {
cursor: pointer;
list-style: none;
font-size: 11px;
font-weight: 600;
color: var(--fg-dim);
padding: 4px 0;
user-select: none;
}
details.settings-advanced > summary:hover { color: var(--fg); }
details.settings-advanced > summary::before {
content: "▸ ";
display: inline-block;
transition: transform 0.12s ease;
}
details.settings-advanced[open] > summary::before {
content: "▾ ";
}
details.settings-advanced > summary::-webkit-details-marker { display: none; }
details.settings-advanced .advanced-note {
font-size: 10.5px; color: var(--fg-faint);
font-weight: 400; margin-left: 6px;
}
.settings-row > label.row-label {
flex: 0 0 auto;
width: 130px;
color: var(--fg-dim);
font-weight: 500;
}
.settings-row > .row-control { flex: 1 1 auto; min-width: 0; }
/* Pill group — reuses the tr-toggle language but adds wrap so
long lists (5 model options, 4 routing modes) don't overflow
on narrow panels. */
.settings-pills {
display: inline-flex; flex-wrap: wrap; gap: 4px;
background: var(--bg); border: 1px solid var(--line-2);
border-radius: 6px; padding: 3px;
}
.settings-pills button {
background: transparent; border: none;
color: var(--fg-dim); padding: 5px 10px;
cursor: pointer; font-size: 11px; font-weight: 600;
font-family: inherit; border-radius: 4px;
white-space: nowrap;
}
.settings-pills button:hover:not(.active) { color: var(--fg); background: rgba(255,255,255,0.04); }
.settings-pills button.active { background: var(--accent); color: var(--bg); }
/* Slider + number-input pair. Number stays editable (operator can
type a precise value); slider gives a fast visual sweep with a
fat accent thumb. Number reflects slider live and vice-versa. */
.settings-slider {
display: flex; align-items: center; gap: 10px;
flex: 1 1 auto;
}
.settings-slider input[type="number"] {
width: 56px; padding: 4px 6px;
background: var(--bg); border: 1px solid var(--line-2);
color: var(--fg); border-radius: 5px; font-size: 12px;
font-family: inherit; text-align: right; font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
}
.settings-slider input[type="number"]::-webkit-outer-spin-button,
.settings-slider input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none; margin: 0;
}
.settings-slider input[type="number"]:focus { border-color: var(--accent); outline: none; }
.settings-slider input[type="range"] {
-webkit-appearance: none; appearance: none;
flex: 1 1 auto; height: 4px;
background: var(--line-2); border-radius: 2px;
outline: none; cursor: pointer; min-width: 80px;
}
.settings-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 16px; height: 16px;
background: var(--accent); border-radius: 50%;
cursor: grab; border: 2px solid var(--panel);
}
.settings-slider input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; }
.settings-slider input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px;
background: var(--accent); border-radius: 50%;
cursor: grab; border: 2px solid var(--panel);
}
.settings-slider .unit { color: var(--fg-faint); font-size: 11px; font-weight: 500; }
.settings-slider .default-hint { color: var(--fg-faint); font-size: 10px; font-style: italic; }
/* Toggle switch (boolean inputs) — visual on/off pill */
.settings-toggle {
display: inline-flex; align-items: center; gap: 8px;
cursor: pointer;
}
.settings-toggle input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 36px; height: 20px; border-radius: 999px;
background: var(--line-2); position: relative;
cursor: pointer; transition: background 0.15s;
}
.settings-toggle input[type="checkbox"]::after {
content: ""; position: absolute;
left: 2px; top: 2px; width: 16px; height: 16px;
background: var(--fg-dim); border-radius: 50%;
transition: left 0.15s, background 0.15s;
}
.settings-toggle input[type="checkbox"]:checked { background: rgba(165,180,252,0.4); }
.settings-toggle input[type="checkbox"]:checked::after { left: 18px; background: var(--accent); }
.settings-toggle .state-label {
font-size: 11px; color: var(--fg-dim); font-weight: 600;
min-width: 56px;
}
/* Prompt textarea cluster */
.settings-prompt {
background: var(--bg); border: 1px solid var(--line-2);
border-radius: 6px; padding: 8px;
}
.settings-prompt textarea {
width: 100%; box-sizing: border-box;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 11px; line-height: 1.5;
background: transparent; color: var(--fg);
border: none; outline: none; resize: vertical;
padding: 4px;
}
.settings-prompt-default {
display: none;
margin: 8px 0 0; padding: 10px 12px;
background: var(--panel-2); border: 1px solid var(--line-2);
border-radius: 4px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 10px; line-height: 1.5;
white-space: pre-wrap; color: var(--fg-dim);
max-height: 240px; overflow-y: auto;
}
.settings-prompt-controls {
display: flex; align-items: center; gap: 8px; margin-top: 8px;
flex-wrap: wrap;
}
.settings-actions {
display: flex; align-items: center; gap: 10px;
padding: 16px 0 8px;
border-top: 1px solid var(--line);
margin-top: 8px;
}
</style>
</head>
<body>
<!-- Global in-flight job indicator. Fixed at the top-right corner so
it stays visible regardless of which tab is rendered (each tab
clobbers #root.innerHTML, so anything inside root would vanish
on tab change). Hidden by default; populated by
renderGlobalInFlightBar() during every render() pass — appears
whenever state.activeJobs has at least one entry. Clicking it
switches to the Jobs tab. -->
<div id="global-inflight-bar"
onclick="switchTab('jobs')"
title="Click to view Jobs tab"
style="display:none;"></div>
<div class="wrap" id="root">
<div class="loading">Loading…</div>
</div>
<script>
const root = document.getElementById("root");
const globalInFlightBar = document.getElementById("global-inflight-bar");
// ── Column-prefs persistence ──
// Operator's chosen column order + which columns are hidden are
// stored in localStorage so they persist across page reloads. Keys
// namespaced per-dashboard so multiple StartOS installs (operator
// running both prod + test relays from one browser) don't collide.
const PREFS_COL_ORDER_KEY = "relay.dashboard.jobs.columnOrder.v1";
const PREFS_HIDDEN_KEY = "relay.dashboard.jobs.hiddenColumns.v1";
const PREFS_COL_WIDTHS_KEY = "relay.dashboard.jobs.columnWidths.v1";
function loadPrefs(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed ?? fallback;
} catch { return fallback; }
}
function savePrefs(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
let state = {
authed: false,
adminEnabled: false,
loginError: null,
data: null,
rangeDays: 30,
loading: true,
// Tab nav: "overview" (default) | "jobs" | "settings".
// Persisted across hard refresh via localStorage so the
// operator stays on whichever tab they were viewing instead
// of getting yanked back to Overview every reload.
activeTab: (() => {
try {
const saved = localStorage.getItem("recap-relay-active-tab");
if (saved === "jobs" || saved === "settings" || saved === "overview" || saved === "users") return saved;
} catch {}
return "overview";
})(),
// Users-tab state — enriched credit-ledger rows from /admin/credits.
creditsData: null,
creditsLoading: false,
creditsSort: { col: "last_active_at", dir: "desc" },
creditsQuery: "",
// Jobs-tab state.
jobsData: null,
jobsLoading: false,
jobsFilters: {
status: "",
transcribe_backend: "",
analyze_backend: "",
model: "",
q: "",
batch_id: "",
source: "",
},
jobsSort: { col: "started_at", dir: "desc" },
jobsPage: 1,
jobsPageSize: 100,
// Persistent column prefs (loaded from localStorage; saved on
// every change). Order is an array of column keys; hidden is an
// array of keys (Set-like; using array for JSON-friendly persist).
jobsColumnOrder: loadPrefs(PREFS_COL_ORDER_KEY, null),
jobsHiddenColumns: loadPrefs(PREFS_HIDDEN_KEY, []),
// Per-column width prefs (px). Loaded from localStorage; set
// by dragging the resize-handle at each header's right edge.
jobsColumnWidths: loadPrefs(PREFS_COL_WIDTHS_KEY, {}),
// Floating right-click context menu state. null when closed;
// { col, x, y } when open. The col is the column key the menu
// was opened on (drives the menu items: sort/hide for that col).
jobsContextMenu: null,
// Submenu open state for the "Show hidden columns" hover. The
// submenu lists each hidden column individually for one-at-a-
// time unhide. null when closed.
jobsContextSubmenu: null, // "hidden" | null
// Drag-to-reorder state. Tracks which column key is currently
// being dragged; null when no drag in progress.
jobsDragCol: null,
// Per-row expand for the errors cell. Map of job_id → bool;
// flipping one row's errors to wrap leaves other rows on the
// default single-line height.
jobsExpandedErrors: {},
// Per-row expand for the "Inspect" diagnostic. Map of job_id →
// { loading, error, summary, rows } populated lazily on first
// click of the 🔍 column for a row. Renders a sub-row beneath
// the main row showing every audit-log entry keyed to that
// job_id (transcribe row + one row per analyze window), so the
// operator can see WHICH window failed and WHY when a job's
// overall_status comes back as "partial" or "failed".
jobsExpandedDetails: {},
// Set of job_ids selected via the row-checkbox column. Used
// by the "Delete selected" button under the Stored Outputs
// mini-panel. Built incrementally as checkboxes are clicked;
// cleared after a successful bulk-delete.
jobsSelected: new Set(),
// Cached output-store summary { count, total_bytes } refreshed
// alongside the Jobs table.
outputStoreStats: null,
// ── Test-run panel state ──
// Lets the operator submit one or many transcribe+analyze jobs
// directly from the dashboard, with explicit backend/model
// overrides. The benchmark suite fires 6 pre-defined
// permutations sequentially with a shared batch_id so the rows
// are side-by-side comparable in the Jobs table below.
testRun: {
url: "",
captionsMode: "skip", // "skip" | "use" — when "use", relay
// fetches YouTube captions via yt-dlp
// and skips audio transcribe entirely.
// Only valid for YouTube URLs.
txBackend: "gemini", // "gemini" | "hardware"
txModel: "gemini-3-flash-preview",
anBackend: "gemini",
anModel: "gemini-3.1-pro-preview",
},
testRunStatus: "idle", // "idle" | "running" | "error"
testRunMessage: "",
testRunLastInputs: null, // for "Rerun last" button
testRunBatchId: null, // current batch ID when a suite is running
testRunSuiteIdx: 0, // current permutation index when suite is running
testRunSuiteTotal: 0,
// In-flight test-run jobs not yet present in jobsData. Keyed by
// job_id. Each entry: { input, startedAt, status, progress,
// stage, jobIdShort }. Used to (a) render synthetic "pending"
// rows at the top of the Jobs table the moment a single run is
// submitted, and (b) drive the pizza-tracker breadcrumb in the
// test-run panel based on the relay's reported progress text.
// Drained as the real audit row appears in jobsData OR when
// the job hits a terminal state.
activeJobs: {},
};
// ── Boot ──
boot();
async function boot() {
try {
const status = await fetch("/admin/status").then(r => r.json());
state.adminEnabled = !!status.enabled;
if (!state.adminEnabled) {
state.authed = true; // no gate
await loadDashboard();
} else {
// Try fetching the dashboard — if we have a session cookie
// already it'll succeed, otherwise we'll see 401 and show
// the login form.
const r = await fetch("/admin/dashboard?days=" + state.rangeDays);
if (r.ok) {
state.authed = true;
state.data = await r.json();
state.loading = false;
render();
} else {
state.authed = false;
state.loading = false;
render();
}
}
// Persisted tab — if the operator hard-refreshed while on
// Jobs or Settings, lazy-load the relevant data so the tab
// is populated when render runs (not just empty until they
// click a tab manually).
if (state.authed && state.activeTab === "jobs" && !state.jobsData) {
loadJobs();
// Resume an active suite's progress tracker if there was one.
tryResumeActiveBatch();
}
if (state.authed && state.activeTab === "settings" && !state.settingsData) {
loadSettings();
}
if (state.authed && state.activeTab === "meetings" && !state.meetingsList) {
loadMeetingsList();
}
if (state.authed && state.activeTab === "users" && !state.creditsData) {
loadCredits();
}
// Start the Overview auto-refresh poll on boot regardless of
// current tab — cheap (one fetch every 10s) and ensures the
// tab is current the moment the operator switches to it.
// The poll body short-circuits when activeTab !== "overview"
// so there's no actual work when on Jobs/Settings.
if (state.authed) {
startDashboardAutoRefreshPoll();
}
// Resume any single-perm test runs that were still running
// when the browser was refreshed (or the operator switched
// devices). The relay's job state persists in-process so
// /admin/jobs lets us reconstruct state.activeJobs for any
// admin-test-run job still in flight — the pizza-tracker
// breadcrumb and synthetic pending row then reappear without
// the operator having to do anything.
if (state.authed) tryResumeActiveSingleRuns().catch(() => {});
// Start in-flight discovery polling on auth regardless of
// which tab the operator is on. Recap-submitted jobs are
// typically short (90-300s) — if the operator was on
// Overview / Settings when a submission landed, the old
// "only poll while on Jobs tab" gate would miss the entire
// lifecycle and the row only appears AFTER the operator
// switches to Jobs AND refreshes manually. Continuous
// background discovery keeps state.activeJobs hot so the
// pending row paints on first Jobs-tab visit.
if (state.authed) {
startInFlightDiscoveryPoll();
}
} catch (e) {
renderError(e.message);
}
}
async function loadDashboard(opts = {}) {
// opts.silent = true → skip the "Loading…" interstitial render so
// a periodic background refresh doesn't flash a loading state in
// the operator's face. Used by the Overview auto-refresh poll.
const { silent = false } = opts;
if (!silent) {
state.loading = true;
render();
}
try {
const r = await fetch("/admin/dashboard?days=" + state.rangeDays);
if (!r.ok) throw new Error("HTTP " + r.status);
state.data = await r.json();
state.loading = false;
// Preserve scroll position across the re-render so a 10s
// refresh doesn't yank the operator's view back to the top
// mid-scroll. Capture before, restore after one paint tick
// (the new DOM needs to be laid out before scrollY assignment
// can land at the same logical position).
const prevY = window.scrollY;
render();
if (silent && prevY > 0) {
requestAnimationFrame(() => {
window.scrollTo({ top: prevY, left: 0, behavior: "instant" });
});
}
} catch (e) {
if (silent) {
// Don't blow up the page on a transient poll failure —
// keep the existing state.data, log to console, swallow.
console.warn("[dashboard auto-refresh] poll failed:", e?.message || e);
} else {
renderError(e.message);
}
}
}
// ── Overview tab auto-refresh poll ─────────────────────────────
// Re-fetches /admin/dashboard every 10 seconds while the operator
// is on the Overview tab so the summaries / errors / by-model
// tables update without a manual page refresh. Scroll position is
// preserved across each refresh (see loadDashboard opts.silent).
// Started when the Overview tab is entered; stopped when leaving.
let _dashboardAutoRefreshHandle = null;
function startDashboardAutoRefreshPoll() {
if (_dashboardAutoRefreshHandle) return;
_dashboardAutoRefreshHandle = setInterval(() => {
if (state.activeTab !== "overview") return;
if (!state.authed) return;
loadDashboard({ silent: true });
}, 10_000);
}
function stopDashboardAutoRefreshPoll() {
if (_dashboardAutoRefreshHandle) {
clearInterval(_dashboardAutoRefreshHandle);
_dashboardAutoRefreshHandle = null;
}
}
async function doLogin(ev) {
ev.preventDefault();
const username = document.getElementById("u").value.trim();
const password = document.getElementById("p").value;
state.loginError = null;
try {
const r = await fetch("/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "same-origin",
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
state.loginError = e.error || "Login failed (HTTP " + r.status + ")";
render();
return;
}
state.authed = true;
await loadDashboard();
// Mirror the boot() initialization: once auth lands, kick off
// the discovery polling and the resume-active-runs sweep so a
// job submitted from another client (e.g., Recap) is picked
// up immediately rather than waiting for the next manual page
// reload. Without this, a fresh login from the form leaves
// _discoverPollHandle null and discovery never starts.
tryResumeActiveSingleRuns().catch(() => {});
startInFlightDiscoveryPoll();
} catch (e) {
state.loginError = e.message;
render();
}
}
function setRange(days) {
state.rangeDays = days;
loadDashboard();
}
function fmtMoney(n) {
const v = typeof n === "number" ? n : 0;
if (v === 0) return "$0.00";
if (v < 0.01) return "$" + v.toFixed(4);
if (v < 1) return "$" + v.toFixed(3);
return "$" + v.toFixed(2);
}
function fmtInt(n) { return (typeof n === "number" ? n : 0).toLocaleString("en-US"); }
function fmtMs(n) {
if (typeof n !== "number" || !isFinite(n) || n <= 0) return "—";
if (n < 1000) return n.toFixed(0) + "ms";
return (n / 1000).toFixed(1) + "s";
}
function fmtTs(ms) {
if (!ms) return "—";
return new Date(ms).toLocaleString();
}
function shortId(id) {
if (!id || id.length < 12) return id || "—";
return id.slice(0, 8) + "…" + id.slice(-4);
}
function tierPill(t) {
const cls = t === "pro" ? "pill-pro" : t === "max" ? "pill-max" : "pill-core";
return '<span class="pill ' + cls + '">' + (t || "core") + "</span>";
}
function esc(s) {
return String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function bar(value, max, kind) {
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
const cls = kind === "money" ? "bar-fill-money" : kind === "good" ? "bar-fill-good" : "bar-fill";
return '<div class="bar-container"><div class="bar-track"><div class="' + cls + '" style="width:' + pct.toFixed(1) + '%;"></div></div></div>';
}
// Lightweight refresh — updates ONLY the out-of-root global
// in-flight bar and (when the operator happens to already be on
// the Jobs tab) the Jobs-tab body. Skips Settings-tab re-renders
// entirely so a periodic background tick from the discovery poll
// or pollTestRunJob can't clobber an in-progress slider drag /
// text edit / pill click. Used by every periodic poll path; the
// FULL render() is reserved for user-driven state mutations
// (tab switch, form submit, etc.) where a fresh body render is
// expected.
function renderLightweight() {
renderGlobalInFlightBar();
// Jobs tab: do a full render so the in-flight callout, the
// diagnostic strip, and the pending rows all refresh. The Jobs
// tab has no form state to worry about.
if (state.activeTab === "jobs") {
render();
}
// Overview / Settings: skip the body render. The global pill
// is the only signal needed; the body's state hasn't changed
// in a way the user cares about, and re-rendering would yank
// any form input the operator is mid-edit.
}
// Update the global in-flight indicator. Called from render() (so
// it stays in sync with state.activeJobs) but writes to a node
// OUTSIDE #root, so it persists across tab switches that clobber
// root.innerHTML. Hides itself entirely when no jobs are
// in flight; otherwise shows a compact pill with active-job count
// and (when there's exactly one job) the current stage label.
function renderGlobalInFlightBar() {
if (!globalInFlightBar) return;
const activeIds = Object.keys(state.activeJobs || {});
if (activeIds.length === 0 || !state.authed) {
globalInFlightBar.style.display = "none";
return;
}
const STAGE_LABELS = ["Downloading", "Transcribing", "Analyzing", "Done"];
let stageBit = "";
if (activeIds.length === 1) {
const job = state.activeJobs[activeIds[0]];
const stage = job && job.stage;
if (typeof stage === "number" && stage >= 1 && stage <= 4) {
stageBit = '<span class="gb-stage">' + esc(STAGE_LABELS[stage - 1]) + '</span>';
}
}
const countLabel = activeIds.length === 1
? '<span class="gb-jobcount">1</span> job in flight'
: '<span class="gb-jobcount">' + activeIds.length + '</span> jobs in flight';
// Don't show the bar when the operator is already on the Jobs
// tab — they have the dedicated callout right there, an
// additional fixed pill would be visual noise. Reappears
// automatically when they navigate away.
if (state.activeTab === "jobs") {
globalInFlightBar.style.display = "none";
return;
}
globalInFlightBar.innerHTML =
'<span class="gb-dot"></span>' + countLabel + stageBit;
globalInFlightBar.style.display = "flex";
}
function render() {
// Update the global in-flight bar BEFORE the body render so it
// doesn't flicker on tab change. It writes to a node outside
// #root so it survives the innerHTML clobber below.
renderGlobalInFlightBar();
// Skip renders while a column resize OR column drag-reorder is
// in progress. The 2s pizza-tracker poll calls render()
// periodically; without this gate, a mid-drag innerHTML
// replacement destroys the TH being dragged, the browser's
// drag-tracking loses its target and fires dragend WITHOUT
// firing drop (the columns appear to snap back unchanged).
// The resize / drop / dragend handlers each fire their own
// render() at the end so we catch up to any state mutations
// that happened during the drag.
if (_colResize) return;
if (state.jobsDragCol) return;
// Settings-tab guard: if the operator is on the Settings tab AND
// the body is already rendered with Settings content, skip the
// root.innerHTML rewrite. The Settings tab is a pure form — its
// visual state lives in DOM input/slider values, NOT in
// state.settingsData. A periodic re-render rebuilds the form
// from state.settingsData and yanks any in-progress slider drag
// / textarea edit / pill click back to the last-saved value.
//
// Switching INTO Settings still re-renders correctly because
// before that, the body has Overview / Jobs content (no
// .settings-actions element); the check below fails and we
// fall through to the full render. Save/Reset actions also
// re-render correctly — they explicitly re-fetch settingsData
// first which the Settings UI watches for, OR they trigger a
// foreground state mutation which still passes this guard.
//
// Why a DOM probe instead of a "lastRenderedTab" state? Because
// we want the FIRST render after a tab switch INTO Settings to
// fire (DOM has no .settings-actions yet → guard fails → full
// render), but subsequent background-poll-driven renders to be
// skipped (DOM has .settings-actions → guard passes → return).
if (state.activeTab === "settings" && root.querySelector(".settings-actions")) {
return;
}
if (state.adminEnabled && !state.authed) {
renderLogin();
return;
}
if (state.loading) {
root.innerHTML = '<div class="loading">Loading dashboard…</div>';
return;
}
if (!state.data) {
root.innerHTML = '<div class="loading">No data.</div>';
return;
}
if (state.activeTab === "jobs") {
renderJobsTab();
} else if (state.activeTab === "settings") {
renderSettingsTab();
} else if (state.activeTab === "meetings") {
renderMeetingsTab();
} else if (state.activeTab === "users") {
renderUsersTab();
} else {
renderDashboard();
}
}
function tabsHtml() {
const t = (id, label) =>
'<button class="tab ' + (state.activeTab === id ? "active" : "") + '" onclick="switchTab(\'' + id + '\')">' + label + '</button>';
return '<div class="tabs">' +
t("overview", "Overview") +
t("jobs", "Jobs") +
t("users", "Users") +
t("meetings", "Internal Meetings") +
t("settings", "Settings") +
'</div>';
}
// ── Hardware-queue chip (top-right, fixed-position) ─────────
// Polls /admin/hardware-queue every 3s. Hidden when no job is
// hitting the hardware path (pendingCount === 0). Visible as a
// small badge in the top-right corner when something's queued
// or running — gives the operator at-a-glance visibility into
// queue activity without grepping the relay logs.
function getOrCreateHwQueueChip() {
let chip = document.getElementById("hardware-queue-chip");
if (!chip) {
chip = document.createElement("div");
chip.id = "hardware-queue-chip";
chip.style.cssText =
"position:fixed; top:14px; right:18px; z-index:9998; " +
"padding:6px 12px; border-radius:999px; font-size:11px; " +
"font-weight:600; pointer-events:none; opacity:0; " +
"transition:opacity 0.18s ease; " +
"background:rgba(15,23,42,0.95); border:1px solid rgba(165,180,252,0.4); " +
"color:#a5b4fc; font-family:ui-monospace, Menlo, Consolas, monospace; " +
"letter-spacing:0.02em; display:flex; align-items:center; gap:7px;";
document.body.appendChild(chip);
}
return chip;
}
async function pollHardwareQueue() {
try {
const r = await fetch("/admin/hardware-queue", { cache: "no-store" });
if (!r.ok) return;
const data = await r.json();
const chip = getOrCreateHwQueueChip();
const pending = Number(data.pendingCount) || 0;
if (pending === 0) {
chip.style.opacity = "0";
return;
}
const waiting = Math.max(0, pending - 1);
const activeShort = data.currentJobId
? data.currentJobId.slice(0, 8)
: "(none)";
let text;
if (waiting === 0) {
text = `<span style="width:7px;height:7px;border-radius:50%;background:#fbbf24;animation:pulse 1.2s ease-in-out infinite;display:inline-block;"></span>`
+ `Hardware: ${activeShort}`;
} else {
text = `<span style="width:7px;height:7px;border-radius:50%;background:#fbbf24;animation:pulse 1.2s ease-in-out infinite;display:inline-block;"></span>`
+ `Hardware: ${activeShort} · ${waiting} waiting`;
}
chip.innerHTML = text;
chip.style.opacity = "1";
chip.title =
`Active: ${data.currentJobId || "(none)"}\n` +
`Total in queue: ${pending}\n` +
`Waiting: ${waiting}\n\n` +
`Hardware path is FIFO — Gemini path bypasses this queue.`;
} catch {
// Network glitch; leave the chip in its previous state
}
}
// Kick off polling once at boot; runs forever in the background.
// 3s interval is gentle enough to be free; aligned with how
// often the queue state actually changes.
setInterval(pollHardwareQueue, 3000);
// Also do an initial poll right away so the chip appears within
// a second of the dashboard mounting if there's already a job.
setTimeout(pollHardwareQueue, 200);
function switchTab(tab) {
state.activeTab = tab;
// Remember which tab the operator was on so a hard refresh
// lands them back where they were instead of bouncing to
// Overview. Read at boot in the initial state object.
try { localStorage.setItem("recap-relay-active-tab", tab); } catch {}
render();
// Lazy-load jobs data the first time the user hits the tab.
// Re-load on EVERY tab entry now (not just first) so the
// historical row for a job that completed while the operator
// was on another tab shows up without a manual page refresh.
// Cost: one /admin/jobs-history fetch per tab entry — fine
// since the operator pays this same cost if they manually
// refreshed.
if (tab === "jobs") {
loadJobs();
}
if (tab === "meetings") {
loadMeetingsList();
}
if (tab === "users") {
loadCredits();
}
// Overview tab: re-fetch the dashboard data on EVERY entry (so
// the summaries / errors / perf tables show current state, not
// whatever was last cached) AND start the 10-second auto-refresh
// poll so the operator sees fresh data without manual reloads.
if (tab === "overview") {
loadDashboard({ silent: true });
startDashboardAutoRefreshPoll();
}
// Start in-flight job discovery polling on Jobs tab. Discovery
// ALSO runs continuously in the background after auth (see the
// boot path) so a job submitted while the operator was on
// Overview/Settings is already captured in state.activeJobs by
// the time they click into Jobs. Calling start() here is
// idempotent — the handle-already-exists guard short-circuits.
if (tab === "jobs") {
startInFlightDiscoveryPoll();
}
// If boot landed on Jobs tab via localStorage AND a batch is
// active in localStorage, resume its progress tracking.
// (Same logic as the tab-switch case below.)
// If a benchmark suite was started in a previous browser
// session and may still be running server-side, resume the
// progress poll automatically so the operator sees rows land.
if (tab === "jobs") {
tryResumeActiveBatch();
}
// Lazy-load settings data the first time the user hits the tab.
if (tab === "settings" && !state.settingsData) {
loadSettings();
}
}
// ── Settings tab ────────────────────────────────────────────────
// Edits chunking / concurrency knobs that drive both real-user
// traffic AND test-run benchmarks. Settings are read at job-start;
// in-flight phases keep the values they picked up when they
// started. GET /admin/settings returns current values + allowed
// ranges; PUT /admin/settings validates + writes them to the
// live-reloaded relay-config.json.
async function loadSettings() {
state.settingsLoading = true;
render();
try {
const r = await fetch("/admin/settings");
if (!r.ok) throw new Error("HTTP " + r.status);
state.settingsData = await r.json();
} catch (e) {
state.settingsData = { error: e.message };
}
state.settingsLoading = false;
render();
}
// ────────────────────────────────────────────────────────────────
// Internal Meetings tab (Path 2A — Phase 1)
// ────────────────────────────────────────────────────────────────
//
// Three sub-views, switched via state.meetingsView:
// "list" — upload card + past-meetings list (default)
// "live" — live job in progress, SSE-streamed events
// "detail" — full saved meeting (topics + transcripts + dl btns)
//
// State shape on the main `state` object:
// meetingsView: "list" | "live" | "detail"
// meetingsList: array of { id, title, ... } | null
// meetingsLiveJobId: active job id when streaming a new upload
// meetingsLiveEvents: accumulated SSE events for the live view
// meetingsLiveStatus: human-readable status string
// meetingsDetail: full saved record { id, title, chunks, ... }
// meetingsDetailLoading: bool
// meetingsUploadBusy: bool — disables the submit button mid-POST
async function loadMeetingsList() {
try {
const r = await fetch("/admin/internal-meetings");
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
state.meetingsList = Array.isArray(data.meetings) ? data.meetings : [];
} catch (err) {
state.meetingsList = [];
console.warn("loadMeetingsList failed:", err);
}
if (state.activeTab === "meetings") render();
}
async function loadMeetingDetail(id) {
state.meetingsDetailLoading = true;
state.meetingsView = "detail";
render();
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(id));
if (!r.ok) throw new Error("HTTP " + r.status);
state.meetingsDetail = await r.json();
} catch (err) {
state.meetingsDetail = { error: err.message };
}
state.meetingsDetailLoading = false;
render();
}
async function deleteMeeting(id) {
if (!confirm("Delete this meeting permanently? The transcript + analysis JSON will be removed from disk.")) {
return;
}
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(id), {
method: "DELETE",
});
if (!r.ok) throw new Error("HTTP " + r.status);
} catch (err) {
alert("Delete failed: " + (err?.message || err));
return;
}
state.meetingsView = "list";
state.meetingsDetail = null;
await loadMeetingsList();
}
function backToMeetingsList() {
state.meetingsView = "list";
state.meetingsDetail = null;
state.meetingsLiveJobId = null;
state.meetingsLiveEvents = [];
state.meetingsLiveStatus = "";
render();
// Refresh the list in case a job that just finished should
// appear in it.
loadMeetingsList();
}
async function submitMeetingUpload(formEl) {
if (state.meetingsUploadBusy) return;
const file = formEl.querySelector('input[name="file"]').files[0];
if (!file) {
alert("Pick an audio file first.");
return;
}
const title = (formEl.querySelector('input[name="title"]').value || "").trim();
const participants = (formEl.querySelector('input[name="participants"]').value || "").trim();
const notes = (formEl.querySelector('textarea[name="notes"]')?.value || "").trim();
const fd = new FormData();
fd.append("file", file);
fd.append("title", title);
fd.append("participants", participants);
fd.append("notes", notes);
state.meetingsUploadBusy = true;
render();
let data;
try {
const r = await fetch("/admin/internal-meetings/upload", {
method: "POST",
body: fd,
});
if (!r.ok) {
const errBody = await r.text();
throw new Error("HTTP " + r.status + ": " + errBody.slice(0, 200));
}
data = await r.json();
} catch (err) {
alert("Upload failed: " + (err?.message || err));
state.meetingsUploadBusy = false;
render();
return;
}
// Switch to live view + subscribe to SSE
state.meetingsLiveJobId = data.job_id;
state.meetingsLiveEvents = [];
state.meetingsLiveStatus = "queued…";
state.meetingsView = "live";
state.meetingsUploadBusy = false;
render();
subscribeMeetingJob(data.job_id);
}
function subscribeMeetingJob(jobId) {
const es = new EventSource(
"/admin/internal-meetings/jobs/" + encodeURIComponent(jobId) + "/stream"
);
const onEvent = (type) => (e) => {
let payload = {};
try { payload = JSON.parse(e.data || "{}"); } catch {}
state.meetingsLiveEvents = state.meetingsLiveEvents.concat([{ type, payload, ts: Date.now() }]);
if (type === "queued" && payload.position) {
state.meetingsLiveStatus = "queued — " + payload.position + " job(s) ahead";
} else if (type === "progress" && payload.message) {
state.meetingsLiveStatus = payload.message;
} else if (type === "transcribe_complete") {
state.meetingsLiveStatus = "transcribe done — analyzing topics…";
} else if (type === "window_complete") {
const idx = payload.windowIdx != null ? payload.windowIdx + 1 : "?";
const total = payload.totalWindows || "?";
state.meetingsLiveStatus = "analyze window " + idx + "/" + total + " done";
} else if (type === "done") {
state.meetingsLiveStatus = "complete";
es.close();
// Auto-switch to detail view of the just-finished meeting
if (state.meetingsLiveJobId) {
loadMeetingDetail(state.meetingsLiveJobId);
}
return;
} else if (type === "error") {
state.meetingsLiveStatus = "failed: " + (payload.error || "unknown");
es.close();
}
if (state.activeTab === "meetings" && state.meetingsView === "live") {
render();
}
};
// Subscribe to each event type we care about
["queued", "progress", "transcribe_complete", "window_complete", "done", "error"].forEach((t) => {
es.addEventListener(t, onEvent(t));
});
es.onerror = () => {
// Will reconnect automatically; just note the disconnect.
};
}
function fmtMeetingTimestamp(secs) {
const s = Math.max(0, Math.floor(secs || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n) => n.toString().padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
function meetingsSpeakerDisplayName(id, names) {
if (!id) return "—";
if (id === "Speaker_Unknown") return "Unknown";
const inferred = names && typeof names[id] === "string" && names[id].trim();
if (inferred) return inferred;
const m = String(id).match(/^Speaker_([A-Z]+)$/);
return m ? "Speaker " + m[1] : id;
}
function meetingsSpeakerChipLabel(id, names) {
if (id === "Speaker_Unknown") return "?";
const inferred = names && typeof names[id] === "string" && names[id].trim();
if (inferred) {
const parts = inferred.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const m = String(id).match(/^Speaker_([A-Z]+)$/);
return m ? m[1] : "?";
}
function meetingsSpeakerChipColor(id) {
// Mirror the Recap-side chip palette (8 cycling colors). Same
// mapping logic so the same global speaker has the same color
// whether displayed in Recap or here.
if (id === "Speaker_Unknown") return { bg: "rgba(100,116,139,0.18)", fg: "#cbd5e1", bd: "rgba(100,116,139,0.35)" };
const m = String(id || "").match(/^Speaker_([A-Z]+)$/);
const letters = m ? m[1] : "A";
let n = 0;
for (const c of letters) n = n * 26 + (c.charCodeAt(0) - 64);
n -= 1;
const palette = [
{ bg: "rgba(239,68,68,0.18)", fg: "#fca5a5", bd: "rgba(239,68,68,0.35)" },
{ bg: "rgba(59,130,246,0.18)", fg: "#93c5fd", bd: "rgba(59,130,246,0.35)" },
{ bg: "rgba(34,197,94,0.18)", fg: "#86efac", bd: "rgba(34,197,94,0.35)" },
{ bg: "rgba(245,158,11,0.18)", fg: "#fcd34d", bd: "rgba(245,158,11,0.35)" },
{ bg: "rgba(168,85,247,0.18)", fg: "#d8b4fe", bd: "rgba(168,85,247,0.35)" },
{ bg: "rgba(14,165,233,0.18)", fg: "#7dd3fc", bd: "rgba(14,165,233,0.35)" },
{ bg: "rgba(236,72,153,0.18)", fg: "#f9a8d4", bd: "rgba(236,72,153,0.35)" },
{ bg: "rgba(100,116,139,0.18)",fg: "#cbd5e1", bd: "rgba(100,116,139,0.35)" },
];
return palette[n % 8];
}
// Click-to-rename for speaker chips in the meeting detail
// legend. Swaps the name span for an inline text input. Enter
// saves via PATCH; Escape cancels. Saved names propagate to
// chip labels (initials), the legend, and all download formats
// (.md / .html / .json) on next load.
async function beginRenameMeetingSpeaker(el) {
if (!el || el.querySelector("input")) return;
const speakerId = el.getAttribute("data-speaker-id");
const original = el.getAttribute("data-original-name") || "";
const meetingId = state.meetingsDetail && state.meetingsDetail.id;
if (!speakerId || !meetingId) return;
const input = document.createElement("input");
input.type = "text";
input.value = original;
input.maxLength = 60;
input.style.cssText =
"padding:1px 6px; font-size:11px; border:1px solid var(--accent); " +
"background:rgba(255,255,255,0.05); color:var(--fg); border-radius:4px; " +
"width:" + Math.max(80, original.length * 8 + 20) + "px; outline:none;";
const restore = (txt) => {
el.textContent = txt;
el.setAttribute("data-original-name", txt);
};
const commit = async () => {
const next = input.value.trim();
if (next === original) { restore(original); return; }
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(meetingId) + "/speakers", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ speaker_names: { [speakerId]: next } }),
});
if (!r.ok) {
const body = await r.text().catch(() => "");
console.warn("speaker rename failed:", r.status, body);
restore(original);
return;
}
// Refresh the in-memory meeting record so the chips on
// every transcript line, the legend, and the title bar
// all reflect the new name without a full reload.
await loadMeetingDetail(meetingId);
} catch (err) {
console.warn("speaker rename error:", err);
restore(original);
}
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") { ev.preventDefault(); input.blur(); }
else if (ev.key === "Escape") { ev.preventDefault(); restore(original); }
});
input.addEventListener("blur", commit);
el.textContent = "";
el.appendChild(input);
input.focus();
input.select();
}
window.beginRenameMeetingSpeaker = beginRenameMeetingSpeaker;
// Speaker-legend toolbar: show/hide the merge + re-run panels.
function toggleMeetingPanel(panelId) {
const el = document.getElementById(panelId);
if (el) el.style.display = el.style.display === "none" ? "block" : "none";
}
window.toggleMeetingPanel = toggleMeetingPanel;
// Merge one cluster into another (ONE person diarized as two).
// PATCHes /:id/merge-speakers, then reloads the detail view so
// chips, legend, extras, and downloads all reflect the merge.
async function submitMergeMeetingSpeakers() {
const meetingId = state.meetingsDetail && state.meetingsDetail.id;
if (!meetingId) return;
const fromEl = document.getElementById("mtg-merge-from");
const intoEl = document.getElementById("mtg-merge-into");
if (!fromEl || !intoEl) return;
const absorbed = fromEl.value;
const survivor = intoEl.value;
if (!absorbed || !survivor) return;
if (absorbed === survivor) { alert("Pick two different speakers to merge."); return; }
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(meetingId) + "/merge-speakers", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ survivor, absorbed: [absorbed] }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
alert("Merge failed: " + (body.error || ("HTTP " + r.status)));
return;
}
await loadMeetingDetail(meetingId);
} catch (err) {
alert("Merge error: " + err.message);
}
}
window.submitMergeMeetingSpeakers = submitMergeMeetingSpeakers;
// Re-run cross-chunk clustering at a new strictness (TWO people
// diarized as one). POSTs /:id/recluster; the server re-stamps every
// line and clears the now-stale names/overrides/extras tags. We warn
// before firing since it discards manual corrections.
async function submitReclusterMeeting() {
const meetingId = state.meetingsDetail && state.meetingsDetail.id;
if (!meetingId) return;
const tEl = document.getElementById("mtg-recluster-threshold");
const threshold = tEl ? Number(tEl.value) : 70;
if (!Number.isFinite(threshold) || threshold < 50 || threshold > 95) {
alert("Strictness must be a number between 50 and 95.");
return;
}
if (!confirm(
"Re-run speaker detection at " + threshold + "% strictness?\n\n" +
"This re-detects speakers from the audio fingerprints and CLEARS the current " +
"names and per-line corrections for this meeting. You'll re-label afterward."
)) return;
const optNum = (id) => {
const el = document.getElementById(id);
if (!el || el.value === "") return undefined;
const n = Number(el.value);
return Number.isFinite(n) ? n : undefined;
};
const payload = { threshold };
const anchor = optNum("mtg-recluster-anchor");
const small = optNum("mtg-recluster-small");
const margin = optNum("mtg-recluster-margin");
if (anchor !== undefined) payload.anchorMinSpeakingSec = anchor;
if (small !== undefined) payload.smallClusterMaxSpeakingSec = small;
if (margin !== undefined) payload.uncertainMarginPct = margin;
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(meetingId) + "/recluster", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) {
alert("Re-run failed: " + (body.error || ("HTTP " + r.status)));
return;
}
await loadMeetingDetail(meetingId);
const n = body.speakers
? Object.keys(body.speakers).filter((k) => k !== "Speaker_Unknown").length
: 0;
if (n) alert("Re-detected " + n + " speaker(s). Rename them in the legend.");
} catch (err) {
alert("Re-run error: " + err.message);
}
}
window.submitReclusterMeeting = submitReclusterMeeting;
// Re-run the Phase-2 summary polish with the CURRENT speaker names,
// so topic summaries get re-attributed after a legend rename / merge.
// It's an LLM pass on the operator's analyze hardware, so it can take
// a moment — disable the button + show progress while it runs.
async function submitRepolishMeeting(btn) {
const meetingId = state.meetingsDetail && state.meetingsDetail.id;
if (!meetingId) return;
if (!confirm(
"Re-write the topic summaries using the current speaker names?\n\n" +
"This runs your analyze hardware (one pass per analysis window) and may take " +
"a moment on long meetings. Transcript lines and per-line corrections are left as-is."
)) return;
const label = btn ? btn.textContent : "";
if (btn) { btn.disabled = true; btn.textContent = "Re-polishing… (may take a minute)"; }
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(meetingId) + "/repolish", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
});
const body = await r.json().catch(() => ({}));
if (!r.ok) {
alert("Re-polish failed: " + (body.error || ("HTTP " + r.status)));
if (btn) { btn.disabled = false; btn.textContent = label; }
return;
}
await loadMeetingDetail(meetingId);
alert("Re-polished " + (body.polished_count != null ? body.polished_count : 0) + " topic summaries.");
} catch (err) {
alert("Re-polish error: " + err.message);
if (btn) { btn.disabled = false; btn.textContent = label; }
}
}
window.submitRepolishMeeting = submitRepolishMeeting;
// Renders a speaker chip. When `ctx` is provided (dashboard
// detail view, where the operator can correct attributions), the
// chip is a clickable button that opens the speaker picker for
// that specific line. When `ctx` is omitted (read-only contexts),
// a plain inline span. Overridden chips render with a dashed
// border so the operator can spot edits at a glance.
function renderMeetingChip(speaker, confidence, uncertain, names, ctx) {
const isOverridden = ctx && ctx.isOverridden;
if (!speaker) {
// No-speaker chip: editable in dashboard context, blank otherwise.
if (ctx) {
return '<button type="button" onclick="event.stopPropagation(); showMeetingSpeakerPicker(this, ' + ctx.chunkIdx + ', ' + ctx.entryIdx + ');" ' +
'style="display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:18px; padding:0 6px; ' +
'font-size:10px; font-weight:700; border-radius:4px; flex-shrink:0; line-height:1; cursor:pointer; ' +
'font-family:ui-monospace,Menlo,Consolas,monospace; background:transparent; ' +
'color:var(--fg-faint); border:1px dashed rgba(148,163,184,0.4);" ' +
'title="Click to assign a speaker">?</button>';
}
return "";
}
const c = meetingsSpeakerChipColor(speaker);
const display = meetingsSpeakerChipLabel(speaker, names);
const showQ = uncertain || (typeof confidence === "number" && confidence < 0.5);
const suffix = showQ ? "?" : "";
const fullName = meetingsSpeakerDisplayName(speaker, names);
const tooltip = speaker === "Speaker_Unknown"
? "Unknown speaker — click to assign"
: isOverridden
? fullName + " (operator-corrected · click to change)"
: (uncertain ? fullName + " — best-guess attribution · click to correct" : fullName + " · click to correct");
const borderStyle = isOverridden ? "dashed" : "solid";
const commonStyle = 'display:inline-flex; align-items:center; justify-content:center; ' +
'min-width:26px; height:18px; padding:0 6px; font-size:10px; font-weight:700; ' +
'border-radius:4px; flex-shrink:0; letter-spacing:0.02em; line-height:1; ' +
'font-family:ui-monospace,Menlo,Consolas,monospace; ' +
'background:' + c.bg + '; color:' + c.fg + '; border:1px ' + borderStyle + ' ' + c.bd + ';';
if (ctx) {
return '<button type="button" onclick="event.stopPropagation(); showMeetingSpeakerPicker(this, ' + ctx.chunkIdx + ', ' + ctx.entryIdx + ');" ' +
'style="' + commonStyle + ' cursor:pointer;" ' +
'title="' + esc(tooltip) + '">' + esc(display + suffix) + '</button>';
}
return '<span style="' + commonStyle + '" ' +
'title="' + esc(tooltip) + '">' + esc(display + suffix) + '</span>';
}
// Per-line speaker re-assignment picker. Opens a small popover
// listing every speaker in the meeting (with their colored chip
// + name + total speaking time) plus a "Clear override" option
// and a "? Unknown" option. Click outside or press Escape to
// dismiss without changing.
let meetingPickerCleanup = null;
function closeMeetingSpeakerPicker() {
const ex = document.getElementById("meeting-speaker-picker");
if (ex) ex.remove();
if (meetingPickerCleanup) {
meetingPickerCleanup();
meetingPickerCleanup = null;
}
}
function showMeetingSpeakerPicker(anchorEl, chunkIdx, entryIdx) {
closeMeetingSpeakerPicker();
const rec = state.meetingsDetail;
if (!rec) return;
const names = rec.speaker_names || {};
const speakerIds = Object.keys(rec.speakers || {}).filter((k) => /^Speaker_[A-Z]+$/.test(k)).sort();
if (!speakerIds.includes("Speaker_Unknown") && rec.speakers && rec.speakers["Speaker_Unknown"]) {
speakerIds.push("Speaker_Unknown");
}
// Always offer Speaker_Unknown as a target even if it wasn't
// produced by clustering — operator may want to mark a line as
// unknown.
if (!speakerIds.includes("Speaker_Unknown")) speakerIds.push("Speaker_Unknown");
const chunk = rec.chunks?.[chunkIdx];
const entry = chunk?.entries?.[entryIdx];
const currentEffective = entry?.speaker_override || entry?.speaker || "";
const popover = document.createElement("div");
popover.id = "meeting-speaker-picker";
popover.style.cssText =
"position:absolute; z-index:9999; background:var(--panel); border:1px solid var(--line); " +
"border-radius:8px; padding:6px; box-shadow:0 8px 24px rgba(0,0,0,0.5); " +
"min-width:180px; max-height:300px; overflow-y:auto;";
const header = document.createElement("div");
header.style.cssText = "padding:4px 8px 6px; font-size:10px; color:var(--fg-faint); text-transform:uppercase; letter-spacing:0.06em; border-bottom:1px solid var(--line); margin-bottom:4px;";
header.textContent = "Reassign this line to…";
popover.appendChild(header);
const makeRow = (sid, label, isClear) => {
const row = document.createElement("button");
row.type = "button";
const isCurrent = !isClear && sid === currentEffective;
row.style.cssText =
"display:flex; align-items:center; gap:8px; width:100%; padding:6px 8px; " +
"background:" + (isCurrent ? "rgba(96,165,250,0.1)" : "transparent") + "; " +
"border:none; border-radius:4px; cursor:pointer; text-align:left; " +
"color:var(--fg); font-size:11px;";
row.onmouseover = () => { if (!isCurrent) row.style.background = "rgba(255,255,255,0.04)"; };
row.onmouseout = () => { row.style.background = isCurrent ? "rgba(96,165,250,0.1)" : "transparent"; };
if (!isClear) {
const c = meetingsSpeakerChipColor(sid);
const chipText = meetingsSpeakerChipLabel(sid, names);
const chipSpan = document.createElement("span");
chipSpan.style.cssText =
"display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:18px; " +
"padding:0 6px; font-size:10px; font-weight:700; border-radius:4px; line-height:1; " +
"font-family:ui-monospace,Menlo,Consolas,monospace; background:" + c.bg + "; color:" + c.fg + "; border:1px solid " + c.bd + ";";
chipSpan.textContent = chipText;
row.appendChild(chipSpan);
} else {
const ico = document.createElement("span");
ico.style.cssText = "display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:18px; font-size:11px; color:var(--fg-faint);";
ico.textContent = "↺";
row.appendChild(ico);
}
const labelSpan = document.createElement("span");
labelSpan.style.cssText = "flex:1;";
labelSpan.textContent = label;
row.appendChild(labelSpan);
if (isCurrent) {
const tag = document.createElement("span");
tag.style.cssText = "font-size:9px; color:var(--accent); text-transform:uppercase;";
tag.textContent = "current";
row.appendChild(tag);
}
row.onclick = (ev) => {
ev.stopPropagation();
saveMeetingEntryOverride(chunkIdx, entryIdx, isClear ? "" : sid);
};
return row;
};
for (const sid of speakerIds) {
const display = sid === "Speaker_Unknown" ? "Unknown" : meetingsSpeakerDisplayName(sid, names);
popover.appendChild(makeRow(sid, display, false));
}
if (entry && entry.speaker_override) {
const sep = document.createElement("div");
sep.style.cssText = "border-top:1px solid var(--line); margin:4px 0;";
popover.appendChild(sep);
popover.appendChild(makeRow("", "Clear override (revert to auto)", true));
}
document.body.appendChild(popover);
const rect = anchorEl.getBoundingClientRect();
const left = Math.min(rect.left + window.scrollX, window.innerWidth - 200);
popover.style.left = Math.max(8, left) + "px";
popover.style.top = (rect.bottom + window.scrollY + 4) + "px";
const onDocClick = (ev) => {
if (!popover.contains(ev.target)) closeMeetingSpeakerPicker();
};
const onKey = (ev) => {
if (ev.key === "Escape") { ev.preventDefault(); closeMeetingSpeakerPicker(); }
};
setTimeout(() => {
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
}, 0);
meetingPickerCleanup = () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
};
}
async function saveMeetingEntryOverride(chunkIdx, entryIdx, speakerId) {
closeMeetingSpeakerPicker();
const rec = state.meetingsDetail;
if (!rec) return;
try {
const r = await fetch("/admin/internal-meetings/" + encodeURIComponent(rec.id) + "/entries", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
overrides: [{ chunk_idx: chunkIdx, entry_idx: entryIdx, speaker_id: speakerId }],
}),
});
if (!r.ok) {
const body = await r.text().catch(() => "");
console.warn("entry override failed:", r.status, body);
return;
}
await loadMeetingDetail(rec.id);
} catch (err) {
console.warn("entry override error:", err);
}
}
window.showMeetingSpeakerPicker = showMeetingSpeakerPicker;
window.saveMeetingEntryOverride = saveMeetingEntryOverride;
function renderMeetingsTab() {
const view = state.meetingsView || "list";
let body;
if (view === "live") {
body = renderMeetingsLiveView();
} else if (view === "detail") {
body = renderMeetingsDetailView();
} else {
body = renderMeetingsListView();
}
root.innerHTML =
'<h1>Recap Relay — Operator Dashboard</h1>' +
tabsHtml() +
'<div style="max-width:1000px; padding:12px 0;">' +
body +
'</div>';
}
function renderMeetingsListView() {
const list = state.meetingsList || [];
const busy = !!state.meetingsUploadBusy;
const uploadCard =
'<div style="background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:18px 20px; margin-bottom:20px;">' +
'<div style="font-size:13px; font-weight:600; color:var(--fg); margin-bottom:6px;">Upload an internal meeting</div>' +
'<div style="font-size:11px; color:var(--fg-dim); line-height:1.5; margin-bottom:14px;">' +
'Audio file (mp3, m4a, wav — up to 500 MB) runs through the same hardware pipeline as YouTube/podcast summaries: transcribe → diarize → cluster speakers → analyze topics → polish with speaker attribution. ' +
'Audio is deleted from disk after processing. Results saved at <code>/data/internal-meetings/&lt;id&gt;.json</code>; downloadable as JSON or Markdown.' +
'</div>' +
'<form onsubmit="event.preventDefault(); submitMeetingUpload(this);" style="display:flex; flex-direction:column; gap:10px;">' +
'<div style="display:flex; align-items:center; gap:10px;">' +
'<label style="font-size:11px; color:var(--fg-dim); width:100px;">Audio file</label>' +
'<input type="file" name="file" accept="audio/*,.mp3,.m4a,.wav,.ogg,.opus,.flac" required ' +
'style="flex:1; font-size:12px; color:var(--fg);" />' +
'</div>' +
'<div style="display:flex; align-items:center; gap:10px;">' +
'<label style="font-size:11px; color:var(--fg-dim); width:100px;">Title</label>' +
'<input type="text" name="title" placeholder="e.g. Q3 planning sync — 2026-05-20" maxlength="200" ' +
'style="flex:1; padding:6px 10px; font-size:12px; background:var(--bg); border:1px solid var(--line-2); border-radius:5px; color:var(--fg);" />' +
'</div>' +
'<div style="display:flex; align-items:center; gap:10px;">' +
'<label style="font-size:11px; color:var(--fg-dim); width:100px;">Participants</label>' +
'<input type="text" name="participants" placeholder="optional, comma-separated — hints only, the LLM verifies against the transcript" ' +
'style="flex:1; padding:6px 10px; font-size:12px; background:var(--bg); border:1px solid var(--line-2); border-radius:5px; color:var(--fg);" />' +
'</div>' +
'<div style="display:flex; align-items:flex-start; gap:10px;">' +
'<label style="font-size:11px; color:var(--fg-dim); width:100px; padding-top:6px;">Notes</label>' +
'<div style="flex:1; display:flex; flex-direction:column; gap:4px;">' +
'<textarea name="notes" rows="4" maxlength="4000" placeholder="optional — LLM hints only, NOT saved with the meeting. Example: &quot;Steve gave a business update; John followed up with questions; Hank chimed in toward the end.&quot;" ' +
'style="padding:6px 10px; font-size:12px; background:var(--bg); border:1px solid var(--line-2); border-radius:5px; color:var(--fg); font-family:inherit; line-height:1.5; resize:vertical;"></textarea>' +
'<div style="font-size:10.5px; color:var(--fg-faint); line-height:1.4;">Not persisted — sent to the LLM as hints at pipeline time then dropped. Doesn\'t appear in the meeting record or downloads.</div>' +
'</div>' +
'</div>' +
'<div style="display:flex; justify-content:flex-end; padding-top:6px;">' +
'<button type="submit" class="tr-btn tr-btn-primary" ' + (busy ? "disabled" : "") + '>' +
(busy ? "Uploading…" : "Upload &amp; analyze") +
'</button>' +
'</div>' +
'</form>' +
'</div>';
let listHtml = "";
if (list.length === 0) {
listHtml = '<div style="padding:24px 14px; text-align:center; color:var(--fg-faint); font-size:12px;">No internal meetings yet. Upload one above to get started.</div>';
} else {
listHtml = '<div style="display:flex; flex-direction:column; gap:8px;">' +
list.map((m) => {
const dateStr = m.created_at
? new Date(m.created_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })
: "(unknown date)";
return '<div style="background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px 16px; cursor:pointer; transition:border-color 0.15s;" ' +
'onclick="loadMeetingDetail(\'' + esc(m.id) + '\')" ' +
'onmouseover="this.style.borderColor=\'var(--accent)\'" ' +
'onmouseout="this.style.borderColor=\'var(--line)\'">' +
'<div style="font-size:13px; font-weight:600; color:var(--fg);">' + esc(m.title || "(untitled)") + '</div>' +
'<div style="display:flex; gap:14px; font-size:11px; color:var(--fg-dim); margin-top:4px;">' +
'<span>' + dateStr + '</span>' +
'<span>· ' + fmtMeetingTimestamp(m.audio_seconds) + '</span>' +
'<span>· ' + (m.topic_count || 0) + ' topics</span>' +
'<span>· ' + (m.speaker_count || 0) + ' speakers</span>' +
'</div>' +
'</div>';
}).join("") +
'</div>';
}
return uploadCard +
'<div style="font-size:10px; font-weight:600; color:var(--fg-faint); text-transform:uppercase; letter-spacing:0.06em; margin:24px 0 10px;">Past meetings</div>' +
listHtml;
}
function renderMeetingsLiveView() {
const events = state.meetingsLiveEvents || [];
const status = state.meetingsLiveStatus || "starting…";
const jobIdShort = (state.meetingsLiveJobId || "").slice(0, 8);
const eventList = events.slice(-20).map((e) => {
const ts = new Date(e.ts).toLocaleTimeString("en-US", { hour12: false });
return '<div style="font-size:10.5px; color:var(--fg-dim); font-family:ui-monospace,Menlo,Consolas,monospace;">' +
'<span style="color:var(--fg-faint);">' + ts + '</span> ' +
'<span style="color:var(--accent); font-weight:600;">' + esc(e.type) + '</span> ' +
esc(JSON.stringify(e.payload).slice(0, 120)) +
'</div>';
}).join("");
return '<div style="background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:18px 20px;">' +
'<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">' +
'<span style="width:10px; height:10px; border-radius:50%; background:#fbbf24; animation:pulse 1.2s ease-in-out infinite;"></span>' +
'<div style="font-size:13px; font-weight:600; color:var(--fg);">Processing meeting (' + esc(jobIdShort) + ')</div>' +
'</div>' +
'<div style="font-size:12px; color:var(--fg-dim); margin-bottom:14px;">' + esc(status) + '</div>' +
'<div style="border-top:1px dashed var(--line-2); padding-top:10px; max-height:300px; overflow-y:auto;">' +
(eventList || '<div style="font-size:11px; color:var(--fg-faint);">Waiting for first event…</div>') +
'</div>' +
'<div style="display:flex; justify-content:flex-end; padding-top:14px;">' +
'<button class="tr-btn" onclick="backToMeetingsList()">Back to list</button>' +
'</div>' +
'</div>';
}
function renderMeetingsDetailView() {
if (state.meetingsDetailLoading) {
return '<div class="loading">Loading meeting…</div>';
}
const rec = state.meetingsDetail;
if (!rec) {
return '<div class="empty">Meeting not found.</div>';
}
if (rec.error) {
return '<div class="empty">Failed to load: ' + esc(rec.error) + '</div>';
}
const names = rec.speaker_names || {};
const speakerEntries = rec.speakers ? Object.entries(rec.speakers).sort((a, b) => {
if (a[0] === "Speaker_Unknown") return 1;
if (b[0] === "Speaker_Unknown") return -1;
return a[0].localeCompare(b[0]);
}) : [];
// Speaker legend with click-to-rename. Each chip with a known
// cluster ID becomes editable on click — the name span swaps to
// a text input, Enter saves via PATCH /admin/internal-meetings/
// :id/speakers, Escape cancels. Speaker_Unknown is read-only
// (it's a bucket, not a real person).
const totalSpeakingSec = speakerEntries.reduce((n, [, s]) => n + (s.total_speaking_seconds || 0), 0);
const durationSec = rec.audio_seconds || 0;
const speakingPctLabel = durationSec > 0
? ' · ' + Math.round((totalSpeakingSec / durationSec) * 100) + '% speech detected'
: '';
// Post-hoc speaker-edit controls (merge + re-run). Merge needs ≥2
// real speakers; re-run needs saved voice fingerprints in
// rec.diarization (absent when diarization was off at process time).
const namedSpeakerIds = speakerEntries
.map(([id]) => id)
.filter((id) => id !== "Speaker_Unknown");
const canMerge = namedSpeakerIds.length >= 2;
const mergeOptions = namedSpeakerIds
.map((id) => '<option value="' + esc(id) + '">' +
esc(meetingsSpeakerChipLabel(id, names)) + ' · ' + esc(meetingsSpeakerDisplayName(id, names)) +
'</option>')
.join("");
const diar = Array.isArray(rec.diarization) ? rec.diarization : [];
const fpCount = diar.reduce(
(n, d) => n + (d && d.ok ? Object.keys(d.fingerprints || {}).length : 0), 0);
const canRecluster = fpCount > 0;
const reclusterDefault = (rec.meta && rec.meta.recluster_threshold) || 70;
// Re-polish is offered once at least one speaker is named and there
// are topic summaries to rewrite — it re-attributes summaries to the
// current names via an LLM pass on the analyze hardware.
const hasNamedSpeakers = Object.values(names || {}).some((v) => typeof v === "string" && v.trim());
const hasTopics = Array.isArray(rec.chunks) && rec.chunks.length > 0;
const canRepolish = hasNamedSpeakers && hasTopics;
const sInput = 'background:rgba(255,255,255,0.05); color:var(--fg); border:1px solid var(--line); border-radius:4px; padding:3px 6px; font-size:11px;';
const mergePanelHtml =
'<div id="mtg-merge-panel" style="display:none; margin-top:10px; padding:10px 12px; background:rgba(15,23,42,0.6); border:1px solid var(--line); border-radius:6px;">' +
'<div style="font-size:11px; color:var(--fg-dim); margin-bottom:8px; line-height:1.5;">One person split into two chips? Merge them. The <b>first</b> speaker is folded into the <b>second</b>, which keeps its name. (To separate two people merged into one, use Re-run detection instead.)</div>' +
'<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">' +
'<span style="font-size:11px; color:var(--fg-faint);">Merge</span>' +
'<select id="mtg-merge-from" style="' + sInput + '">' + mergeOptions + '</select>' +
'<span style="font-size:11px; color:var(--fg-faint);">into</span>' +
'<select id="mtg-merge-into" style="' + sInput + '">' + mergeOptions + '</select>' +
'<button class="tr-btn" style="font-size:11px;" onclick="submitMergeMeetingSpeakers()">Merge</button>' +
'</div>' +
'</div>';
const reclusterPanelHtml =
'<div id="mtg-recluster-panel" style="display:none; margin-top:10px; padding:10px 12px; background:rgba(15,23,42,0.6); border:1px solid var(--line); border-radius:6px;">' +
(canRecluster
? '<div style="font-size:11px; color:var(--fg-dim); margin-bottom:8px; line-height:1.5;">Re-detect speakers from the saved voice fingerprints. Higher % = stricter = splits similar-sounding voices apart (raise it when two people were merged into one). <b style="color:#fcd34d;">Clears the current names &amp; per-line corrections</b> — you re-label afterward.</div>' +
'<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">' +
'<label style="font-size:11px; color:var(--fg-faint);">Voice-match strictness %</label>' +
'<input id="mtg-recluster-threshold" type="number" min="50" max="95" value="' + reclusterDefault + '" style="width:64px; ' + sInput + '">' +
'<button class="tr-btn" style="font-size:11px;" onclick="submitReclusterMeeting()">Re-run</button>' +
'</div>' +
'<details style="margin-top:8px;">' +
'<summary style="font-size:10px; color:var(--fg-faint); cursor:pointer;">Advanced (suppression knobs)</summary>' +
'<div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:6px; font-size:10px; color:var(--fg-faint);">' +
'<label>Anchor min sec<br><input id="mtg-recluster-anchor" type="number" min="5" max="120" placeholder="30" style="width:60px; margin-top:2px; ' + sInput + '"></label>' +
'<label>Small-cluster max sec<br><input id="mtg-recluster-small" type="number" min="1" max="60" placeholder="15" style="width:60px; margin-top:2px; ' + sInput + '"></label>' +
'<label>Uncertain margin %<br><input id="mtg-recluster-margin" type="number" min="0" max="30" placeholder="10" style="width:60px; margin-top:2px; ' + sInput + '"></label>' +
'</div>' +
'</details>'
: '<div style="font-size:11px; color:var(--fg-faint); line-height:1.5;">Re-run isn\'t available for this meeting — no saved voice fingerprints (diarization was off when it was processed, or it predates fingerprint capture).</div>') +
'</div>';
const speakersLegend = speakerEntries.length ? (
'<div style="background:rgba(15,23,42,0.5); border:1px solid var(--line); border-radius:8px; padding:10px 14px; margin-bottom:14px;">' +
'<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:8px;">' +
'<div style="font-size:10px; font-weight:600; color:var(--fg-faint); text-transform:uppercase; letter-spacing:0.06em;">Speakers</div>' +
'<div style="font-size:10px; color:var(--fg-faint);">click a name to rename' + esc(speakingPctLabel) + '</div>' +
'</div>' +
'<div style="display:flex; flex-wrap:wrap; gap:8px;">' +
speakerEntries.map(([id, stats]) => {
const c = meetingsSpeakerChipColor(id);
const display = meetingsSpeakerChipLabel(id, names);
const fullName = id === "Speaker_Unknown" ? "Unknown" : meetingsSpeakerDisplayName(id, names);
const secs = Math.round(stats.total_speaking_seconds || 0);
const isEditable = id !== "Speaker_Unknown";
const nameAttrs = isEditable
? ` data-speaker-id="${esc(id)}" data-original-name="${esc(fullName)}" onclick="beginRenameMeetingSpeaker(this)" style="cursor:pointer; border-bottom:1px dashed rgba(255,255,255,0.15);" title="Click to rename"`
: '';
return '<span style="display:inline-flex; align-items:center; gap:6px; padding:3px 10px; background:rgba(255,255,255,0.03); border:1px solid var(--line); border-radius:16px; font-size:11px;">' +
'<span style="display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:18px; padding:0 6px; font-size:10px; font-weight:700; border-radius:4px; font-family:ui-monospace,Menlo,Consolas,monospace; background:' + c.bg + '; color:' + c.fg + '; border:1px solid ' + c.bd + ';">' + esc(display) + '</span>' +
'<span' + nameAttrs + '>' + esc(fullName) + '</span>' +
'<span style="color:var(--fg-faint);">· ' + fmtMeetingTimestamp(secs) + '</span>' +
'</span>';
}).join("") +
'</div>' +
'<div style="display:flex; gap:8px; margin-top:10px; flex-wrap:wrap;">' +
(canMerge ? '<button class="tr-btn" style="font-size:10px; padding:3px 8px;" onclick="toggleMeetingPanel(\'mtg-merge-panel\')">Merge speakers…</button>' : '') +
'<button class="tr-btn" style="font-size:10px; padding:3px 8px;" onclick="toggleMeetingPanel(\'mtg-recluster-panel\')">Re-run detection…</button>' +
(canRepolish ? '<button class="tr-btn" style="font-size:10px; padding:3px 8px;" title="Rewrite the topic summaries to use the current speaker names" onclick="submitRepolishMeeting(this)">Re-polish summaries</button>' : '') +
'</div>' +
(canMerge ? mergePanelHtml : '') +
reclusterPanelHtml +
'</div>'
) : "";
const chunks = Array.isArray(rec.chunks) ? rec.chunks : [];
const chunksHtml = chunks.map((chunk, ci) => {
const i = ci;
const start = fmtMeetingTimestamp(chunk.startTime || 0);
// Display end = next chunk's startTime so consecutive topics
// appear visually adjacent (matches Recaps' YouTube/podcast
// render). The last chunk extends to the full audio duration
// when known. Falls back to last entry's offset if neither
// is available.
let endSec;
if (i + 1 < chunks.length) {
endSec = chunks[i + 1].startTime || 0;
} else if (rec.audio_seconds) {
endSec = rec.audio_seconds;
} else {
const endEntry = chunk.entries && chunk.entries[chunk.entries.length - 1];
endSec = endEntry ? (endEntry.offset || 0) : (chunk.startTime || 0);
}
const end = fmtMeetingTimestamp(endSec);
const lines = (chunk.entries || []).map((entry, ei) => {
const t = fmtMeetingTimestamp(entry.offset || 0);
// Use the operator-edited speaker if set; otherwise the
// original diarization-assigned speaker. The chip is
// clickable in the dashboard so the operator can correct
// mis-attributions — the popover lists every speaker in
// this meeting plus a "clear override" option.
const effective = entry.speaker_override || entry.speaker;
const chipCtx = {
chunkIdx: ci,
entryIdx: ei,
isOverridden: !!entry.speaker_override,
};
const chip = renderMeetingChip(effective, entry.speaker_confidence, entry.speaker_uncertain, names, chipCtx);
return '<div data-meeting-entry="' + ci + '-' + ei + '" style="display:flex; gap:10px; align-items:flex-start; padding:4px 8px; font-size:12px; line-height:1.55; color:var(--fg); transition:background 0.4s ease;">' +
'<span style="font-size:11px; color:var(--accent); min-width:54px; padding-top:2px; font-family:ui-monospace,Menlo,Consolas,monospace;">' + t + '</span>' +
chip +
'<span style="flex:1;">' + esc(entry.text || "") + '</span>' +
'</div>';
}).join("");
return '<details style="background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:0; overflow:hidden;">' +
'<summary style="padding:12px 16px; cursor:pointer; list-style:none;">' +
'<div style="display:flex; align-items:baseline; gap:10px;">' +
'<span style="font-size:13px; font-weight:600; color:var(--fg);">' + (i + 1) + '. ' + esc(chunk.title || "(untitled)") + '</span>' +
'<span style="font-size:10.5px; color:var(--fg-faint); font-family:ui-monospace,Menlo,Consolas,monospace;">' + start + ' — ' + end + '</span>' +
'</div>' +
'<div style="font-size:12px; color:var(--fg-dim); line-height:1.55; margin-top:6px;">' + esc(chunk.summary || "") + '</div>' +
'</summary>' +
'<div style="border-top:1px solid var(--line); padding:8px 8px 12px 8px; background:rgba(15,23,42,0.3);">' +
(lines || '<div style="padding:10px 14px; font-size:11px; color:var(--fg-faint);">No transcript entries for this topic.</div>') +
'</div>' +
'</details>';
}).join("");
const titleBar =
'<div style="display:flex; align-items:flex-start; gap:14px; margin-bottom:14px;">' +
'<div style="flex:1;">' +
'<div style="font-size:18px; font-weight:600; color:var(--fg);">' + esc(rec.title || "(untitled)") + '</div>' +
'<div style="font-size:11px; color:var(--fg-dim); margin-top:4px;">' +
fmtMeetingTimestamp(rec.audio_seconds) + ' · ' + chunks.length + ' topics · ' + speakerEntries.length + ' speakers' +
(rec.created_at ? ' · ' + new Date(rec.created_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "") +
'</div>' +
'</div>' +
'<div style="display:flex; flex-direction:column; gap:6px; flex-shrink:0;">' +
'<a href="/admin/internal-meetings/' + esc(rec.id) + '/html" download class="tr-btn" style="text-decoration:none; text-align:center;">Download .html</a>' +
'<a href="/admin/internal-meetings/' + esc(rec.id) + '/markdown" download class="tr-btn" style="text-decoration:none; text-align:center;">Download .md</a>' +
'<a href="/admin/internal-meetings/' + esc(rec.id) + '/download" download class="tr-btn" style="text-decoration:none; text-align:center;">Download .json</a>' +
'<button class="tr-btn" style="color:#f87171; border-color:rgba(248,113,113,0.4);" onclick="deleteMeeting(\'' + esc(rec.id) + '\')">Delete</button>' +
'</div>' +
'</div>';
return '<div style="margin-bottom:14px;">' +
'<button class="tr-btn" onclick="backToMeetingsList()">← Back to meetings</button>' +
'</div>' +
titleBar +
speakersLegend +
renderMeetingExtras(rec) +
'<div style="display:flex; flex-direction:column; gap:10px;">' +
(chunksHtml || '<div class="empty">No topic data — analyze may have failed.</div>') +
'</div>';
}
// Phase 2 — renders the extras block (decisions / action items /
// open questions / key quotes) above the topic list. Each item
// shows its speaker chip(s) + a clickable [m:ss] timestamp that
// scrolls the page to the relevant transcript line (opens the
// containing topic's details panel first so the line is visible).
// Empty categories collapse — if all four are empty the whole
// block hides.
function renderMeetingExtras(rec) {
const x = rec && rec.extras;
if (!x) return "";
const tldr = x.tldr && typeof x.tldr === "object" ? x.tldr : null;
const decs = Array.isArray(x.decisions) ? x.decisions : [];
const acts = Array.isArray(x.action_items) ? x.action_items : [];
const qs = Array.isArray(x.open_questions) ? x.open_questions : [];
const quotes = Array.isArray(x.key_quotes) ? x.key_quotes : [];
if (!tldr && !decs.length && !acts.length && !qs.length && !quotes.length) return "";
const names = rec.speaker_names || {};
const tsLink = (sec) => {
if (sec == null || !Number.isFinite(sec)) return "";
const label = fmtMeetingTimestamp(sec);
return '<button type="button" onclick="event.stopPropagation(); jumpToMeetingOffset(' + Math.floor(sec) + ');" ' +
'style="background:none; border:1px solid var(--line); color:var(--accent); padding:1px 6px; ' +
'border-radius:4px; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10.5px; cursor:pointer;" ' +
'title="Jump to this point in the transcript">' + label + '</button>';
};
const speakerInlineChip = (sid) => {
if (!sid) return "";
const c = meetingsSpeakerChipColor(sid);
const label = meetingsSpeakerChipLabel(sid, names);
const full = sid === "Speaker_Unknown" ? "Unknown" : meetingsSpeakerDisplayName(sid, names);
return '<span style="display:inline-flex; align-items:center; gap:4px;">' +
'<span style="display:inline-flex; align-items:center; justify-content:center; min-width:22px; height:16px; padding:0 5px; ' +
'font-size:9px; font-weight:700; border-radius:3px; line-height:1; font-family:ui-monospace,Menlo,Consolas,monospace; ' +
'background:' + c.bg + '; color:' + c.fg + '; border:1px solid ' + c.bd + ';">' + esc(label) + '</span>' +
'<span style="font-size:11px; color:var(--fg-dim);">' + esc(full) + '</span>' +
'</span>';
};
const sectionBlock = (label, items, renderItem, emoji) => {
if (!items.length) return "";
return '<details style="background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:0; overflow:hidden;" open>' +
'<summary style="padding:10px 14px; cursor:pointer; list-style:none; display:flex; align-items:baseline; gap:8px;">' +
'<span style="font-size:13px; font-weight:600; color:var(--fg);">' + emoji + ' ' + esc(label) + '</span>' +
'<span style="font-size:11px; color:var(--fg-faint);">· ' + items.length + '</span>' +
'</summary>' +
'<div style="border-top:1px solid var(--line); padding:8px 14px 12px; background:rgba(15,23,42,0.3); display:flex; flex-direction:column; gap:10px;">' +
items.map(renderItem).join("") +
'</div>' +
'</details>';
};
const renderDecision = (d) => {
const agreed = (d.agreed_by || []).map(speakerInlineChip).join('<span style="color:var(--fg-faint);"> · </span>');
return '<div style="font-size:12px; line-height:1.55; color:var(--fg);">' +
'<div>' + esc(d.statement || "") + '</div>' +
'<div style="margin-top:4px; display:flex; flex-wrap:wrap; gap:8px; align-items:center; font-size:11px; color:var(--fg-faint);">' +
(d.supporting_offset != null ? tsLink(d.supporting_offset) : "") +
(agreed ? '<span>agreed by:</span>' + agreed : "") +
'</div>' +
'</div>';
};
const renderAction = (a) => {
return '<div style="font-size:12px; line-height:1.55; color:var(--fg);">' +
'<div>' + esc(a.description || "") + '</div>' +
'<div style="margin-top:4px; display:flex; flex-wrap:wrap; gap:8px; align-items:center; font-size:11px; color:var(--fg-faint);">' +
(a.supporting_offset != null ? tsLink(a.supporting_offset) : "") +
(a.owner ? '<span>owner:</span>' + speakerInlineChip(a.owner) : "") +
(a.due_hint ? '<span style="color:var(--fg-dim);">due: ' + esc(a.due_hint) + '</span>' : "") +
'</div>' +
'</div>';
};
const renderQuestion = (q) => {
return '<div style="font-size:12px; line-height:1.55; color:var(--fg);">' +
'<div>' + esc(q.question || "") + '</div>' +
(q.raised_by ? '<div style="margin-top:4px; display:flex; gap:8px; align-items:center; font-size:11px; color:var(--fg-faint);"><span>raised by:</span>' + speakerInlineChip(q.raised_by) + '</div>' : "") +
'</div>';
};
const renderQuote = (q) => {
return '<div style="font-size:12px; line-height:1.55; color:var(--fg);">' +
'<div style="font-style:italic; border-left:3px solid var(--line); padding-left:10px;">"' + esc(q.quote || "") + '"</div>' +
'<div style="margin-top:4px; display:flex; flex-wrap:wrap; gap:8px; align-items:center; font-size:11px; color:var(--fg-faint);">' +
(q.offset != null ? tsLink(q.offset) : "") +
(q.speaker ? speakerInlineChip(q.speaker) : "") +
(q.why_notable ? '<span style="color:var(--fg-dim);">— ' + esc(q.why_notable) + '</span>' : "") +
'</div>' +
'</div>';
};
// TLDR renders ABOVE the four collapsibles — it's meant to be
// the first thing the reader sees, not collapsed away. Styled
// as a highlighted callout block with the executive summary
// prose + a row of primary-speaker chips below.
let tldrHtml = "";
if (tldr && typeof tldr.summary === "string" && tldr.summary.trim()) {
const primary = Array.isArray(tldr.primary_speakers) ? tldr.primary_speakers : [];
const primaryChips = primary.length
? '<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:8px; align-items:center; font-size:11px; color:var(--fg-faint);">' +
'<span>primary speakers:</span>' +
primary.map(speakerInlineChip).join('<span style="color:var(--fg-faint);"> · </span>') +
'</div>'
: "";
tldrHtml = '<div style="background:linear-gradient(135deg, rgba(96,165,250,0.08), rgba(15,23,42,0.5)); ' +
'border:1px solid var(--line); border-left:3px solid var(--accent); ' +
'border-radius:8px; padding:12px 16px; margin-bottom:8px;">' +
'<div style="font-size:10px; font-weight:600; color:var(--accent); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:6px;">TL;DR</div>' +
'<div style="font-size:13px; line-height:1.6; color:var(--fg);">' + esc(tldr.summary) + '</div>' +
primaryChips +
'</div>';
}
return '<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">' +
tldrHtml +
sectionBlock("Decisions", decs, renderDecision, "✓") +
sectionBlock("Action items", acts, renderAction, "→") +
sectionBlock("Open questions", qs, renderQuestion, "?") +
sectionBlock("Key quotes", quotes, renderQuote, "❝") +
'</div>';
}
// Jump from an extras timestamp to the matching transcript line.
// Finds the topic-card <details> containing the target offset,
// opens it if closed, then scrolls the matching entry row into
// view with a brief highlight flash. Stamped data-* attributes
// on each entry div let us look them up without re-rendering.
function jumpToMeetingOffset(targetSec) {
const rec = state.meetingsDetail;
if (!rec) return;
const chunks = Array.isArray(rec.chunks) ? rec.chunks : [];
if (!chunks.length) return;
let chunkIdx = 0;
for (let i = 0; i < chunks.length; i++) {
if ((chunks[i].startTime || 0) <= targetSec) chunkIdx = i;
else break;
}
// Find the entry within that chunk whose offset is closest to target
const chunk = chunks[chunkIdx];
const entries = Array.isArray(chunk.entries) ? chunk.entries : [];
let entryIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < entries.length; i++) {
const d = Math.abs((entries[i].offset || 0) - targetSec);
if (d < bestDist) { bestDist = d; entryIdx = i; }
}
const sel = '[data-meeting-entry="' + chunkIdx + '-' + entryIdx + '"]';
const row = document.querySelector(sel);
if (!row) return;
const det = row.closest("details");
if (det && !det.open) det.open = true;
setTimeout(() => {
row.scrollIntoView({ behavior: "smooth", block: "center" });
const orig = row.style.background;
row.style.background = "rgba(96,165,250,0.18)";
setTimeout(() => { row.style.background = orig; }, 1400);
}, 30);
}
window.jumpToMeetingOffset = jumpToMeetingOffset;
function renderSettingsTab() {
// Use the module-scoped `root` (initialized at boot to the
// container with id="root"). The earlier local-const shadowed
// it with id="dashboard-root" — an element that doesn't exist —
// so root.innerHTML = … threw a silent TypeError and the click
// appeared to do nothing.
//
// On first click, switchTab() runs render() BEFORE loadSettings()
// dispatches the fetch — so state.settingsData is still null
// for one render tick. Show a loading skeleton in that window
// instead of flashing a misleading "Failed to load" message.
if (state.settingsLoading || !state.settingsData) {
root.innerHTML = tabsHtml() + '<div class="loading">Loading settings…</div>';
return;
}
if (state.settingsData.error) {
root.innerHTML = tabsHtml() +
'<div class="empty">Failed to load settings: ' +
esc(state.settingsData.error) + '</div>';
return;
}
const s = state.settingsData.settings || {};
const ranges = state.settingsData.ranges || {};
const defaults = state.settingsData.defaults || {};
const enums = state.settingsData.enums || {};
const stringsMeta = state.settingsData.strings || {};
// ── Helpers ─────────────────────────────────────────────────
// Pill-group: row of selectable buttons. Use shortLabel when
// present (model menus need both the long human-friendly label
// for hover and a tight pill label for the UI).
const pillGroup = (key, label, options, shortLabels) => {
const current = s[key] || "";
const pills = Object.entries(options).map(([v, longLab]) => {
const sh = (shortLabels && shortLabels[v]) || longLab;
return '<button data-setting-key="' + esc(key) + '" data-pill-value="' + esc(v) + '"' +
(v === current ? ' class="active"' : "") +
' onclick="onSettingsPillClick(this)"' +
' title="' + esc(longLab) + '">' + esc(sh) + '</button>';
}).join("");
return '<div class="settings-row">' +
'<label class="row-label">' + esc(label) + '</label>' +
'<div class="row-control"><div class="settings-pills">' + pills + '</div></div>' +
'</div>';
};
// Slider + number-input pair. Number reflects slider live;
// typing in the number snaps the slider. Default-hint shown
// only when current value differs from default.
const sliderRow = (key, label, unit) => {
const cur = s[key];
const def = defaults[key];
const [lo, hi] = ranges[key] || [1, 100];
// Always show the factory-default hint so the operator
// doesn't see asymmetric "default N" labels across rows
// depending on whether they've moved each slider or not.
// The slider value IS the operator's setting at request
// time — this hint is purely informational ("if you ever
// reset, you'd go back to N").
const showDefault = def != null
? '<span class="default-hint">ships at ' + def + '</span>'
: "";
return '<div class="settings-row">' +
'<label class="row-label">' + esc(label) + '</label>' +
'<div class="row-control settings-slider">' +
'<input type="number" data-setting-key="' + esc(key) + '" min="' + lo + '" max="' + hi + '" step="1" value="' + esc(String(cur)) + '" oninput="onSettingsSliderInput(this)" />' +
'<input type="range" data-setting-slave="' + esc(key) + '" min="' + lo + '" max="' + hi + '" step="1" value="' + esc(String(cur)) + '" oninput="onSettingsSliderInput(this)" />' +
'<span class="unit">' + esc(unit) + '</span>' +
showDefault +
'</div>' +
'</div>';
};
// Boolean toggle pill (visual switch).
const toggleRow = (key, label, hint) => {
const cur = !!s[key];
return '<div class="settings-row">' +
'<label class="row-label">' + esc(label) + '</label>' +
'<div class="row-control">' +
'<label class="settings-toggle">' +
'<input type="checkbox" data-setting-key="' + esc(key) + '"' + (cur ? " checked" : "") + ' onchange="onSettingsToggleChange(this)" />' +
'<span class="state-label" data-toggle-label="' + esc(key) + '">' + (cur ? "enabled" : "disabled") + '</span>' +
'</label>' +
(hint ? ' <span class="default-hint" style="margin-left:8px;">' + esc(hint) + '</span>' : "") +
'</div>' +
'</div>';
};
// Short single-line text input — URLs, model names, masked
// secrets. Compact single-line layout: status chip, label,
// input, ? icon (hover = help tooltip). Reads metadata from
// textMeta[key] (set by GET /admin/settings) so the same helper
// handles plain text, URL-pattern fields, and password-style
// masked secrets. For masked entries the placeholder reflects
// the saved state ("(saved — leave blank to keep)" vs the
// example placeholder) and the input value is empty so the
// operator only types when actually changing the secret.
const textMeta = state.settingsData.text || {};
const textRow = (key, label) => {
const meta = textMeta[key] || {};
const cur = s[key] != null ? String(s[key]) : "";
const isMasked = !!meta.masked;
const inputType = isMasked ? "password" : "text";
let placeholder;
if (isMasked) {
placeholder = meta.set
? "(saved — leave blank to keep)"
: (meta.placeholder || "");
} else {
placeholder = meta.placeholder || "";
}
const value = isMasked ? "" : cur;
const help = meta.help || "";
// Status: small dot ●/○ in the label column.
const dot = meta.set
? '<span style="color:#4ade80;" title="configured">●</span>'
: '<span style="color:#64748b;" title="not set">○</span>';
// Help-tooltip icon: ? circle on the right side of the input
// that reveals the full description on hover. Tooltip is
// rendered via a child element so we can style it (instead of
// the bare-bones title= attribute).
const tooltipId = "tt-" + key;
const helpIcon = help
? '<span class="settings-help-anchor" tabindex="0" aria-describedby="' + esc(tooltipId) + '">' +
'?' +
'<span class="settings-help-tooltip" role="tooltip" id="' + esc(tooltipId) + '">' + esc(help) + '</span>' +
'</span>'
: "";
return '<div class="settings-text-row">' +
'<div class="settings-text-label">' + dot + '&nbsp;' + esc(label) + '</div>' +
'<input type="' + inputType + '" data-setting-key="' + esc(key) + '" data-text-setting="true"' +
' value="' + esc(value) + '"' +
' placeholder="' + esc(placeholder) + '"' +
' autocomplete="off" spellcheck="false"' +
' oninput="onTextSettingInput(this)" />' +
helpIcon +
'</div>';
};
// ── Sections ────────────────────────────────────────────────
// Endpoints & credentials — single-line text inputs for the
// operator-configurable URLs + secrets that used to live only
// in StartOS Actions. Same backing store (relay-config.json);
// the actions still work, but most operators will prefer
// editing here without leaving the dashboard. Masked secrets
// never round-trip their value over the wire (see
// /admin/settings handler — masked fields emit a `set: bool`
// flag and the dashboard renders a "leave blank to keep"
// placeholder).
// Discovery health line — shows the result of the last Spark
// Control discovery fetch. Lives directly under the Service
// Discovery URL row so a silently-failing discovery is
// immediately visible.
const discoveryStatus = state.settingsData.discoveryStatus || null;
const discoveryStatusLine = (() => {
if (!discoveryStatus || !discoveryStatus.configured) return "";
if (discoveryStatus.lastError) {
const ageS = Math.max(0, Math.round((Date.now() - discoveryStatus.lastError.at) / 1000));
return '<div class="discovery-status err">' +
'✗ Last fetch failed ' + ageS + 's ago — ' +
esc(discoveryStatus.lastError.message || "unknown error") +
'</div>';
}
if (discoveryStatus.lastFetched > 0 && discoveryStatus.services) {
const ageS = Math.max(0, Math.round((Date.now() - discoveryStatus.lastFetched) / 1000));
const svcSummary = Object.entries(discoveryStatus.services).map(([name, s]) => {
const dot = s.ready ? "●" : "○";
return dot + " " + esc(name) + (s.model ? " (" + esc(String(s.model).slice(0, 40)) + ")" : "");
}).join(" · ");
return '<div class="discovery-status ok">' +
'✓ Last fetched ' + ageS + 's ago — ' + svcSummary +
'</div>';
}
return '<div class="discovery-status dim">…waiting for first fetch (cold boot, or the relay hasn\'t serviced a hardware-routed request yet)</div>';
})();
const endpointsSection = textMeta.relay_gemini_api_key ? (
'<div class="settings-panel" data-panel-key="endpoints" style="margin-bottom:14px;">' +
'<div class="settings-panel-title routing" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>Endpoints &amp; credentials' +
' <span style="font-weight:400; font-size:10px; color:var(--fg-faint); text-transform:none; letter-spacing:0;">— live-reloaded into the next request</span>' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
textRow("relay_gemini_api_key", "Gemini API key") +
textRow("relay_spark_control_url", "Service discovery URL") +
discoveryStatusLine +
textRow("relay_keysat_base_url", "Keysat license server") +
textRow("relay_cloud_operator_key", "Cloud operator key") +
'</div>' +
'</div>'
) : "";
// AI backends & routing — model pickers + routing preference
// pills. Short labels for the model dropdown options.
const modelShortLabels = {
"gemini-3.1-pro-preview": "3.1 Pro",
"gemini-3-flash-preview": "3 Flash",
"gemini-3.1-flash-lite": "3.1 Flash-Lite",
"gemini-2.5-pro": "2.5 Pro",
"gemini-2.5-flash": "2.5 Flash",
};
const routingShortLabels = {
gemini_first: "Gemini→HW",
hardware_first: "HW→Gemini",
gemini_only: "Gemini only",
hardware_only: "HW only",
};
const routingSection = enums.relay_gemini_transcription_model ? (
'<div class="settings-panel" data-panel-key="routing">' +
'<div class="settings-panel-title routing" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>AI backends &amp; routing' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
pillGroup("relay_gemini_transcription_model", "Transcribe model",
enums.relay_gemini_transcription_model.options, modelShortLabels) +
pillGroup("relay_gemini_analysis_model", "Analyze model",
enums.relay_gemini_analysis_model.options, modelShortLabels) +
pillGroup("relay_transcribe_backend_preference", "Transcribe routing",
enums.relay_transcribe_backend_preference.options, routingShortLabels) +
pillGroup("relay_analyze_backend_preference", "Analyze routing",
enums.relay_analyze_backend_preference.options, routingShortLabels) +
'</div>' +
'</div>'
) : "";
// Gemini + Hardware tuning panels, side by side.
const geminiTuning =
'<div class="settings-panel" data-panel-key="gemini-tuning">' +
'<div class="settings-panel-title gemini" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>Gemini backend' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
sliderRow("relay_gemini_tx_chunk_minutes", "TX chunk size", "min") +
sliderRow("relay_gemini_tx_concurrency", "TX concurrency", "parallel") +
sliderRow("relay_gemini_analyze_window_minutes", "AN window body", "min") +
sliderRow("relay_gemini_analyze_overlap_minutes", "AN window overlap", "min") +
sliderRow("relay_gemini_analyze_concurrency", "AN concurrency", "parallel") +
sliderRow("relay_gemini_tx_max_output_tokens", "TX max output tokens", "tokens") +
sliderRow("relay_gemini_an_max_output_tokens", "AN max output tokens", "tokens") +
'</div>' +
'</div>';
// Help text for `Diarization enabled` and `Speaker-aware summary
// polish` toggles is intentionally empty — Grant flagged the
// descriptions as redundant for a single-operator setup.
const hardwareTuning =
'<div class="settings-panel" data-panel-key="hardware-tuning">' +
'<div class="settings-panel-title hardware" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>Operator hardware' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
sliderRow("relay_hardware_tx_chunk_minutes", "TX chunk size", "min") +
sliderRow("relay_hardware_tx_chunk_overlap_seconds", "TX chunk overlap", "sec") +
sliderRow("relay_hardware_tx_concurrency", "TX concurrency", "parallel") +
toggleRow("relay_hardware_diarization_enabled", "Diarization enabled", "") +
sliderRow("relay_hardware_voice_clustering_threshold", "Voice clustering threshold", "% similarity") +
sliderRow("relay_hardware_anchor_min_speaking_sec", "Anchor min speaking time", "sec") +
sliderRow("relay_hardware_small_cluster_max_speaking_sec", "Small-cluster suppress under", "sec") +
sliderRow("relay_hardware_uncertain_margin_pct", "Uncertain reassignment margin", "%") +
toggleRow("relay_post_cluster_polish_enabled", "Speaker-aware summary polish", "") +
toggleRow("relay_meeting_extras_enabled", "Internal-meetings extras (decisions / action items / questions / quotes)",
"Adds ~5-15s LLM call at end of pipeline. Internal-meetings only — YouTube/podcast flow ignores this") +
sliderRow("relay_hardware_analyze_window_minutes", "AN window body", "min") +
sliderRow("relay_hardware_analyze_overlap_minutes", "AN window overlap", "min") +
sliderRow("relay_hardware_analyze_concurrency", "AN concurrency", "parallel") +
sliderRow("relay_hardware_an_max_tokens", "AN max output tokens", "tokens") +
'</div>' +
'</div>';
const tuningGrid = '<div class="settings-grid">' + geminiTuning + hardwareTuning + '</div>';
// Shared knobs + output toggle (small panel, full-width).
const sharedAndOutput =
'<div class="settings-panel" data-panel-key="shared" style="margin-bottom:16px;">' +
'<div class="settings-panel-title shared" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>Shared &amp; output' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
sliderRow("relay_analyze_cutoff_minutes", "Analyze single-shot cutoff", "min") +
toggleRow("relay_save_user_outputs", "Save user-submission outputs",
"privacy default OFF — turn on for testing / debugging") +
'</div>' +
'</div>';
// Per-prompt collapsible block. Each prompt has its own
// chevron + header so the operator can hide prompts they
// aren't editing. Body holds the textarea, the reset/promote
// controls, the "show current default" toggle, and the hint.
const promptRow = (key, label, hintHtml) => {
const defaultText = defaults[key] || "";
const current = s[key] || "";
const displayText = current || defaultText;
return '<div class="prompt-block" data-panel-key="prompt:' + esc(key) + '">' +
'<div class="prompt-block-header" onclick="toggleSettingsPanel(this)">' +
'<span>' + esc(label) + '</span>' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="prompt-block-body">' +
'<div class="settings-prompt">' +
'<textarea data-setting-key="' + esc(key) + '" data-prompt-default="' + esc(defaultText) + '" rows="10">' + esc(displayText) + '</textarea>' +
'<div class="settings-prompt-controls">' +
'<button class="tr-btn tr-btn-primary" onclick="saveOnePrompt(\'' + esc(key) + '\')">Save this prompt</button>' +
'<button class="tr-btn" onclick="resetPromptToDefault(\'' + esc(key) + '\')">Reset to default</button>' +
'<button class="tr-btn" onclick="promotePromptToDefault(\'' + esc(key) + '\')" title="Save the current textarea content as the new operator default. Future Reset clicks return here.">Set as new default</button>' +
'<span data-prompt-status="' + esc(key) + '" class="default-hint">' + (current ? "overriding default" : "using default (no override saved)") + '</span>' +
'<button class="tr-btn" data-prompt-view-toggle="' + esc(key) + '" onclick="togglePromptDefaultView(\'' + esc(key) + '\')" style="margin-left:auto;">Show current default</button>' +
'</div>' +
'<pre class="settings-prompt-default" data-prompt-default-view="' + esc(key) + '">' + esc(defaultText) + '</pre>' +
'<div style="padding:6px 0 0; font-size:11px; color:var(--fg-dim); line-height:1.5;">' + hintHtml + '</div>' +
'</div>' +
'</div>' +
'</div>';
};
const promptsSection = stringsMeta.relay_transcribe_prompt ? (
'<div class="settings-panel" data-panel-key="prompts" style="margin-bottom:16px;">' +
'<div class="settings-panel-title prompts" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>LLM prompts' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
'<div style="font-size:11px; color:var(--fg-dim); line-height:1.5;">' +
'System instructions sent to the models. Leaving a textarea at default is the safe bet — saved as empty so future code-side prompt improvements flow through. Use "Show current default" to compare your edits to the latest built-in. Save-time validation blocks each prompt from dropping its required <code>{{variables}}</code> or the JSON-output instruction.' +
'</div>' +
promptRow("relay_transcribe_prompt", "Transcribe prompt (Gemini only)",
"The metadata block (title / channel / description / chapters) is auto-prepended at request time and is NOT part of this textarea. The operator-hardware transcribe path ignores this override (typically pure STT, no prompt input).") +
promptRow("relay_analyze_prompt", "Analyze prompt (Gemini + operator hardware)",
"Applied to BOTH the Gemini and operator-hardware analyze paths. Keep <code>{{transcript}}</code>, <code>{{windowMin}}</code>, <code>{{targetSections}}</code> in your prompt.") +
(stringsMeta.relay_polish_name_inference_prompt ? promptRow(
"relay_polish_name_inference_prompt",
"Polish prompt — speaker name inference (post-cluster)",
"Stage 1 of the post-cluster polish pass. One LLM call sees the full speaker-labeled transcript + episode metadata and infers real names. Required template variables: <code>{{transcript}}</code> and <code>{{speakerKeys}}</code>. Optional: <code>{{channel}}</code>, <code>{{title}}</code>, <code>{{description}}</code>, <code>{{speakerStats}}</code>. Skipped entirely when fewer than 2 speakers are detected OR when the Speaker-aware polish toggle (in Operator Hardware) is off."
) : "") +
(stringsMeta.relay_polish_summary_rewrite_prompt ? promptRow(
"relay_polish_summary_rewrite_prompt",
"Polish prompt — section summary rewrite (post-cluster)",
"Stage 2 of the post-cluster polish pass. N parallel calls (one per analyze window) rewrite each section's summary to attribute statements to specific speakers. Required template variables: <code>{{sections}}</code> and <code>{{transcript}}</code>. Optional: <code>{{speakerRoster}}</code>. Titles and section indices are never modified — only the summary text."
) : "") +
(stringsMeta.relay_meeting_extras_prompt ? promptRow(
"relay_meeting_extras_prompt",
"Internal-meetings extras prompt (decisions / action items / open questions / key quotes)",
"Path 2A Phase 2 — runs at the end of the internal-meetings pipeline (after analyze + polish). One LLM call extracts structured items: decisions, action items, open questions, key quotes. Required template variable: <code>{{transcript}}</code>. Optional: <code>{{title}}</code>, <code>{{duration}}</code>, <code>{{speakerRoster}}</code>, <code>{{topics}}</code>. Output must be JSON shaped <code>{ decisions: [...], action_items: [...], open_questions: [...], key_quotes: [...] }</code> — each item carries Speaker_X IDs + integer second offsets so the dashboard can render speaker chips and clickable timestamp jumps. Skipped automatically when the Internal-meetings extras toggle (in Operator Hardware) is off. Affects internal meetings only — YouTube/podcast flow ignores this."
) : "") +
'</div>' +
'</div>'
) : "";
// Section-count targets table — 7 rows keyed by VIDEO total
// duration buckets. Each row: operator-set total-sections target
// (integer), plus a computed "sections per window" preview
// (computed live from the AN window body slider, NOT saved —
// shown for transparency so the operator knows what the model
// will receive). The preview recomputes every time the operator
// tweaks the AN window body slider OR a target-total input.
//
// At request time the relay uses these same values to compute
// the per-window target, formats it as a label ("around N
// sections" / "NM sections" / "1 section"), and splices into
// {{targetSections}} in the analyze prompt.
//
// Why per-video-duration rather than per-window-duration: a
// 30-min single-window podcast and a 3-hour 6-window podcast
// need very different segmentation density even when their
// window duration matches. Section count should scale with the
// total video length, not just window length.
const TARGET_ROWS = [
{ key: "relay_analyze_total_sections_under_30", label: "Under 30 min", midSec: 15 * 60 },
{ key: "relay_analyze_total_sections_30_60", label: "30 60 min", midSec: 45 * 60 },
{ key: "relay_analyze_total_sections_60_90", label: "60 90 min", midSec: 75 * 60 },
{ key: "relay_analyze_total_sections_90_120", label: "90 120 min", midSec: 105 * 60 },
{ key: "relay_analyze_total_sections_120_150", label: "120 150 min", midSec: 135 * 60 },
{ key: "relay_analyze_total_sections_150_180", label: "150 180 min", midSec: 165 * 60 },
{ key: "relay_analyze_total_sections_over_180", label: "Over 180 min", midSec: 210 * 60 },
];
const targetRows = TARGET_ROWS.map((row) => {
const def = defaults[row.key];
const current = s[row.key] != null ? s[row.key] : def;
const range = ranges[row.key] || [1, 40];
return (
'<tr data-target-row="' + esc(row.key) + '" data-mid-sec="' + row.midSec + '">' +
'<td style="padding:6px 12px; color:var(--fg); font-weight:500;">' + esc(row.label) + '</td>' +
'<td style="padding:6px 12px;">' +
'<input type="number" min="' + range[0] + '" max="' + range[1] + '" step="1" ' +
'data-setting-key="' + esc(row.key) + '" ' +
'data-target-input ' +
'value="' + esc(String(current)) + '" ' +
'oninput="updateTargetSectionsPreviewRow(this)" ' +
'style="width:64px; padding:3px 6px; font-size:12px; font-variant-numeric:tabular-nums; text-align:right;" />' +
'</td>' +
'<td style="padding:6px 12px; font-variant-numeric:tabular-nums; color:var(--fg-dim);" data-per-window-preview>' +
'—' +
'</td>' +
'<td style="padding:6px 12px; font-size:11px; color:var(--fg-faint);">Default: <code>' + esc(String(def)) + '</code></td>' +
'</tr>'
);
}).join("");
const targetSectionsSection = (
'<div class="settings-panel" data-panel-key="targets" style="margin-bottom:16px;">' +
'<div class="settings-panel-title prompts" onclick="toggleSettingsPanel(this)">' +
'<span class="dot"></span>Section-count targets ' +
'<span style="font-weight:400; font-size:10px; color:var(--fg-faint); text-transform:none; letter-spacing:0;">— interpolated into the analyze prompt as <code>{{targetSections}}</code></span>' +
'<span class="panel-chevron">▾</span>' +
'</div>' +
'<div class="settings-panel-body">' +
'<div style="font-size:11px; color:var(--fg-dim); line-height:1.5; margin-bottom:12px;">' +
'Set the TOTAL sections you want for each video-length bucket. The relay reads the actual video duration + your AN window body setting and divides them to get the per-window target, then splices the result into <code>{{targetSections}}</code>. The "Sections per window" column is a live preview using your current AN window body of ' +
'<span data-window-body-display>' + (s.relay_gemini_analyze_window_minutes || defaults.relay_gemini_analyze_window_minutes || 18) + '</span> min — it updates as you tweak that slider above.' +
'</div>' +
'<table style="width:100%; max-width:780px; border-collapse:collapse; font-size:12px;">' +
'<thead><tr style="color:var(--fg-faint); font-size:10px; text-transform:uppercase; letter-spacing:0.04em; text-align:left;">' +
'<th style="padding:4px 12px; font-weight:600;">Video duration</th>' +
'<th style="padding:4px 12px; font-weight:600;">Target total</th>' +
'<th style="padding:4px 12px; font-weight:600;">Sections / window</th>' +
'<th style="padding:4px 12px; font-weight:600;"></th>' +
'</tr></thead>' +
'<tbody>' + targetRows + '</tbody>' +
'</table>' +
'</div>' +
'</div>'
);
// BTCPay pill — moved from the Overview banner so the Overview
// doesn't dedicate a full-width row to BTCPay status. Settings is
// a more natural home (the operator visits Settings when they
// care about config; the BTCPay link belongs there too). The
// setup-area div is preserved here as the same id that the
// BTCPay setup wizard hooks into — same code path, just relocated.
const btcpayPill = '<div id="btcpay-setup-area" style="margin: 0 0 14px;"></div>';
root.innerHTML =
'<h1>Recap Relay — Operator Dashboard</h1>' +
tabsHtml() +
'<div style="max-width:1000px; padding:12px 0;">' +
btcpayPill +
'<div style="padding:10px 14px; margin-bottom:14px; background: rgba(165,180,252,0.06); border-left:3px solid var(--accent); font-size:11px; color:var(--fg-dim); line-height:1.5; border-radius:4px;">' +
'Live-reloaded — changes apply to the next request. Edits affect new jobs only; in-flight benchmarks keep the values they started with. Settings drive both real-user traffic AND test-run benchmarks; no separate config.' +
'</div>' +
endpointsSection +
(routingSection ? routingSection + '<div style="height:14px;"></div>' : "") +
tuningGrid +
sharedAndOutput +
targetSectionsSection +
promptsSection +
'<div class="settings-actions">' +
'<span id="settings-save-status" style="font-size:11px; color:var(--fg-dim);"></span>' +
'</div>' +
'</div>';
// The BTCPay setup-area renderer normally fires after the
// Overview body lands. Trigger it explicitly here so the pill
// shows up on the Settings tab too. Idempotent — refilling the
// same #btcpay-setup-area with the same content is a no-op.
if (typeof renderBtcpaySetupArea === "function") {
try { renderBtcpaySetupArea(); } catch {}
}
// Populate the "Sections per window" preview cells in the
// section-count targets table with concrete values (each row
// initially renders "—" because the math depends on the AN
// window body input value which we can only read from the DOM
// after it's been laid out). Subsequent updates flow via the
// oninput= hooks on the target-total inputs + the global
// input-event delegation for the AN window slider.
if (typeof refreshAllTargetSectionsPreviews === "function") {
try { refreshAllTargetSectionsPreviews(); } catch {}
}
// Restore persisted collapse state from localStorage — applies
// .collapsed class to any panel whose data-panel-key is in
// the saved set. Runs AFTER innerHTML swap so the
// [data-panel-key] elements exist in the DOM.
applyCollapsedSettingsPanels();
}
// Pill click handler — sets the active class on the clicked
// button and clears it from siblings in the same group. No state
// update yet; the value is read out at save time via the
// data-pill-value attribute.
function onSettingsPillClick(btn) {
const key = btn.getAttribute("data-setting-key");
const value = btn.getAttribute("data-pill-value");
const group = btn.parentElement;
group.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// Auto-save on pill click — no debounce needed, single click.
if (key && value != null) autoSaveField(key, value);
}
// Slider/number input handler — keeps the two synced and clamps
// to the field's min/max. Called on every keystroke or slide.
// Also debounce-auto-saves the new value.
function onSettingsSliderInput(el) {
const key = el.getAttribute("data-setting-key") || el.getAttribute("data-setting-slave");
if (!key) return;
// Find the sibling (number ↔ range) within the same .settings-slider.
const wrap = el.closest(".settings-slider");
if (!wrap) return;
const num = wrap.querySelector('input[type="number"]');
const rng = wrap.querySelector('input[type="range"]');
const min = Number(num.min) || 0;
const max = Number(num.max) || 100;
let v = Number(el.value);
if (!Number.isFinite(v)) return;
if (v < min) v = min;
if (v > max) v = max;
// Sync both inputs.
if (num && num !== el) num.value = String(v);
if (rng && rng !== el) rng.value = String(v);
autoSaveField(key, v);
}
// Toggle switch handler — updates the visual state label
// (enabled / disabled) in place. The actual config update
// happens at saveSettings().
// ── Auto-save for sliders / pills / toggles / text fields ──
// These fields persist on every change instead of waiting for
// a global "Save changes" button. Per-field debounce coalesces
// rapid changes (e.g. dragging a slider) into a single PUT.
// Prompts get their own per-prompt "Save" button (large
// textareas — you don't want every keystroke firing a save).
const AUTO_SAVE_DEBOUNCE_MS = 400;
const _autoSavePending = {};
let _autoSaveTimer = null;
function autoSaveField(key, value) {
_autoSavePending[key] = value;
if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
_autoSaveTimer = setTimeout(flushAutoSave, AUTO_SAVE_DEBOUNCE_MS);
}
async function flushAutoSave() {
_autoSaveTimer = null;
const payload = { ..._autoSavePending };
for (const k of Object.keys(_autoSavePending)) delete _autoSavePending[k];
if (Object.keys(payload).length === 0) return;
setSavedStatus("saving…", "dim");
try {
const r = await fetch("/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await r.json();
if (!r.ok || !body.ok) {
setSavedStatus("save failed: " + (body.errors?.join("; ") || ("HTTP " + r.status)), "err");
return;
}
// Update in-memory state.settingsData so a later re-render
// (e.g. tab switch + back) reflects the confirmed values.
if (state.settingsData) {
state.settingsData = {
...state.settingsData,
settings: { ...state.settingsData.settings, ...body.settings },
};
}
setSavedStatus("✓ saved", "ok");
} catch (err) {
setSavedStatus("save error: " + (err?.message || err), "err");
}
}
let _savedStatusFadeTimer = null;
// Fixed-position toast at the bottom-right of the viewport so
// it's visible no matter where the operator is scrolled in the
// settings tab. Replaces (rather than stacks) on each save to
// keep the surface minimal.
function getOrCreateSaveToast() {
let toast = document.getElementById("settings-save-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "settings-save-toast";
toast.style.cssText =
"position:fixed; bottom:18px; right:18px; z-index:9999; " +
"padding:8px 14px; border-radius:8px; font-size:12px; " +
"font-weight:500; pointer-events:none; opacity:0; " +
"transition:opacity 0.18s ease, transform 0.18s ease; " +
"transform:translateY(6px); box-shadow:0 4px 14px rgba(0,0,0,0.4); " +
"background:rgba(15,23,42,0.95); border:1px solid var(--line-2); " +
"color:var(--fg);";
document.body.appendChild(toast);
}
return toast;
}
function setSavedStatus(text, kind) {
// Also write to the in-page #settings-save-status span (still
// present at the bottom of the settings tab for accessibility
// when scrolled there). Primary surface is the fixed toast.
const inPageEl = document.getElementById("settings-save-status");
if (inPageEl) {
inPageEl.textContent = text;
if (kind === "ok") inPageEl.style.color = "var(--accent)";
else if (kind === "err") inPageEl.style.color = "#f55";
else inPageEl.style.color = "var(--fg-dim)";
}
const toast = getOrCreateSaveToast();
toast.textContent = text;
if (kind === "ok") {
toast.style.color = "#4ade80";
toast.style.borderColor = "rgba(74,222,128,0.4)";
} else if (kind === "err") {
toast.style.color = "#f87171";
toast.style.borderColor = "rgba(248,113,113,0.4)";
} else {
toast.style.color = "var(--fg-dim)";
toast.style.borderColor = "var(--line-2)";
}
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
if (_savedStatusFadeTimer) clearTimeout(_savedStatusFadeTimer);
// OK toasts fade after 2s; "saving…" stays visible until next
// call replaces it; err toasts fade after 5s so the operator
// has time to read them.
const fadeMs = kind === "ok" ? 2000 : kind === "err" ? 5000 : 0;
if (fadeMs > 0) {
_savedStatusFadeTimer = setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(6px)";
if (inPageEl && inPageEl.textContent === text) inPageEl.textContent = "";
}, fadeMs);
}
}
// ── Collapsible settings panels ─────────────────────────────
// State persisted to localStorage so a reload doesn't pop every
// panel back open. The set is keyed by `data-panel-key` on each
// collapsible element (settings-panel OR prompt-block). Toggling
// updates both the DOM class and the persisted set.
const SETTINGS_COLLAPSE_KEY = "recaps:settings:collapsed";
function loadCollapsedPanelKeys() {
try {
const raw = localStorage.getItem(SETTINGS_COLLAPSE_KEY);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr.map(String) : []);
} catch {
return new Set();
}
}
function saveCollapsedPanelKeys(set) {
try {
localStorage.setItem(SETTINGS_COLLAPSE_KEY, JSON.stringify([...set]));
} catch {}
}
function toggleSettingsPanel(triggerEl) {
// Walk up to the nearest collapsible container. Order matters:
// .prompt-block FIRST (the closer ancestor when a prompt header
// is clicked inside the LLM prompts panel) so individual
// prompts collapse independently of the outer panel. The
// earlier order matched .settings-panel first, which caused
// every per-prompt chevron click to fold the whole LLM
// prompts section instead of the targeted prompt.
const container =
triggerEl.closest(".prompt-block[data-panel-key]") ||
triggerEl.closest(".settings-panel[data-panel-key]");
if (!container) return;
const key = container.getAttribute("data-panel-key");
if (!key) return;
const set = loadCollapsedPanelKeys();
const isNowCollapsed = container.classList.toggle("collapsed");
if (isNowCollapsed) set.add(key);
else set.delete(key);
saveCollapsedPanelKeys(set);
}
// Called after renderSettingsTab's innerHTML swap to apply the
// persisted collapse state to every container with a data-
// panel-key attribute. Newly-rendered DOM starts with no
// .collapsed class; this restores it from localStorage.
function applyCollapsedSettingsPanels() {
const set = loadCollapsedPanelKeys();
if (set.size === 0) return;
document.querySelectorAll("[data-panel-key]").forEach((el) => {
if (set.has(el.getAttribute("data-panel-key"))) {
el.classList.add("collapsed");
}
});
}
function onSettingsToggleChange(el) {
const key = el.getAttribute("data-setting-key");
const label = document.querySelector('[data-toggle-label="' + CSS.escape(key) + '"]');
if (label) label.textContent = el.checked ? "enabled" : "disabled";
// Auto-save — toggle changes are discrete clicks, no debounce
// benefit; just push immediately.
if (key) autoSaveField(key, !!el.checked);
}
// Short text-input change handler. Debounce-auto-saves the
// trimmed value. Masked fields (Gemini key) treat an empty
// string as "leave unchanged" on the server side, so partial
// typing during the debounce window can't blow away the saved
// secret.
function onTextSettingInput(el) {
const key = el.getAttribute("data-setting-key");
if (!key) return;
autoSaveField(key, (el.value || "").trim());
}
function resetSettingsToDefaults() {
if (!state.settingsData) return;
const defaults = state.settingsData.defaults || {};
// Numbers + textareas: just write defaults onto the .value
// property. Sliders sync via their oninput handler if the
// user moves them; we also explicitly update both sides of
// each slider pair below.
document.querySelectorAll("[data-setting-key]").forEach((el) => {
const k = el.getAttribute("data-setting-key");
if (defaults[k] === undefined) return;
if (el.hasAttribute("data-pill-value")) return; // handled below
if (el.type === "checkbox") {
el.checked = !!defaults[k];
const label = document.querySelector('[data-toggle-label="' + CSS.escape(k) + '"]');
if (label) label.textContent = el.checked ? "enabled" : "disabled";
} else if (el.tagName === "TEXTAREA") {
el.value = String(defaults[k]);
} else {
el.value = String(defaults[k]);
// Sync the slider companion (if this is the number-input
// side of a slider pair).
const wrap = el.closest(".settings-slider");
if (wrap) {
const rng = wrap.querySelector('input[type="range"]');
if (rng) rng.value = String(defaults[k]);
}
}
});
// Pill groups: clear `.active` from all, then add to the one
// whose data-pill-value matches the default.
const enumKeys = new Set();
document.querySelectorAll("button[data-pill-value]").forEach((b) => {
enumKeys.add(b.getAttribute("data-setting-key"));
});
for (const k of enumKeys) {
if (defaults[k] === undefined) continue;
document.querySelectorAll('button[data-setting-key="' + CSS.escape(k) + '"]').forEach((b) => {
if (b.getAttribute("data-pill-value") === defaults[k]) b.classList.add("active");
else b.classList.remove("active");
});
}
const status = document.getElementById("settings-save-status");
if (status) status.textContent = "Reset to defaults — click Save to apply.";
}
// Reset a prompt textarea to the latest default text and update
// the "overriding default" status pill. The operator still has to
// click Save for the change to persist server-side.
// Save a single prompt's current textarea content. Mirrors the
// old global saveSettings behavior for one prompt only: if the
// textarea matches the current default exactly, save empty
// string (so future code-side default changes flow through);
// otherwise save the verbatim override. Pairs with the per-
// prompt "Save this prompt" button.
async function saveOnePrompt(key) {
const ta = document.querySelector(`textarea[data-setting-key="${CSS.escape(key)}"]`);
if (!ta) return;
const def = (ta.getAttribute("data-prompt-default") || "").trim();
const val = (ta.value || "");
const payload = { [key]: (val.trim() === def) ? "" : val };
const status = document.querySelector(`[data-prompt-status="${CSS.escape(key)}"]`);
if (status) status.textContent = "saving…";
try {
const r = await fetch("/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await r.json();
if (!r.ok || !body.ok) {
if (status) {
status.style.color = "#f55";
status.textContent = "save failed: " + (body.errors?.join("; ") || ("HTTP " + r.status));
}
return;
}
if (state.settingsData) {
state.settingsData = {
...state.settingsData,
settings: { ...state.settingsData.settings, ...body.settings },
};
}
if (status) {
status.style.color = "";
const stored = body.settings[key] || "";
status.textContent = stored
? "✓ saved — overriding default"
: "✓ saved — matches default (no override stored)";
}
setSavedStatus("✓ saved", "ok");
} catch (err) {
if (status) {
status.style.color = "#f55";
status.textContent = "save error: " + (err?.message || err);
}
}
}
function resetPromptToDefault(key) {
const ta = document.querySelector(`textarea[data-setting-key="${CSS.escape(key)}"]`);
if (!ta) return;
const def = ta.getAttribute("data-prompt-default") || "";
ta.value = def;
const status = document.querySelector(`[data-prompt-status="${CSS.escape(key)}"]`);
if (status) status.textContent = "matches default — will save as empty (no override)";
}
// Promote the textarea's current content (the operator's
// override) to a persistent operator-default via POST
// /admin/settings/promote-prompt. After success the override is
// cleared and the default preview pane updates to show the new
// operator-promoted default. The textarea also clears so the
// status pill flips back to "using default (no override saved)".
async function promotePromptToDefault(key) {
const ta = document.querySelector(`textarea[data-setting-key="${CSS.escape(key)}"]`);
if (!ta) return;
const status = document.querySelector(`[data-prompt-status="${CSS.escape(key)}"]`);
const content = ta.value || "";
if (!content.trim()) {
if (status) status.textContent = "nothing to promote — textarea is empty";
return;
}
const ok = confirm(
"Set this textarea content as the new default for " +
key.replace("relay_", "").replace("_", " ") +
"?\n\nThe override will be cleared (since the default already contains this text). Future Reset clicks will return here.",
);
if (!ok) return;
if (status) status.textContent = "promoting…";
try {
const r = await fetch("/admin/settings/promote-prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) {
if (status)
status.textContent =
"promote failed: " + (data.message || data.error || `HTTP ${r.status}`);
return;
}
if (status)
status.textContent =
"✓ promoted (" + (data.promoted?.length || 0) + " chars saved as new default)";
// Refresh settings so the textarea + default preview reflect
// the new state (override empty, default = promoted text).
if (typeof loadSettings === "function") loadSettings();
} catch (err) {
if (status)
status.textContent = "promote failed: " + (err?.message || String(err));
}
}
// Toggle the always-available "Show current default" panel. Lets
// the operator see what the latest hardcoded default is at any
// moment without leaving the page — important because the
// textarea may show their override instead of the default text.
function togglePromptDefaultView(key) {
const view = document.querySelector(`[data-prompt-default-view="${CSS.escape(key)}"]`);
const toggle = document.querySelector(`[data-prompt-view-toggle="${CSS.escape(key)}"]`);
if (!view || !toggle) return;
const isHidden = view.style.display === "none";
view.style.display = isHidden ? "block" : "none";
toggle.textContent = isHidden ? "Hide current default" : "Show current default";
}
// Live preview math for the section-count targets table. Same
// formula the server uses at request time (see
// computePerWindowTarget in server/chunked-analyze.js): the
// per-window section average = target_total * window_sec /
// total_audio_sec. Each row picks a representative `midSec` for
// its duration bucket (e.g. the under_30 row uses 15 min) so the
// preview shows what the prompt label would look like for a
// typical video in that bucket. Rounds to one decimal for the
// preview to surface fractional results without misleading
// precision. The relay's label generator rounds and ranges these
// when emitting to the actual prompt.
function formatPerWindowPreview(target, midSec, windowSec) {
const t = Number(target);
const w = Math.max(60, Number(windowSec) || 60);
if (!Number.isFinite(t) || t <= 0) return "—";
if (!midSec || midSec <= 0) return "—";
// Effective number of windows ≥ 1 (single-shot below the cutoff).
const numWindows = Math.max(1, midSec / w);
const avg = t / numWindows;
// 1-decimal display; if the result is a whole number, drop the
// trailing .0 for cleaner reading.
const rounded = avg.toFixed(1);
const display = rounded.endsWith(".0") ? rounded.slice(0, -2) : rounded;
// Add the label the prompt would emit so the operator sees the
// exact string the model receives.
let label;
if (avg <= 1.2) label = "1 section";
else {
const lo = Math.max(1, Math.floor(avg));
const hi = Math.max(lo, Math.ceil(avg));
label = lo === hi ? "around " + lo + " sections" : lo + "" + hi + " sections";
}
return display + " <span class=\"dim\">→ \"" + label + "\"</span>";
}
function getCurrentAnWindowBodyMinutes() {
const el = document.querySelector('input[data-setting-key="relay_gemini_analyze_window_minutes"]');
if (!el) return 18;
const v = Number(el.value);
return Number.isFinite(v) && v > 0 ? v : 18;
}
function refreshAllTargetSectionsPreviews() {
const windowSec = getCurrentAnWindowBodyMinutes() * 60;
document.querySelectorAll("tr[data-target-row]").forEach((tr) => {
const midSec = Number(tr.getAttribute("data-mid-sec"));
const inputEl = tr.querySelector("input[data-target-input]");
const previewEl = tr.querySelector("[data-per-window-preview]");
if (!inputEl || !previewEl) return;
previewEl.innerHTML = formatPerWindowPreview(inputEl.value, midSec, windowSec);
});
// Also update the "X min" display in the section's intro line.
document.querySelectorAll("[data-window-body-display]").forEach((el) => {
el.textContent = String(getCurrentAnWindowBodyMinutes());
});
}
// Called by oninput= on each target-total <input>. Recomputes ONLY
// the row's own preview cell; cheap, doesn't touch the others.
function updateTargetSectionsPreviewRow(inputEl) {
const tr = inputEl.closest("tr[data-target-row]");
if (!tr) return;
const midSec = Number(tr.getAttribute("data-mid-sec"));
const previewEl = tr.querySelector("[data-per-window-preview]");
if (!previewEl) return;
const windowSec = getCurrentAnWindowBodyMinutes() * 60;
previewEl.innerHTML = formatPerWindowPreview(inputEl.value, midSec, windowSec);
// Auto-save the new target — keystroke-debounced so a number
// input like "12" doesn't fire two PUTs.
const key = inputEl.getAttribute("data-setting-key");
const v = Number(inputEl.value);
if (key && Number.isFinite(v) && Number.isInteger(v)) {
autoSaveField(key, v);
}
}
// Hook the AN window body slider so preview updates as you drag.
// Uses event delegation since the slider is inside the
// settings-panel innerHTML that gets rebuilt on every render.
//
// IMPORTANT: the slider+number-input pair uses TWO attributes:
// - the number input has `data-setting-key`
// - the range slider has `data-setting-slave` (the "shadow"
// control that syncs via onSettingsSliderInput)
// Match both so dragging the range slider OR typing in the
// number input fires our preview refresh. Earlier this listener
// only matched data-setting-key, so drag events on the slider
// never triggered the refresh — that's why Grant\'s table
// values (and the "X min" description text) stayed at the
// pre-drag value of 18 even after he\'d moved the slider to 24.
document.addEventListener("input", (ev) => {
if (!ev?.target) return;
const t = ev.target;
if (!t.getAttribute) return;
const key =
t.getAttribute("data-setting-key") ||
t.getAttribute("data-setting-slave");
if (key === "relay_gemini_analyze_window_minutes") {
refreshAllTargetSectionsPreviews();
}
});
async function saveSettings() {
const payload = {};
// Pill groups: collect the active button's data-pill-value per
// setting-key. Each setting-key may have multiple buttons in
// the DOM (one per option); we want the active one.
document.querySelectorAll("button[data-pill-value].active").forEach((btn) => {
const k = btn.getAttribute("data-setting-key");
payload[k] = btn.getAttribute("data-pill-value");
});
// Number / slider inputs, toggle checkboxes, textareas all have
// data-setting-key on the input element directly. The slider
// shadow uses data-setting-slave so we only count one of the
// pair below.
document.querySelectorAll("[data-setting-key]").forEach((el) => {
// Skip pill buttons (handled above).
if (el.hasAttribute("data-pill-value")) return;
const k = el.getAttribute("data-setting-key");
if (el.hasAttribute("data-text-setting")) {
// Endpoints & credentials panel — short single-line text
// inputs (URLs, model names, masked Gemini key). Send the
// raw trimmed value. For masked inputs an empty value is
// significant: the server treats it as "leave unchanged"
// so the operator never has to re-type a secret when
// editing an adjacent field.
payload[k] = (el.value || "").trim();
} else if (el.type === "checkbox") {
payload[k] = !!el.checked;
} else if (el.tagName === "TEXTAREA") {
// Free-form prompt. If the textarea content matches the
// built-in default exactly, save empty string so future
// default-prompt changes in code flow through. Otherwise
// save the operator override verbatim.
const def = el.getAttribute("data-prompt-default") || "";
const val = el.value || "";
payload[k] = (val.trim() === def.trim()) ? "" : val;
} else if (el.tagName === "INPUT" && el.type === "text") {
// Free-form text input (used for the section-target overrides
// — "1 section" / "1-2 sections" / etc.). If the value
// matches the default exactly, save empty string so future
// default changes in code flow through (same convention as
// the textarea prompts above). Otherwise save the override.
const def = (el.getAttribute("data-prompt-default") || "").trim();
const val = (el.value || "").trim();
payload[k] = (val === def) ? "" : val;
} else {
// Numeric input (the number side of a slider pair).
const v = Number(el.value);
if (Number.isFinite(v)) payload[k] = v;
}
});
const status = document.getElementById("settings-save-status");
if (status) {
status.style.color = "var(--fg-dim)";
status.textContent = "Saving…";
}
try {
const r = await fetch("/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await r.json();
if (!r.ok || !body.ok) {
if (status) {
status.style.color = "#f55";
status.textContent = "Save failed: " + (body.errors?.join("; ") || ("HTTP " + r.status));
}
return;
}
// Refresh in-memory state with the server's confirmed values
// so any subsequent re-render shows them. Preserve the
// metadata blocks (enums / strings / text / discoveryStatus)
// — the PUT response only echoes settings values, not the
// associated UI metadata, so re-using the cached version
// keeps the next renderSettingsTab() from blanking out the
// routing pills, prompts, endpoints panel, and discovery
// health line.
state.settingsData = {
...state.settingsData,
settings: body.settings,
};
// Update the in-place toggle labels (enabled/disabled). The
// numeric inputs reflect their new values automatically via
// their input.value attribute, but the checkbox companion
// <span class="dim">enabled/disabled</span> was rendered with
// the OLD state and won't refresh until a full re-render. We
// don't full-render (would clobber the save-status pill we're
// about to set), so just patch the sibling labels here.
document.querySelectorAll("[data-setting-key]").forEach((el) => {
if (el.type !== "checkbox") return;
const k = el.getAttribute("data-setting-key");
const newVal = !!body.settings[k];
el.checked = newVal;
// Update the state-label span (new settings-toggle).
const lbl = document.querySelector('[data-toggle-label="' + CSS.escape(k) + '"]');
if (lbl) lbl.textContent = newVal ? "enabled" : "disabled";
});
// Prompt textareas: server may have stored empty string
// (default match). Update the status pill below each textarea.
document.querySelectorAll('[data-prompt-status]').forEach((status) => {
const k = status.getAttribute("data-prompt-status");
const stored = body.settings[k] || "";
status.textContent = stored
? "overriding default"
: "using default (no override saved)";
});
if (status) {
status.style.color = "var(--accent)";
status.textContent = "✓ Saved — new values apply to next job.";
}
} catch (e) {
if (status) {
status.style.color = "#f55";
status.textContent = "Save failed: " + e.message;
}
}
}
async function loadJobs() {
state.jobsLoading = true;
render();
try {
const qs = new URLSearchParams();
qs.set("days", state.rangeDays);
qs.set("page", state.jobsPage);
qs.set("page_size", state.jobsPageSize);
qs.set("sort", state.jobsSort.col);
qs.set("dir", state.jobsSort.dir);
for (const [k, v] of Object.entries(state.jobsFilters)) {
if (v) qs.set(k, v);
}
const [r, statsRes] = await Promise.all([
fetch("/admin/jobs-history?" + qs.toString()),
fetch("/admin/output-store-stats"),
]);
if (!r.ok) throw new Error("HTTP " + r.status);
state.jobsData = await r.json();
if (statsRes.ok) {
state.outputStoreStats = await statsRes.json();
}
} catch (e) {
state.jobsData = { error: e.message };
}
state.jobsLoading = false;
render();
}
// Toggle row selection — checkbox click handler. Stored on a
// Set so we don't accidentally count duplicates when the user
// navigates pages.
function toggleJobSelect(jobId) {
const sel = state.jobsSelected || new Set();
if (sel.has(jobId)) sel.delete(jobId);
else sel.add(jobId);
state.jobsSelected = sel;
// Update the small "N selected" counter inline without a full
// re-render (would lose scroll position).
const counter = document.getElementById("jobs-selected-counter");
if (counter) counter.textContent = sel.size;
const btn = document.getElementById("delete-selected-btn");
if (btn) btn.disabled = sel.size === 0;
}
// Delete selected rows. Wipes BOTH stored outputs AND audit log
// rows for those job_ids — single button for a single mental
// model ("this row + everything about it is gone"). Previously
// only deleted the output payload, leaving stale audit rows
// behind.
async function deleteSelectedOutputs() {
const ids = [...(state.jobsSelected || [])];
if (ids.length === 0) return;
if (!confirm("Delete " + ids.length + " row" + (ids.length === 1 ? "" : "s") + " AND their audit log entries? This cannot be undone.")) return;
try {
await fetch("/admin/job-outputs", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_ids: ids, include_audit: true }),
});
state.jobsSelected = new Set();
loadJobs();
} catch (err) {
alert("Delete failed: " + (err?.message || err));
}
}
async function deleteAllOutputs() {
if (!confirm("Delete ALL stored outputs? Audit log rows kept. This cannot be undone.")) return;
try {
await fetch("/admin/job-outputs", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ all: true }),
});
state.jobsSelected = new Set();
loadJobs();
} catch (err) {
alert("Delete failed: " + (err?.message || err));
}
}
// Nuclear: wipes EVERYTHING — stored outputs + every audit log
// entry. Two-step confirm because there's no undo and the audit
// log is the only record of past benchmark runs. Used to get to
// a clean slate before going live with the relay (or after a
// string of test-run cycles producing bad data).
async function wipeEverything() {
if (!confirm("DELETE EVERYTHING — every stored output AND every audit log row?\\n\\nThis is the 'going-live cleanup' button. There is no undo.")) return;
if (!confirm("Are you absolutely sure? Type-confirm step.")) return;
try {
const r = await fetch("/admin/wipe-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const body = await r.json();
state.jobsSelected = new Set();
loadJobs();
alert("Wiped. Outputs deleted: " + (body.outputs_deleted ?? "?") + ". Audit log cleared: " + (body.audit_cleared ? "yes" : "NO — " + (body.audit_error || "unknown error")));
} catch (err) {
alert("Wipe failed: " + (err?.message || err));
}
}
function setJobsFilter(key, value) {
state.jobsFilters[key] = value;
state.jobsPage = 1; // reset to first page on filter change
loadJobs();
}
function clearJobsFilters() {
state.jobsFilters = {
status: "",
transcribe_backend: "",
analyze_backend: "",
model: "",
q: "",
batch_id: "",
source: "",
};
state.jobsPage = 1;
loadJobs();
}
function setJobsSort(col) {
if (state.jobsSort.col === col) {
state.jobsSort.dir = state.jobsSort.dir === "asc" ? "desc" : "asc";
} else {
state.jobsSort.col = col;
state.jobsSort.dir = "desc";
}
loadJobs();
}
function setJobsPage(p) {
state.jobsPage = p;
loadJobs();
}
// ── Column prefs (reorder + hide) ─────────────────────────
// applyColumnPrefs: given the master catalog of available columns,
// return the array in user-preferred order with hidden columns
// filtered out. Keeps "new" columns (added in a future release
// and not present in the saved order) at their catalog position.
function applyColumnPrefs(catalog) {
const order = Array.isArray(state.jobsColumnOrder) ? state.jobsColumnOrder : null;
const hidden = new Set(state.jobsHiddenColumns || []);
const byKey = new Map(catalog.map((c) => [c.key, c]));
const seen = new Set();
const out = [];
if (order) {
for (const key of order) {
if (byKey.has(key) && !seen.has(key)) {
seen.add(key);
out.push(byKey.get(key));
}
}
}
// Append any catalog entries the user hasn't reordered (e.g.
// a newly-added column from a relay update). Keeps the UI stable
// across schema additions.
for (const c of catalog) {
if (!seen.has(c.key)) out.push(c);
}
return out.filter((c) => !hidden.has(c.key));
}
function saveColumnOrder(orderArr) {
state.jobsColumnOrder = orderArr;
savePrefs(PREFS_COL_ORDER_KEY, orderArr);
}
function saveHiddenColumns(arr) {
state.jobsHiddenColumns = arr;
savePrefs(PREFS_HIDDEN_KEY, arr);
}
// Drag-and-drop reorder. We track the dragged column key on
// state, paint a drop indicator on the column being hovered, and
// on drop splice the dragged key into the new position. Persist
// immediately so reorder survives a refresh.
function currentOrderedKeys() {
// Read the order from the DOM. This is what the user is actually
// looking at — applyColumnPrefs walks the saved order first and
// then APPENDS any new catalog keys at the end, so a relay
// update that adds new columns (e.g. download_ms, transcribe_
// ms_sum, analyze_wall_time_ms) gets them rendered even though
// they\'re not in state.jobsColumnOrder yet.
//
// The previous implementation returned state.jobsColumnOrder
// directly, which DID NOT include those appended new columns —
// so dragging one of them produced from=-1 (not found) and the
// splice silently no-op\'d. Reading from DOM ensures
// currentOrderedKeys reflects what the user sees.
const ths = document.querySelectorAll(".jobs-table th[data-col]");
if (ths.length > 0) {
return Array.from(ths).map((th) => th.getAttribute("data-col"));
}
// Fallback: saved order from localStorage (shouldn't hit this
// since the table is rendered before a drag is possible).
const order = state.jobsColumnOrder;
if (Array.isArray(order) && order.length) return [...order];
return [];
}
function onJobsColDragStart(ev, key) {
state.jobsDragCol = key;
ev.dataTransfer.effectAllowed = "move";
// Setting some data is required to make Firefox dispatch drop.
try { ev.dataTransfer.setData("text/plain", key); } catch {}
}
function onJobsColDragOver(ev, key) {
if (!state.jobsDragCol || state.jobsDragCol === key) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
// Visually highlight the drop-target column.
document.querySelectorAll(".jobs-table th.col-drag-over").forEach((el) => el.classList.remove("col-drag-over"));
const th = ev.currentTarget;
if (th && th.classList) th.classList.add("col-drag-over");
}
function onJobsColDrop(ev, targetKey) {
ev.preventDefault();
const dragKey = state.jobsDragCol;
state.jobsDragCol = null;
document.querySelectorAll(".jobs-table th.col-drag-over").forEach((el) => el.classList.remove("col-drag-over"));
if (!dragKey || dragKey === targetKey) return;
const order = currentOrderedKeys();
const from = order.indexOf(dragKey);
const to = order.indexOf(targetKey);
if (from < 0 || to < 0) return;
order.splice(from, 1);
// Drop semantics: the dragged column ends up at the targetKey's
// original position. Splice(from, 1) already shifted target's
// index down by 1 when dragging left→right, so splicing at
// `to` puts the column right after target in that direction;
// splicing at `to` when dragging right→left puts it before.
// Matches typical drag-and-drop conventions.
order.splice(to, 0, dragKey);
saveColumnOrder(order);
render();
}
function onJobsColDragEnd() {
state.jobsDragCol = null;
document.querySelectorAll(".jobs-table th.col-drag-over").forEach((el) => el.classList.remove("col-drag-over"));
// Catch-up render so any polling-triggered state changes that
// were skipped during the drag get applied.
render();
}
// ── Right-click context menu on column headers ────────────
// preventDefault() suppresses the browser's native menu and we
// render our own at the cursor position. Click outside (via
// the transparent overlay) closes it.
function onJobsColContextMenu(ev, key) {
ev.preventDefault();
ev.stopPropagation();
state.jobsContextMenu = { col: key, x: ev.clientX, y: ev.clientY };
renderContextMenu();
}
function closeJobsContextMenu() {
state.jobsContextMenu = null;
state.jobsContextSubmenu = null;
const menu = document.getElementById("jobs-col-context-menu");
if (menu) menu.remove();
const overlay = document.getElementById("jobs-col-context-overlay");
if (overlay) overlay.remove();
}
function ctxSortAsc() {
const col = state.jobsContextMenu?.col;
closeJobsContextMenu();
if (!col) return;
state.jobsSort = { col, dir: "asc" };
loadJobs();
}
function ctxSortDesc() {
const col = state.jobsContextMenu?.col;
closeJobsContextMenu();
if (!col) return;
state.jobsSort = { col, dir: "desc" };
loadJobs();
}
function ctxHideCol() {
const col = state.jobsContextMenu?.col;
closeJobsContextMenu();
if (!col) return;
const hidden = [...(state.jobsHiddenColumns || [])];
if (!hidden.includes(col)) hidden.push(col);
saveHiddenColumns(hidden);
render();
}
function ctxShowAll() {
closeJobsContextMenu();
saveHiddenColumns([]);
render();
}
function ctxResetCols() {
closeJobsContextMenu();
saveColumnOrder(null);
saveHiddenColumns([]);
render();
}
function renderContextMenu() {
// Remove any existing menu before re-rendering.
const existing = document.getElementById("jobs-col-context-menu");
if (existing) existing.remove();
const overlayExisting = document.getElementById("jobs-col-context-overlay");
if (overlayExisting) overlayExisting.remove();
if (!state.jobsContextMenu) return;
const { x, y, col } = state.jobsContextMenu;
const hidden = state.jobsHiddenColumns || [];
const anyHidden = hidden.length > 0;
// Catalog-ordered labels — without rebuilding the full catalog
// here, we'd lose human-readable names in the submenu. The
// catalog mirrors the one in renderJobsBody; kept in sync via
// this small static map. Add new keys here when adding columns.
const labelByKey = {
select: "(Selection)",
view: "(View link)",
details: "(Inspect details)",
started_at: "Date",
title: "Title / URL",
batch_id: "Batch",
audio_seconds: "Duration",
audio_bytes: "Size (MB)",
chunk_count: "TX chunks",
download_ms: "DL time",
download_ms_per_mb: "DL s/MB",
transcribe_ms: "TX wall time",
transcribe_ms_sum: "TX time (sum)",
transcribe_ms_per_min: "TX s/audio-min",
transcribe_ms_per_mb: "TX s/MB",
transcribe_backend: "TX backend",
transcribe_model: "TX model",
analyze_windows_total: "AN windows",
analyze_ms: "AN time (sum)",
analyze_wall_time_ms: "AN wall time",
analyze_ms_per_min: "AN s/audio-min",
analyze_ms_per_mb: "AN s/MB",
analyze_backend: "AN backend",
analyze_model: "AN model",
wall_time_ms: "Wall time",
cost_usd: "Cost",
tier: "Tier",
overall_status: "Status",
errors: "Errors",
};
const overlay = document.createElement("div");
overlay.id = "jobs-col-context-overlay";
overlay.className = "col-context-overlay";
overlay.onclick = closeJobsContextMenu;
document.body.appendChild(overlay);
const menu = document.createElement("div");
menu.id = "jobs-col-context-menu";
menu.className = "col-context-menu";
menu.style.left = Math.min(x, window.innerWidth - 200) + "px";
menu.style.top = Math.min(y, window.innerHeight - 220) + "px";
// Submenu inline render — when state.jobsContextSubmenu is
// "hidden", show the list of hidden columns with click-to-show
// for each. Otherwise show the main menu with the hidden-
// columns option that flips into the submenu.
let inner;
if (state.jobsContextSubmenu === "hidden") {
const hiddenItems = hidden.length === 0
? '<button disabled>No hidden columns</button>'
: hidden.map((key) => {
const label = labelByKey[key] || key;
return '<button onclick="ctxShowOne(\'' + esc(key) + '\')">' + esc(label) + '</button>';
}).join("");
inner =
'<div class="menu-label">Show hidden column</div>' +
'<button onclick="ctxHideSubmenu()" class="back-btn">← Back</button>' +
'<hr />' +
(hidden.length > 1 ? '<button onclick="ctxShowAll()">Show all (' + hidden.length + ')</button><hr />' : '') +
hiddenItems;
} else {
inner =
'<div class="menu-label">' + esc(labelByKey[col] || col) + '</div>' +
'<button onclick="ctxSortAsc()">↑ Sort ascending</button>' +
'<button onclick="ctxSortDesc()">↓ Sort descending</button>' +
'<hr />' +
'<button onclick="ctxHideCol()">Hide column</button>' +
(anyHidden
? '<button onclick="ctxShowHiddenSubmenu()" class="has-submenu">Show hidden columns (' + hidden.length + ') ▸</button>'
: '<button disabled>No hidden columns</button>') +
'<hr />' +
'<button onclick="ctxResetCols()">Reset to default order</button>';
}
menu.innerHTML = inner;
// Stop right-click on the menu itself from closing it.
menu.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); };
document.body.appendChild(menu);
}
// Close any open context menu when the Escape key is pressed.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.jobsContextMenu) closeJobsContextMenu();
});
// ── Per-row errors-cell expand ────────────────────────────
// Flip exactly one row's errors cell between truncated single-
// line and wrapped. Other rows in the table keep their default
// single-line height so the table stays clean.
// Copy the raw (un-escaped) error text from an errors cell to the
// clipboard. The cell stores the original error string on a
// data-err-raw attribute (URI-encoded to survive any embedded
// quotes / newlines) — we decode + ship it via the async clipboard
// API. Brief visual confirmation via a "✓" swap on the button so
// the operator knows the click landed. Fallback for browsers
// without navigator.clipboard.writeText (older Safari, non-https)
// uses the deprecated execCommand("copy") via a hidden textarea.
function copyErrorCell(btnEl, id) {
try {
const td = btnEl.closest("td");
if (!td) return;
const raw = decodeURIComponent(td.getAttribute("data-err-raw") || "");
if (!raw) return;
const onSuccess = () => {
const original = btnEl.textContent;
btnEl.textContent = "✓";
btnEl.classList.add("copied");
setTimeout(() => {
btnEl.textContent = original;
btnEl.classList.remove("copied");
}, 1100);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(raw).then(onSuccess).catch((err) => {
console.warn("clipboard write failed:", err);
// Fall through to textarea fallback.
fallbackCopy(raw, onSuccess);
});
} else {
fallbackCopy(raw, onSuccess);
}
} catch (err) {
console.warn("copyErrorCell error:", err);
}
}
function fallbackCopy(text, onSuccess) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
onSuccess && onSuccess();
} finally {
document.body.removeChild(ta);
}
}
function toggleErrorsCell(id) {
if (!state.jobsExpandedErrors) state.jobsExpandedErrors = {};
state.jobsExpandedErrors[id] = !state.jobsExpandedErrors[id];
// Surgically toggle the class on just the target cell + chevron;
// a full render would also reset scroll position.
const cells = document.querySelectorAll(".jobs-table td.errors-cell-td");
cells.forEach((td) => {
const btn = td.querySelector(".err-expand");
if (!btn) return;
const onclick = btn.getAttribute("onclick") || "";
if (onclick.includes("'" + id + "'")) {
const nowExpanded = !!state.jobsExpandedErrors[id];
td.classList.toggle("expanded", nowExpanded);
btn.textContent = nowExpanded ? "" : "+";
}
});
}
// ── Per-job audit-row drill-down ──────────────────────────────
// Click the 🔍 in the "details" column to fetch every audit row
// keyed to this job_id (transcribe + one row per analyze window)
// and render them as a sub-table beneath the main row. First
// click for a row fetches from GET /admin/job/:id/details and
// caches in state.jobsExpandedDetails. Second click collapses.
// No surgical-toggle here — we re-render() because the detail
// sub-row needs to be inserted/removed in the table DOM, and
// jobs-table render is fast enough that a full pass is fine.
async function toggleJobDetails(jobId) {
if (!jobId) return;
if (!state.jobsExpandedDetails) state.jobsExpandedDetails = {};
if (state.jobsExpandedDetails[jobId]) {
delete state.jobsExpandedDetails[jobId];
render();
return;
}
// Optimistically open with a loading placeholder so the operator
// sees feedback immediately. Replaced with rows when fetch resolves.
state.jobsExpandedDetails[jobId] = { loading: true };
render();
try {
const r = await fetch("/admin/job/" + encodeURIComponent(jobId) + "/details", {
credentials: "same-origin",
});
if (!r.ok) {
let msg = "HTTP " + r.status;
try {
const body = await r.json();
msg = body.error || body.message || msg;
} catch {}
state.jobsExpandedDetails[jobId] = { loading: false, error: msg };
render();
return;
}
const data = await r.json();
state.jobsExpandedDetails[jobId] = {
loading: false,
summary: data.summary || null,
rows: Array.isArray(data.rows) ? data.rows : [],
};
render();
} catch (err) {
state.jobsExpandedDetails[jobId] = {
loading: false,
error: err?.message || String(err),
};
render();
}
}
// Renders the inner HTML of the detail sub-row's TD. Pure function
// of (jobId, cachedState) so each render() call regenerates from
// the cached fetch result without re-hitting the network. Layout:
// a header line with summary stats, then a compact table of audit
// rows showing pipeline / window_idx / status / duration / model /
// error message.
function renderJobDetailsCell(jobId, detail) {
if (detail.loading) {
return '<div class="job-detail-loading">Loading audit rows for ' + esc(jobId.slice(0, 12)) + '…</div>';
}
if (detail.error) {
return '<div class="job-detail-error">Failed to load details: ' + esc(detail.error) + '</div>';
}
const rows = detail.rows || [];
const summary = detail.summary || {};
if (rows.length === 0) {
return '<div class="job-detail-empty">No audit rows for this job (older than retention window, or job_id mismatch).</div>';
}
// Summary line — most useful diagnostic facts at a glance. Each
// value is conditional so it only appears when meaningful (e.g.
// truncated_chunks only shown when non-empty).
const summaryParts = [];
summaryParts.push('TX: <b>' + esc(summary.transcribe_status || "—") + '</b>');
if (summary.transcribe_truncated_chunks && Array.isArray(summary.transcribe_truncated_chunks) && summary.transcribe_truncated_chunks.length > 0) {
summaryParts.push(
'<span class="job-detail-warn">⚠ ' + summary.transcribe_truncated_chunks.length +
' truncated chunk(s) — transcript has time-range hole(s)</span>'
);
}
summaryParts.push(
'AN rows: <b>' + (summary.analyze_rows || 0) + '</b>' +
(summary.analyze_failed > 0
? ' <span class="job-detail-bad">(' + summary.analyze_failed + ' failed)</span>'
: '')
);
// Planned-vs-actual window-count check. If the analyze code
// planned N windows but only M audit rows landed, M-N windows
// never ran to completion (process crashed mid-window before
// recordCall, or similar). Important signal for hypothesis 1
// when the audit log itself doesn't carry an obvious error.
if (
typeof summary.analyze_window_count_planned === "number" &&
summary.analyze_window_count_planned > (summary.analyze_rows || 0)
) {
const missing = summary.analyze_window_count_planned - (summary.analyze_rows || 0);
summaryParts.push(
'<span class="job-detail-bad">⚠ ' + missing +
' window(s) planned but never logged — likely silently dropped</span>'
);
}
const header = '<div class="job-detail-summary">' + summaryParts.join(' &nbsp;·&nbsp; ') + '</div>';
// Per-row table. Keep narrow — this is a diagnostic, not the
// main view. Show pipeline + window index, status pill, duration,
// model, and the error/notes column.
const rowHtmls = rows.map((r) => {
const ts = r.ts ? new Date(r.ts).toLocaleTimeString() : "—";
const pipe = r.pipeline || "?";
const widx = typeof r.window_idx === "number" ? "#" + r.window_idx : "—";
const status = r.status || "—";
const dur = typeof r.duration_ms === "number"
? (r.duration_ms / 1000).toFixed(1) + "s"
: "—";
const model = r.model || "—";
const err = r.error
? esc(String(r.error)).slice(0, 400)
: (r.status === "success" ? "—" : "(no error recorded)");
const statusCls = "status-pill status-" + esc(status === "success" ? "success" : status === "partial" ? "partial" : "failed");
return (
'<tr>' +
'<td class="dim">' + esc(ts) + '</td>' +
'<td>' + esc(pipe) + '</td>' +
'<td>' + esc(widx) + '</td>' +
'<td><span class="' + statusCls + '">' + esc(status) + '</span></td>' +
'<td class="num">' + esc(dur) + '</td>' +
'<td>' + esc(model) + '</td>' +
'<td class="job-detail-err">' + err + '</td>' +
'</tr>'
);
}).join("");
const table =
'<table class="job-detail-table">' +
'<thead><tr>' +
'<th>Time</th><th>Pipeline</th><th>Win idx</th><th>Status</th>' +
'<th class="num">Duration</th><th>Model</th><th>Error / notes</th>' +
'</tr></thead>' +
'<tbody>' + rowHtmls + '</tbody>' +
'</table>';
return header + table;
}
// ── Column width resize ─────────────────────────────────
// Mouse-drag from the resize handle at the right edge of a
// header. Tracks delta against the starting width and writes back
// to state + localStorage on mouseup. We deliberately don't
// call render() during drag — direct DOM width assignment is
// smooth, and a full render mid-drag would reset our drag
// baseline. Final widths are persisted at mouseup.
let _colResize = null;
function onJobsColResizeStart(ev, key) {
ev.preventDefault();
ev.stopPropagation();
const th = ev.currentTarget.parentElement;
// ── HTML5 drag-and-drop interference workaround ──
// The TH has draggable="true" for column reordering. When the
// user mousedowns on the (draggable=false) resize handle inside
// it and starts moving, Chrome / Firefox interpret the motion
// as the START OF AN HTML5 DRAG ON THE TH, which suppresses
// mousemove events in favor of dragstart/dragover. Our resize
// handler never gets the moves it needs. stopPropagation on the
// handle doesn't help — the drag-detection happens at the
// document level based on the closest draggable ancestor at
// mousedown time. Workaround: turn the TH non-draggable for the
// duration of the resize, then restore at mouseup.
const wasDraggable = th.draggable;
th.draggable = false;
const startWidth = th.getBoundingClientRect().width;
_colResize = { key, startX: ev.clientX, startWidth, th, wasDraggable };
document.body.classList.add("col-resizing");
document.addEventListener("mousemove", onJobsColResizeMove);
document.addEventListener("mouseup", onJobsColResizeEnd);
}
function onJobsColResizeMove(ev) {
if (!_colResize) return;
const dx = ev.clientX - _colResize.startX;
// 60px floor — narrower than that is unusable (the resize
// handle and any 1-2 digit number both need ~50px combined).
const newWidth = Math.max(60, Math.round(_colResize.startWidth + dx));
_colResize.th.style.width = newWidth + "px";
_colResize.th.style.maxWidth = newWidth + "px";
_colResize.lastWidth = newWidth;
// Auto-layout tables use the WIDEST cell as the column-width
// floor unless every cell has a max-width. Without this loop,
// the user drags the TH smaller and the column visibly doesn't
// change until mouseup triggers a full re-render with
// injectMaxWidth firing on every TD. Updating each TD live
// gives instant visual feedback while dragging.
const cellIndex = _colResize.th.cellIndex;
if (cellIndex >= 0) {
const rows = document.querySelectorAll(".jobs-table tbody tr");
for (const tr of rows) {
const td = tr.children[cellIndex];
if (td && td.tagName === "TD") {
td.style.maxWidth = newWidth + "px";
}
}
}
}
function onJobsColResizeEnd() {
if (!_colResize) return;
const { key, lastWidth, startWidth, th, wasDraggable } = _colResize;
const finalWidth = lastWidth || startWidth;
const widths = { ...(state.jobsColumnWidths || {}) };
widths[key] = finalWidth;
state.jobsColumnWidths = widths;
savePrefs(PREFS_COL_WIDTHS_KEY, widths);
// Restore the TH's drag-reorder ability now that the resize is done.
if (th && wasDraggable !== undefined) th.draggable = wasDraggable;
document.body.classList.remove("col-resizing");
document.removeEventListener("mousemove", onJobsColResizeMove);
document.removeEventListener("mouseup", onJobsColResizeEnd);
_colResize = null;
// Trigger a render now that we're no longer mid-drag — picks
// up any polling-triggered renders that were skipped while
// _colResize was active (see render() guard).
render();
}
// ── Hidden-columns submenu actions ──────────────────────
function ctxShowHiddenSubmenu(ev) {
// Open the submenu in place; rendered inline as part of the
// context menu via renderContextMenu() rerender.
state.jobsContextSubmenu = "hidden";
renderContextMenu();
}
function ctxHideSubmenu() {
state.jobsContextSubmenu = null;
renderContextMenu();
}
function ctxShowOne(key) {
closeJobsContextMenu();
const hidden = (state.jobsHiddenColumns || []).filter((k) => k !== key);
saveHiddenColumns(hidden);
render();
}
function renderError(msg) {
root.innerHTML = '<div class="loading" style="color:var(--bad);">Error: ' + esc(msg) + '</div>';
}
function renderLogin() {
root.innerHTML =
'<form class="login-card" onsubmit="doLogin(event)">' +
'<h1>Recap Relay — Dashboard</h1>' +
'<input id="u" type="text" placeholder="Admin username" autocomplete="username" required />' +
'<input id="p" type="password" placeholder="Admin password" autocomplete="current-password" required />' +
(state.loginError ? '<div class="error">' + esc(state.loginError) + '</div>' : '') +
'<button type="submit">Sign in</button>' +
'</form>';
const u = document.getElementById("u");
if (u) u.focus();
}
function renderDashboard() {
const d = state.data;
const s = d.summary || {};
const ranges = [
{ label: "24h", days: 1 },
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
{ label: "All", days: 9999 },
];
const controls = ranges.map(r =>
'<button class="range-btn ' + (r.days === state.rangeDays ? "active" : "") + '" onclick="setRange(' + r.days + ')">' + r.label + '</button>'
).join("");
// ── Summary tiles ──
const rev = d.revenue || {};
const marginCls = (rev.margin_usd || 0) >= 0 ? "margin-pos" : "margin-neg";
const installsByTier = s.active_installs_by_tier || { core: 0, pro: 0, max: 0 };
const tiles =
tile("Summaries", fmtInt(s.total_summaries || 0),
fmtInt(s.calls || 0) + " calls (transcribe + analyze pairs)") +
tile("Success rate", ((s.success_rate || 0) * 100).toFixed(1) + "%",
fmtInt(s.success) + " of " + fmtInt(s.calls) + " · " + fmtInt(s.errors || 0) + " errors · " + fmtInt(s.refused || 0) + " refused", "good") +
tile("Monthly revenue (est.)", fmtMoney(rev.monthly_revenue_usd || 0),
installsByTier.pro + " pro · " + installsByTier.max + " max licenses active", "money") +
tile("Gemini cost", fmtMoney(s.total_cost_usd || 0), "USD spent on Gemini API", "money") +
marginTile("Operating margin (est.)", rev.margin_usd || 0, marginCls) +
tile("Avg call duration", fmtMs(s.avg_duration_ms), "across all backends");
// ── By tier ──
const byTier = (d.by_tier || []).sort((a, b) => b.cost_usd - a.cost_usd);
const tierMaxCost = Math.max(0, ...byTier.map(r => r.cost_usd));
const tierTable = table(
// "Unique users" rather than "Unique installs" because for paid
// tiers (pro/max) the underlying count is now distinct licenses,
// not installs — a Pro license active on cloud + self-host
// counts ONCE. Core remains install-based (no license to dedup).
["Tier", "Calls", "Cost (USD)", "Avg duration", "Unique users", "Cost share"],
byTier.map(r => [
tierPill(r.tier),
fmtInt(r.calls),
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
fmtMs(r.avg_duration_ms),
fmtInt(r.unique_installs),
bar(r.cost_usd, tierMaxCost, "money"),
])
);
// ── By model ──
const byModel = (d.by_model || []).sort((a, b) => b.cost_usd - a.cost_usd);
const modelMaxCost = Math.max(0, ...byModel.map(r => r.cost_usd));
const modelTable = table(
["Model", "Calls", "Cost (USD)", "Avg cost / call", "Avg duration", "Tokens", "Cost share"],
byModel.map(r => [
{ html: '<code>' + esc(r.model) + '</code>', num: false },
fmtInt(r.calls),
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
fmtMoney(r.avg_cost_usd),
fmtMs(r.avg_duration_ms),
fmtInt((r.input_tokens || 0) + (r.output_tokens || 0) + (r.thinking_tokens || 0)),
bar(r.cost_usd, modelMaxCost, "money"),
])
);
// ── By pipeline ──
const byPipeline = (d.by_pipeline || []);
const pipelineTable = table(
["Pipeline", "Calls", "Cost (USD)", "Avg duration"],
byPipeline.map(r => [
esc(r.pipeline),
fmtInt(r.calls),
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
fmtMs(r.avg_duration_ms),
])
);
// ── By backend ──
const byBackend = (d.by_backend || []);
const backendTable = table(
["Backend", "Calls", "Cost (USD)", "Avg duration"],
byBackend.map(r => [
esc(r.backend),
fmtInt(r.calls),
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
fmtMs(r.avg_duration_ms),
])
);
// ── Cost vs speed ──
const csRows = (d.cost_vs_speed || []);
const csTable = table(
["Model", "Avg duration", "Avg cost / call", "Calls"],
csRows.map(r => [
{ html: '<code>' + esc(r.model) + '</code>', num: false },
fmtMs(r.avg_duration_ms),
fmtMoney(r.avg_cost_usd),
fmtInt(r.calls),
])
);
// ── Top installs ──
const byInstall = (d.by_install || []);
const installMaxCost = Math.max(0, ...byInstall.map(r => r.cost_usd));
const installTable = table(
["Install", "Tier", "Summaries", "Calls", "Cost (USD)", "Avg duration", "Last active", "Cost share"],
byInstall.map(r => [
{ html: '<code>' + esc(shortId(r.install_id)) + '</code>', num: false },
tierPill(r.tier_snapshot),
fmtInt(r.summaries),
fmtInt(r.calls),
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
fmtMs(r.avg_duration_ms),
{ html: '<span class="dim">' + esc(fmtTs(r.last_active_at)) + '</span>', num: false },
bar(r.cost_usd, installMaxCost, "money"),
])
);
// ── Traffic by hour ──
const byHour = (d.by_hour_utc || []);
const hourMaxCalls = Math.max(0, ...byHour.map(r => r.calls));
const hourTable = table(
["Hour (UTC)", "Calls", "Cost (USD)", "Activity"],
byHour.map(r => [
String(r.hour_utc).padStart(2, "0") + ":00",
fmtInt(r.calls),
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
bar(r.calls, hourMaxCalls),
])
);
const rangeStart = new Date(d.range.since_ms).toLocaleString();
const rangeEnd = new Date(d.range.until_ms).toLocaleString();
// ── 24h success-rate alert banner — REMOVED in 0.2.66 ──
// The success-rate metric is already prominent in the tile grid
// below ("Success rate") and the by-model perf tables also surface
// it. The red banner was visual noise duplicating signal the
// operator can see at a glance. Removed entirely; alertBanner is
// kept as an empty-string sentinel so the existing
// `root.innerHTML = ... + alertBanner + ...` concatenation
// continues to work without an additional edit.
const alertBanner = '';
// ── Revenue / margin breakdown table ──
const tierRevRows = (rev.by_tier_revenue || []).map(r => [
tierPill(r.tier),
fmtInt(r.active_installs),
fmtMoney(r.price_usd),
{ html: '<span class="money">' + fmtMoney(r.revenue_usd) + '</span>', num: true },
]);
const revenueTable = table(
// Core column is install-count (free tier has no license to dedup);
// Pro/Max columns are distinct-license-count (a license active on
// multiple installs is one paying user). Single column header
// works for both because the underlying number is "active users
// we'd bill this month" in either case.
["Tier", "Active users", "Price (USD/mo)", "Revenue (USD)"],
tierRevRows
);
// ── Per-summary rollup table (top 50) ──
const summaries = (d.by_summary || []).slice(0, 50);
const summariesTable = summaries.length === 0
? '<div class="empty">No completed summaries in this range.</div>'
: table(
["Completed", "Install", "Tier", "Transcribe", "Analyze", "Duration", "Cost", "Status"],
summaries.map(r => [
{ html: '<span class="dim">' + esc(fmtTs(r.completed_at)) + '</span>', num: false },
{ html: '<code>' + esc(shortId(r.install_id)) + '</code>', num: false },
tierPill(r.tier),
{ html: '<code>' + esc(r.transcribe_backend || "—") + (r.transcribe_model ? ' / ' + r.transcribe_model : '') + '</code>', num: false },
{ html: '<code>' + esc(r.analyze_backend || "—") + (r.analyze_model ? ' / ' + r.analyze_model : '') + '</code>', num: false },
fmtMs(r.total_duration_ms),
{ html: '<span class="money">' + fmtMoney(r.total_cost_usd) + '</span>', num: true },
{ html: '<span class="status-' + r.status + '">' + r.status + '</span>', num: false },
])
);
// ── Per-(pipeline, model) performance benchmark + failure rate ──
// Standardizes wall-clock processing time on a per-minute-of-
// audio basis (transcribe) or per-1k-input-tokens basis (analyze)
// so different backends can be compared on a level playing field.
// Also exposes success rate per model — refused calls are
// excluded because they never reached the backend.
const perfRows = (d.perf_by_model || []);
const transcribePerf = perfRows
.filter((r) => r.pipeline === "transcribe")
.sort((a, b) => (a.ms_per_audio_minute ?? Infinity) - (b.ms_per_audio_minute ?? Infinity));
// Analyze rows now sort by ms_per_audio_minute too — the previous
// "per 1k input tokens" measurement was useful for comparing
// model token economics but Grant\'s feedback was that "speed by
// audio minute" is the more intuitive operator metric. The
// underlying audit data records audio_seconds for analyze rows
// (=window body duration) so this works just like the transcribe
// computation: sum_duration_ms / total_audio_minutes.
const analyzePerf = perfRows
.filter((r) => r.pipeline === "analyze")
.sort((a, b) => (a.ms_per_audio_minute ?? Infinity) - (b.ms_per_audio_minute ?? Infinity));
function fmtMsPerMin(v) {
if (v == null) return "—";
if (v < 1000) return v.toFixed(0) + " ms/min";
return (v / 1000).toFixed(2) + " s/min";
}
// Stricter "seconds per minute" formatter — always seconds, two
// decimals, no auto-switch to ms. The TX/AN by-model tables use
// this for a consistent unit across models (a tiny mock test
// permutation might be sub-second but for real audio it never is).
function fmtSecPerMin(v) {
if (v == null) return "—";
return (v / 1000).toFixed(2) + " s/min";
}
function fmtMsPer1k(v) {
if (v == null) return "—";
if (v < 1000) return v.toFixed(0) + " ms/1k tok";
return (v / 1000).toFixed(2) + " s/1k tok";
}
function fmtPct(v) {
if (v == null) return "—";
return (v * 100).toFixed(1) + "%";
}
function ratePill(rate, errors) {
if (rate == null) return '<span class="dim">—</span>';
const pct = (rate * 100).toFixed(1);
const cls = rate >= 0.95 ? "tile-good" : rate >= 0.8 ? "" : "status-error";
return '<span class="' + cls + '">' + pct + '%</span>' +
(errors > 0 ? ' <span class="dim">(' + errors + ' err)</span>' : '');
}
const transcribePerfTable = transcribePerf.length === 0
? '<div class="empty">No transcribe calls in this range.</div>'
: table(
["Model", "Calls", "Audio in range", "Speed (s / audio-min)", "Success rate"],
transcribePerf.map(r => [
{ html: '<code>' + esc(r.model) + '</code>', num: false },
fmtInt(r.calls),
r.total_audio_minutes ? r.total_audio_minutes.toFixed(0) + " min" : { html: '<span class="dim">—</span>', num: false },
{ html: '<span style="font-variant-numeric:tabular-nums;">' + fmtSecPerMin(r.ms_per_audio_minute) + '</span>', num: true },
{ html: ratePill(r.success_rate, r.errors), num: false },
])
);
const analyzePerfTable = analyzePerf.length === 0
? '<div class="empty">No analyze calls in this range.</div>'
: table(
["Model", "Calls", "Audio in range", "Speed (s / audio-min)", "Success rate"],
analyzePerf.map(r => [
{ html: '<code>' + esc(r.model) + '</code>', num: false },
fmtInt(r.calls),
r.total_audio_minutes ? r.total_audio_minutes.toFixed(0) + " min" : { html: '<span class="dim">—</span>', num: false },
{ html: '<span style="font-variant-numeric:tabular-nums;">' + fmtSecPerMin(r.ms_per_audio_minute) + '</span>', num: true },
{ html: ratePill(r.success_rate, r.errors), num: false },
])
);
// ── Top errors by model ──
// Surfaces the most-common failure mode per model — operator can
// see at a glance whether a model is mostly failing for one
// reason (a real bug or quota issue) or distributed across many
// (probably noise).
// "Failures" here = errors + partials (the audit aggregator now
// exposes both, plus a `failures` sum). Sorted by total failure
// volume so the worst offenders surface first.
const errorsByModelRows = perfRows
.filter((r) => r.top_errors && r.top_errors.length > 0)
.sort((a, b) => (b.failures || b.errors || 0) - (a.failures || a.errors || 0));
const errorsByModelTable = errorsByModelRows.length === 0
? '<div class="empty">No failures recorded against any model.</div>'
: table(
["Pipeline", "Model", "Failures", "Top failure modes"],
errorsByModelRows.map(r => {
const totalFailures = (r.failures != null) ? r.failures : (r.errors || 0);
const breakdown = (r.partials > 0 && r.errors > 0)
? ' <span class="dim">(' + r.errors + ' err, ' + r.partials + ' partial)</span>'
: (r.partials > 0)
? ' <span class="dim">(' + r.partials + ' partial)</span>'
: '';
return [
esc(r.pipeline),
{ html: '<code>' + esc(r.model) + '</code>', num: false },
{ html: fmtInt(totalFailures) + breakdown, num: true },
{ html: r.top_errors.map(e =>
'<div style="margin:2px 0;"><span class="dim">' + e.count + '×</span> <span class="err-msg" title="' + esc(e.signature) + '">' + esc(e.signature) + '</span></div>'
).join(""), num: false },
];
})
);
// ── Recent errors ──
const errs = d.errors || [];
const errorsTable = errs.length === 0
? '<div class="empty">No errors in this range — nice.</div>'
: table(
["When", "Install", "Tier", "Pipeline", "Backend", "Error", "Cascade"],
errs.map(e => [
{ html: '<span class="dim">' + esc(fmtTs(e.ts)) + '</span>', num: false },
{ html: '<code>' + esc(shortId(e.install_id)) + '</code>', num: false },
tierPill(e.tier),
esc(e.pipeline || "—"),
esc(e.backend || "—"),
{ html: '<span class="err-msg" title="' + esc(e.error) + '">' + esc(e.error) + '</span>', num: false },
{ html: e.attempts
? e.attempts.map(a =>
'<span style="white-space:nowrap;"><code>' + esc(a.model) + '</code> ' +
(a.status === "success"
? '<span class="tile-good">✓</span>'
: '<span class="status-error">' + esc(String(a.status)) + '</span>') +
'</span>'
).join(' <span class="dim">→</span> ')
: '<span class="dim">—</span>',
num: false },
])
);
const csvHref = "/admin/dashboard.csv?days=" + state.rangeDays;
root.innerHTML =
'<h1>Recap Relay — Operator Dashboard</h1>' +
'<p class="subtitle">' +
fmtInt(d.range.total_entries) + ' audit entries from ' + esc(rangeStart) + ' → ' + esc(rangeEnd) +
'</p>' +
tabsHtml() +
alertBanner +
// BTCPay setup-area moved to the Settings tab in 0.2.66 — the
// Overview no longer dedicates a row to it. The 24-hour
// success-rate alert was also removed (redundant with the
// tile-grid "Success rate" metric below).
'<div class="controls">' + controls +
'<a class="csv-link" href="' + csvHref + '" download>⤓ Export CSV</a>' +
'</div>' +
'<div class="tiles">' + tiles + '</div>' +
'<h2>Revenue / margin (estimated)</h2>' + revenueTable +
'<h2>Transcription speed by model (normalized)</h2>' + transcribePerfTable +
'<h2>Analysis speed by model (normalized)</h2>' + analyzePerfTable +
'<h2>Top failure modes by model</h2>' + errorsByModelTable +
'<h2>Summaries (most recent 50)</h2>' + summariesTable +
'<h2>Recent errors (most recent 50)</h2>' + errorsTable +
'<h2>By tier</h2>' + tierTable +
'<h2>By model</h2>' + modelTable +
'<div class="grid-2">' +
'<div><h2>By pipeline</h2>' + pipelineTable + '</div>' +
'<div><h2>By backend</h2>' + backendTable + '</div>' +
'</div>' +
'<h2>Cost vs speed</h2>' + csTable +
'<h2>Top installs by spend</h2>' + installTable +
'<h2>Traffic by hour (UTC)</h2>' + hourTable;
// Populate the BTCPay setup widget AFTER the DOM has been
// written — its target div needs to exist first.
renderBtcpaySetupArea();
}
// ── Jobs tab ───────────────────────────────────────────────
// Per-video aggregated stats with sortable + filterable table.
// Lazy-loaded on first switchTab("jobs"); refetches whenever
// sort / filter / pagination changes via loadJobs().
function renderJobsTab() {
const ranges = [
{ label: "24h", days: 1 },
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
{ label: "All", days: 9999 },
];
const rangeButtons = ranges
.map(
(r) =>
'<button class="range-btn ' +
(r.days === state.rangeDays ? "active" : "") +
'" onclick="setRange(' + r.days + ')">' + r.label + '</button>'
)
.join("");
let body;
if (state.jobsLoading && !state.jobsData) {
body = '<div class="loading">Loading jobs…</div>';
} else if (state.jobsData && state.jobsData.error) {
body = '<div class="loading" style="color:var(--bad);">Error: ' + esc(state.jobsData.error) + '</div>';
} else if (!state.jobsData) {
body = '<div class="loading">No data.</div>';
} else {
body = renderJobsBody(state.jobsData);
}
// Preserve scroll positions across the innerHTML replacement so
// the 5s suite-progress poll doesn't yank the operator's
// horizontal scroll back to column 0 every time the table
// re-renders. Captures from the OLD DOM (if it exists from a
// previous render), restores onto the NEW DOM via a microtask
// (browser needs the new layout to be in place before
// setting scrollLeft).
const prevWrap = document.getElementById("jobs-table-wrap");
const prevScrollLeft = prevWrap ? prevWrap.scrollLeft : 0;
const prevWindowScrollY = window.scrollY;
root.innerHTML =
'<h1>Recap Relay — Operator Dashboard</h1>' +
tabsHtml() +
'<div class="controls">' + rangeButtons + '</div>' +
body;
// Restore after the new DOM is in place. requestAnimationFrame
// waits one paint tick, ensuring scrollWidth/scrollHeight are
// up to date before we assign.
requestAnimationFrame(() => {
const newWrap = document.getElementById("jobs-table-wrap");
if (newWrap && prevScrollLeft > 0) {
newWrap.scrollLeft = prevScrollLeft;
}
if (prevWindowScrollY > 0) {
window.scrollTo({ top: prevWindowScrollY, behavior: "instant" });
}
});
// After the DOM is laid out, set up the sticky horizontal
// scrollbar to mirror the table's scroll position. Small
// setTimeout so the browser has computed scrollWidth.
setTimeout(syncStickyScrollbar, 0);
}
// ── Fixed-bottom horizontal scrollbar for the Jobs table ──
// Goal: a horizontal scrollbar pinned to the bottom of the
// browser viewport whenever the Jobs tab is open AND the table
// overflows horizontally — while the page itself scrolls
// vertically through ALL rows naturally. position: fixed (not
// sticky) avoids the "containing block" gymnastics that made
// sticky behave weirdly on tall pages.
//
// Position+width: matched to the table-wrap's bounding rect so
// the phantom bar visually lives directly under the table. As
// the user scrolls vertically, the bar's left+width stay in
// place horizontally; only the body's vertical scroll moves the
// rest of the page. As the user resizes columns or the window,
// the bar's geometry refreshes via ResizeObserver+resize.
let _stickyScrollObserver = null;
let _stickyScrollListeners = null;
function syncStickyScrollbar() {
const wrap = document.getElementById("jobs-table-wrap");
const sticky = document.getElementById("jobs-sticky-scroll");
const inner = document.getElementById("jobs-sticky-scroll-inner");
if (!wrap || !sticky || !inner) {
// Tear down listeners from a previous render where these
// DOM nodes existed — they're stale now.
teardownStickyScrollbar();
return;
}
const updateGeometry = () => {
const rect = wrap.getBoundingClientRect();
const overflows = wrap.scrollWidth > wrap.clientWidth + 1;
if (!overflows) {
sticky.style.display = "none";
return;
}
sticky.style.display = "block";
sticky.style.left = Math.max(0, rect.left) + "px";
sticky.style.width = Math.min(rect.width, window.innerWidth - Math.max(0, rect.left)) + "px";
inner.style.width = wrap.scrollWidth + "px";
};
updateGeometry();
// Two-way scroll sync — guarded against recursion.
let syncing = false;
const fromWrap = () => {
if (syncing) return;
syncing = true;
sticky.scrollLeft = wrap.scrollLeft;
requestAnimationFrame(() => (syncing = false));
};
const fromSticky = () => {
if (syncing) return;
syncing = true;
wrap.scrollLeft = sticky.scrollLeft;
requestAnimationFrame(() => (syncing = false));
};
// Reposition the bar on window resize and on page scroll
// (left position can shift if a sidebar appears, etc).
const onWindowChange = () => updateGeometry();
// Clean up any previous listeners (every render rewires).
teardownStickyScrollbar();
wrap.addEventListener("scroll", fromWrap, { passive: true });
sticky.addEventListener("scroll", fromSticky, { passive: true });
window.addEventListener("resize", onWindowChange, { passive: true });
window.addEventListener("scroll", onWindowChange, { passive: true });
_stickyScrollListeners = { wrap, sticky, fromWrap, fromSticky, onWindowChange };
_stickyScrollObserver = new ResizeObserver(updateGeometry);
_stickyScrollObserver.observe(wrap);
const table = wrap.querySelector("table");
if (table) _stickyScrollObserver.observe(table);
}
function teardownStickyScrollbar() {
if (_stickyScrollListeners) {
const { wrap, sticky, fromWrap, fromSticky, onWindowChange } = _stickyScrollListeners;
try { wrap.removeEventListener("scroll", fromWrap); } catch {}
try { sticky.removeEventListener("scroll", fromSticky); } catch {}
try { window.removeEventListener("resize", onWindowChange); } catch {}
try { window.removeEventListener("scroll", onWindowChange); } catch {}
_stickyScrollListeners = null;
}
if (_stickyScrollObserver) {
_stickyScrollObserver.disconnect();
_stickyScrollObserver = null;
}
}
function renderJobsBody(d) {
const s = d.summary || {};
const f = state.jobsFilters;
// Test-run panel (above the summary cards).
const testRunPanel = renderTestRunPanel();
// Summary cards.
const summary =
'<div class="jobs-summary">' +
tile("Jobs in range", fmtInt(s.total)) +
tile("Success rate", fmtPct(s.success_rate), s.partial > 0 ? fmtInt(s.partial) + " partial · " + fmtInt(s.failed) + " failed" : fmtInt(s.failed) + " failed") +
tile("Median wall time", fmtDuration(s.median_wall_time_ms)) +
tile("Median transcribe / audio-min", fmtMsPerMin(s.median_transcribe_ms_per_min)) +
tile("Median analyze / audio-min", fmtMsPerMin(s.median_analyze_ms_per_min)) +
tile("Total audio processed", (s.total_audio_hours || 0).toFixed(1) + " h") +
tile("Total cost", fmtUsd(s.total_cost_usd)) +
'</div>';
// Filter row.
const statusOpt = (v, label) =>
'<option value="' + v + '"' + (f.status === v ? " selected" : "") + '>' + label + '</option>';
const txOpt = (v, label) =>
'<option value="' + v + '"' + (f.transcribe_backend === v ? " selected" : "") + '>' + label + '</option>';
const anOpt = (v, label) =>
'<option value="' + v + '"' + (f.analyze_backend === v ? " selected" : "") + '>' + label + '</option>';
const filters =
'<div class="jobs-filters">' +
'<label>Status</label>' +
'<select onchange="setJobsFilter(\'status\', this.value)">' +
statusOpt("", "All") + statusOpt("success", "Success") +
statusOpt("partial", "Partial") + statusOpt("failed", "Failed") +
'</select>' +
'<label>TX backend</label>' +
'<select onchange="setJobsFilter(\'transcribe_backend\', this.value)">' +
txOpt("", "Any") + txOpt("gemini", "Gemini") + txOpt("hardware", "Hardware") +
'</select>' +
'<label>Analyze backend</label>' +
'<select onchange="setJobsFilter(\'analyze_backend\', this.value)">' +
anOpt("", "Any") + anOpt("gemini", "Gemini") + anOpt("hardware", "Hardware") +
'</select>' +
'<label>Model</label>' +
'<input type="text" placeholder="e.g. flash, parakeet" value="' + esc(f.model) + '" oninput="setJobsFilter(\'model\', this.value)" />' +
'<label>Search</label>' +
'<input class="q" type="text" placeholder="Title or URL…" value="' + esc(f.q) + '" oninput="setJobsFilter(\'q\', this.value)" />' +
'<label>Batch</label>' +
'<input type="text" placeholder="batch_id…" value="' + esc(f.batch_id || "") + '" oninput="setJobsFilter(\'batch_id\', this.value)" style="font-family: \'SF Mono\', Menlo, monospace; min-width: 120px;" />' +
'<label>Source</label>' +
'<select onchange="setJobsFilter(\'source\', this.value)">' +
'<option value=""' + ((!f.source) ? " selected" : "") + '>All</option>' +
'<option value="admin-test"' + (f.source === "admin-test" ? " selected" : "") + '>Test runs only</option>' +
'</select>' +
'<button onclick="clearJobsFilters()">Clear</button>' +
'<span style="color:var(--fg-faint); font-size:11px; margin-left:auto;">' + fmtInt(d.total_filtered) + ' jobs match</span>' +
'</div>';
// Column catalog — full set of available columns, keyed by
// their `key` (which matches the row object's property + the
// server-side sort key). The user's preferred ORDER and which
// columns are HIDDEN are applied below; both are persisted to
// localStorage so they survive page reloads.
const colCatalog = [
{ key: "select", label: "" },
{ key: "view", label: "" },
// "details" — per-job audit-log drill-down. Click the 🔍 to
// expand a sub-row showing every audit entry (transcribe +
// per-window analyze rows) so the operator can see exactly
// which analyze window failed and what error message it
// recorded. Backed by GET /admin/job/:id/details.
{ key: "details", label: "" },
{ key: "started_at", label: "Date" },
// Title/URL is the most variable-width column. Default 280px
// gives ~50 chars before ellipsis; user can resize wider for
// long YouTube URLs or narrower to claim space for other cols.
{ key: "title", label: "Title / URL", defaultWidth: 280 },
{ key: "batch_id", label: "Batch" },
{ key: "audio_seconds", label: "Duration" },
{ key: "audio_bytes", label: "Size (MB)" },
{ key: "chunk_count", label: "TX chunks" },
// Total download wall-time for the media file fetch. Surfaces
// alongside the per-MB and per-min rate columns so the
// operator can spot videos where the fetch itself was slow
// (rate-limit, network blip, big files) vs. where the
// transcribe phase dominated.
{ key: "download_ms", label: "DL time" },
{ key: "download_ms_per_mb", label: "DL s/MB" },
// TX wall time = outer wall-time of the whole TX phase (the
// time a user actually waits). TX time (sum) = total backend
// compute summed across all concurrent chunks (drives cost).
// For 3 Gemini chunks at concurrency 12 with each chunk
// taking 60s of API time: wall ≈ 60s, sum = 180s.
{ key: "transcribe_ms", label: "TX wall time" },
{ key: "transcribe_ms_sum", label: "TX time (sum)" },
{ key: "transcribe_ms_per_min", label: "TX s/audio-min" },
{ key: "transcribe_ms_per_mb", label: "TX s/MB" },
{ key: "transcribe_backend", label: "TX backend" },
{ key: "transcribe_model", label: "TX model" },
{ key: "analyze_windows_total", label: "AN windows" },
// AN time = SUM of per-window durations (total backend
// compute, drives cost). AN wall time = ELAPSED clock time
// from first window start to last window end (the time the
// operator/user actually waits for analyze to finish). For
// a single-batch concurrent analyze the two diverge by
// ~N×: 10 windows at 100s each = 1000s AN time but ≈100s
// AN wall. WALL ≈ DL + TX + AN_wall is the intuitive total.
{ key: "analyze_ms", label: "AN time (sum)" },
{ key: "analyze_wall_time_ms", label: "AN wall time" },
{ key: "analyze_ms_per_min", label: "AN s/audio-min" },
{ key: "analyze_ms_per_mb", label: "AN s/MB" },
{ key: "analyze_backend", label: "AN backend" },
{ key: "analyze_model", label: "AN model" },
{ key: "wall_time_ms", label: "Wall time" },
{ key: "cost_usd", label: "Cost" },
{ key: "tier", label: "Tier" },
{ key: "overall_status", label: "Status" },
// Default width capped — a long error string would otherwise
// stretch the column to the full message width, blowing out
// the table's horizontal scroll. The expand button (+) on each
// row's errors cell shows the full text on demand, so a tight
// default is fine.
{ key: "errors", label: "Errors", sortable: false, defaultWidth: 220 },
];
const cols = applyColumnPrefs(colCatalog);
const hidden = state.jobsHiddenColumns || [];
const widths = state.jobsColumnWidths || {};
const headers = cols.map((c) => {
const isActive = c.sortable !== false && state.jobsSort.col === c.key;
const ind = isActive
? '<span class="sort-ind">' + (state.jobsSort.dir === "asc" ? "▲" : "▼") + '</span>'
: '';
// Sort on left-click (for sortable cols), context menu on
// right-click. Drag handlers reorder columns. Keying by data-*
// attributes keeps the global delegated handlers clean. The
// small col-resize-handle div at the right edge lets the user
// drag the column wider/narrower — stopPropagation keeps the
// mousedown there from being interpreted as a header click /
// drag-reorder.
const onclick = c.sortable === false ? "" : "onclick=\"setJobsSort('" + c.key + "')\"";
// Width precedence: user-resized (jobsColumnWidths) → catalog
// defaultWidth → auto. Catalog defaults exist for columns
// whose natural content is unbounded (e.g. Errors) so the
// table doesn't stretch sideways when a single long string
// shows up.
const effectiveWidth = widths[c.key] || c.defaultWidth || null;
const widthStyle = effectiveWidth
? 'style="width:' + effectiveWidth + 'px; max-width:' + effectiveWidth + 'px;"'
: '';
// Label wrapped in a span so line-clamp:2 + word-break:normal
// CSS can apply WITHOUT changing the TH's display from
// table-cell (which would break the table layout). The TH
// itself stays table-cell; the inner span handles the visual
// wrapping behavior. Span also carries `title` for native
// tooltip when the label gets ellipsis-truncated.
return '<th draggable="true" data-col="' + esc(c.key) + '"' +
' class="' + (isActive ? "sort-active " : "") + '"' +
' ' + widthStyle +
' ondragstart="onJobsColDragStart(event, \'' + c.key + '\')"' +
' ondragover="onJobsColDragOver(event, \'' + c.key + '\')"' +
' ondrop="onJobsColDrop(event, \'' + c.key + '\')"' +
' ondragend="onJobsColDragEnd(event)"' +
' oncontextmenu="onJobsColContextMenu(event, \'' + c.key + '\')"' +
' ' + onclick + '>' +
'<span class="th-label" draggable="false" title="' + esc(c.label) + '">' + esc(c.label) + '</span>' + ind +
'<div class="col-resize-handle" draggable="false"' +
' onmousedown="onJobsColResizeStart(event, \'' + c.key + '\')"' +
' onclick="event.stopPropagation()"' +
' oncontextmenu="event.stopPropagation()"' +
'></div>' +
'</th>';
}).join("");
// Per-column cell renderers — drive both the static layout AND
// the user-reorderable / user-hideable layout from a single
// source. Each entry maps a column key to a function that
// produces the <td>...</td> string for a given job row.
const cellRenderers = {
// Row-select checkbox. ALWAYS rendered now, regardless of
// whether the row has a stored output. v0.2.39 extended the
// "Delete selected" button to ALSO wipe audit rows for the
// selected job_ids (not just output JSON), so the checkbox
// is useful even on rows whose output was already deleted
// OR rows that never had an output (Recap-submitted traffic
// with relay_save_user_outputs=false). Pending rows (no
// job_id yet) and orphan-rows render with disabled checkbox.
select: (j) => {
const id = j.job_id;
if (!id || j._isPending) {
// Pending rows can't be selected — there's no real
// job_id to delete by. Render disabled checkbox so the
// column visually lines up but isn't clickable.
return '<td style="padding-left:14px;"><input type="checkbox" disabled title="Job in flight — wait for completion to select" /></td>';
}
const checked = (state.jobsSelected || new Set()).has(id) ? "checked" : "";
return '<td style="padding-left:14px;"><input type="checkbox" data-job-select="' + esc(id) + '" ' + checked + ' onclick="toggleJobSelect(\'' + esc(id) + '\')" title="Select for Delete-selected" /></td>';
},
// "View output" link — opens the standalone Recap-style
// render in a new tab when an output JSON is on disk for
// this job. When no output exists (e.g. user-traffic row
// and relay_save_user_outputs=false, or Delete EVERYTHING
// was run earlier), render a dimmed disabled placeholder
// with an explanatory tooltip so the operator knows WHY the
// eyeball isn\'t actionable.
view: (j) => {
if (!j.has_output) {
return '<td><span class="dim" title="No saved output for this row. Either Delete EVERYTHING was run earlier, or this is user traffic with relay_save_user_outputs=false (toggle in Settings to capture).">👁</span></td>';
}
const href = "/job-output-view.html?id=" + encodeURIComponent(j.job_id);
return '<td><a href="' + href + '" target="_blank" rel="noopener" title="Open transcript + topics in a new tab" style="color: var(--accent); text-decoration: none;">👁 View</a></td>';
},
// 🔍 button — expands a sub-row below this one showing every
// audit row keyed to job_id. Useful when a job's overall_status
// is "partial" or "failed" and the operator wants to see WHICH
// analyze window failed and what the recorded error was, without
// shell access to /data/relay-calls.ndjson. Disabled on pending
// rows (no job_id yet) and on orphan rows (job_id missing).
details: (j) => {
const id = j.job_id;
if (!id || j._isPending) {
return '<td><span class="dim" title="No job_id available — pending or orphan row.">🔍</span></td>';
}
const expanded = state.jobsExpandedDetails && state.jobsExpandedDetails[id];
const sym = expanded ? "▾" : "🔍";
const title = expanded
? "Hide per-window audit detail"
: "Inspect per-window audit detail (transcribe + each analyze window)";
return '<td><button class="job-detail-btn" onclick="toggleJobDetails(\'' + esc(id) + '\')" title="' + esc(title) + '">' + sym + '</button></td>';
},
started_at: (j) => '<td>' + esc(fmtDate(j.started_at)) + '</td>',
title: (j) => {
const titleCell = j.title
? (j.media_url
? '<a href="' + esc(j.media_url) + '" target="_blank" rel="noopener">' + esc(j.title) + '</a>'
: esc(j.title))
: (j.media_url
? '<a href="' + esc(j.media_url) + '" target="_blank" rel="noopener">' + esc(j.media_url) + '</a>'
: '<span class="dim">—</span>');
return '<td class="title-cell" title="' + esc(j.title || j.media_url || "") + '">' + titleCell + '</td>';
},
batch_id: (j) => {
if (!j.batch_id) return '<td><span class="dim">—</span></td>';
// Click the batch ID to filter the table to just this batch.
const short = j.batch_id.length > 12 ? j.batch_id.slice(0, 12) + "…" : j.batch_id;
return '<td><a href="#" onclick="event.preventDefault(); setJobsFilter(\'batch_id\', \'' + esc(j.batch_id) + '\')" title="Click to filter to this batch (' + esc(j.batch_id) + ')" style="color: var(--accent); text-decoration: none; font-family: \'SF Mono\', Menlo, monospace; font-size: 10px;">' + esc(short) + '</a></td>';
},
audio_seconds: (j) => '<td class="num">' + (j.audio_seconds ? fmtDuration(j.audio_seconds * 1000) : '<span class="dim">—</span>') + '</td>',
audio_bytes: (j) => {
const sizeMb = j.audio_bytes ? (j.audio_bytes / (1024 * 1024)).toFixed(1) : null;
return '<td class="num">' + (sizeMb ?? '<span class="dim">—</span>') + '</td>';
},
chunk_count: (j) => '<td class="num">' + (j.chunk_count ?? '<span class="dim">—</span>') + '</td>',
// Total download wall-time (in seconds) for the media fetch
// phase. Same formatter as other ms-bearing cells.
download_ms: (j) => '<td class="num">' + fmtMsOrDash(j.download_ms) + '</td>',
download_ms_per_mb: (j) => '<td class="num">' + fmtMsOrDash(j.download_ms_per_mb, 0) + '</td>',
transcribe_ms: (j) => '<td class="num">' + fmtMsOrDash(j.transcribe_ms) + '</td>',
// Total backend compute across chunks. Differs from
// transcribe_ms (wall) when chunked transcribe runs N chunks
// in parallel: wall ≈ slowest chunk × ⌈N/concurrency⌉, sum
// ≈ N × per-chunk duration.
transcribe_ms_sum: (j) => '<td class="num">' + fmtMsOrDash(j.transcribe_ms_sum) + '</td>',
transcribe_ms_per_min: (j) => '<td class="num">' + fmtMsOrDash(j.transcribe_ms_per_min) + '</td>',
transcribe_ms_per_mb: (j) => '<td class="num">' + fmtMsOrDash(j.transcribe_ms_per_mb) + '</td>',
transcribe_backend: (j) => '<td>' + esc(j.transcribe_backend || "—") + '</td>',
transcribe_model: (j) => '<td>' + esc(j.transcribe_model || "—") + '</td>',
analyze_windows_total: (j) => '<td class="num">' + (j.analyze_windows_total || 0) +
(j.analyze_windows_failed > 0 ? ' <span class="dim">(' + j.analyze_windows_failed + ' failed)</span>' : '') + '</td>',
analyze_ms: (j) => '<td class="num">' + fmtMsOrDash(j.analyze_ms) + '</td>',
// Elapsed clock time across the analyze phase (max window end
// minus min window start). For a single concurrent batch this
// approximates the slowest window's duration; for multi-batch
// analyze (windows > concurrency) it spans both batches.
analyze_wall_time_ms: (j) => '<td class="num">' + fmtMsOrDash(j.analyze_wall_time_ms) + '</td>',
analyze_ms_per_min: (j) => '<td class="num">' + fmtMsOrDash(j.analyze_ms_per_min) + '</td>',
analyze_ms_per_mb: (j) => '<td class="num">' + fmtMsOrDash(j.analyze_ms_per_mb) + '</td>',
analyze_backend: (j) => '<td>' + esc(j.analyze_backend || "—") + '</td>',
analyze_model: (j) => '<td>' + esc(j.analyze_model || "—") + '</td>',
wall_time_ms: (j) => '<td class="num">' + fmtDuration(j.wall_time_ms) + '</td>',
cost_usd: (j) => '<td class="money">' + fmtUsd(j.cost_usd) + '</td>',
tier: (j) => '<td>' + (j.tier ? '<span class="pill pill-' + esc(j.tier) + '">' + esc(j.tier) + '</span>' : '<span class="dim">—</span>') + '</td>',
overall_status: (j) => '<td><span class="status-pill status-' + esc(j.overall_status || "failed") + '">' + esc(j.overall_status || "—") + '</span></td>',
errors: (j) => {
if (!j.errors || j.errors.length === 0) {
// Even the empty case needs the max-width inline style or
// a later row's populated cell will "win" the auto-layout
// column width contest. Stamp the same constraint on the
// dimmed placeholder TD.
const ew = (state.jobsColumnWidths || {}).errors
|| (colCatalog.find((c) => c.key === "errors") || {}).defaultWidth
|| 220;
return '<td style="max-width:' + ew + 'px;"><span class="dim">—</span></td>';
}
const allText = j.errors.join("; ");
// Per-row expand state keyed by job_id (or fallback to
// started_at when job_id is null — orphan rows). Click the
// chevron to flip ONE row's errors cell to wrap; other rows
// keep their single-line height.
const id = j.job_id || ("_orphan_" + j.started_at);
const expanded = state.jobsExpandedErrors && state.jobsExpandedErrors[id];
const cls = "errors-cell-td" + (expanded ? " expanded" : "");
const symbol = expanded ? "" : "+";
// Effective width = user-resized (state.jobsColumnWidths) →
// catalog defaultWidth (220px) → 220 fallback. Applied
// inline as max-width so the TD enforces it in auto-layout
// tables (where the TH width is just a hint).
const ew = (state.jobsColumnWidths || {}).errors
|| (colCatalog.find((c) => c.key === "errors") || {}).defaultWidth
|| 220;
// Store the raw text on a data-attribute so the copy button
// can read it without HTML-entity round-tripping (escape
// injected into the title attr/visible span isn't what we
// want to put on the clipboard). Encoded via encodeURIComponent
// to survive both attribute quoting and any embedded quotes
// / newlines / control chars in the error message.
const dataAttr = encodeURIComponent(allText);
return '<td class="' + cls + '" data-err-raw="' + dataAttr + '" title="' + esc(allText) + '" style="max-width:' + ew + 'px;">' +
'<span class="err-text">' + esc(allText) + '</span>' +
'<button class="err-copy" onclick="copyErrorCell(this, \'' + esc(id) + '\')" title="Copy to clipboard">📋</button>' +
'<button class="err-expand" onclick="toggleErrorsCell(\'' + esc(id) + '\')" title="Expand">' + symbol + '</button>' +
'</td>';
},
};
// Body rows. Walk the (reordered + filtered) cols array so the
// cell order matches the header order.
//
// Synthetic "pending" rows are PREPENDED for any active test-run
// jobs not yet present in d.jobs. Lets the operator see the row
// land in the table the instant they hit Run, with the progress
// text in the title column, and watch it morph into the real
// row once audit entries land. Filtered out by job_id once the
// real row arrives (loadJobs() picks it up by then).
const existingIds = new Set((d.jobs || []).map((j) => j.job_id));
const pendingRows = Object.entries(state.activeJobs || {})
.filter(([id]) => !existingIds.has(id))
.map(([id, info]) => {
// Synthesize a Jobs-table-shaped row that the existing
// cellRenderers can consume. Most fields are null; the title
// shows the progress text + job-id stub so the row reads as
// alive. cellRenderers fall back to dimmed "—" on null, so
// we don't need to fill every column manually.
const url = info.input?.media_url || null;
return {
job_id: id,
install_id: "admin-test",
tier: "core",
media_url: url,
title: (info.progress || "running…"),
batch_id: null,
source: "admin-test",
started_at: info.startedAt,
completed_at: null,
overall_status: info.stage === -1 ? "failed" : "pending",
transcribe_status: "pending",
audio_seconds: null,
audio_bytes: null,
chunk_count: null,
transcribe_backend: info.input?.transcribe_backend || null,
transcribe_model: info.input?.transcribe_model || null,
transcribe_ms: null,
analyze_backend: info.input?.analyze_backend || null,
analyze_model: info.input?.analyze_model || null,
analyze_ms: null,
analyze_windows_total: 0,
wall_time_ms: null,
cost_usd: 0,
errors: [],
has_output: false,
_isPending: true, // flag in case cell renderers want to style
};
});
// Stamp max-width onto each TD based on the column's effective
// width (user-resized → catalog defaultWidth → unconstrained).
// Required because auto-layout tables ignore min-width: 0 on TD
// unless there's also an upper bound — without this, a cell's
// natural content width still pins the column wider than the
// user's resize, even though we set inline width on the TH.
// Injects/merges the style attribute on the first <td of each
// cellRenderer's output. Safe for renderers that already have
// a style attribute (e.g. the errors cell sets its own
// max-width inline — that takes precedence over this injection
// since it appears first in the merged style string).
function injectMaxWidth(tdHtml, w) {
if (!w) return tdHtml;
return tdHtml.replace(/^<td([^>]*)>/, (match, attrs) => {
if (/style\s*=\s*"/.test(attrs)) {
// Prepend max-width to existing style so renderer-set
// styles can override (last-write-wins in CSS).
return match.replace(
/style\s*=\s*"([^"]*)"/,
(_m, s) => 'style="max-width:' + w + 'px; ' + s + '"'
);
}
return '<td' + attrs + ' style="max-width:' + w + 'px;">';
});
}
const visibleJobs = pendingRows.concat(d.jobs || []);
let rowsHtml;
if (visibleJobs.length === 0) {
rowsHtml = '<tr><td colspan="' + cols.length + '" class="jobs-empty">No jobs match these filters.</td></tr>';
} else {
rowsHtml = visibleJobs.map((j) => {
const cells = cols.map((c) => {
const r = cellRenderers[c.key];
const raw = r ? r(j) : '<td><span class="dim">—</span></td>';
const w = widths[c.key] || c.defaultWidth || null;
return injectMaxWidth(raw, w);
}).join("");
// Soft-highlight pending rows so the operator visually
// distinguishes them from completed audit data.
const trStyle = j._isPending
? ' style="background: rgba(165,180,252,0.06);"'
: '';
let mainRow = '<tr' + trStyle + '>' + cells + '</tr>';
// Insert an inline detail row beneath the main row if the
// operator has clicked 🔍 to expand this job. Colspan covers
// every visible column so the detail panel stretches across
// the whole table width. Detail content is rendered by
// renderJobDetailsCell() from the cached fetch result on
// state.jobsExpandedDetails[job_id].
const id = j.job_id;
const expandedDetail = id && state.jobsExpandedDetails && state.jobsExpandedDetails[id];
if (expandedDetail) {
mainRow +=
'<tr class="job-detail-row"><td colspan="' + cols.length + '" class="job-detail-cell">' +
renderJobDetailsCell(id, expandedDetail) +
'</td></tr>';
}
return mainRow;
}).join("");
}
const table =
'<div class="jobs-table-wrap" id="jobs-table-wrap"><table class="jobs-table"><thead><tr>' + headers + '</tr></thead><tbody>' + rowsHtml + '</tbody></table></div>' +
'<div class="jobs-sticky-scroll" id="jobs-sticky-scroll"><div class="jobs-sticky-scroll-inner" id="jobs-sticky-scroll-inner"></div></div>';
// Pagination.
const totalPages = d.total_pages || 1;
const page = d.page || 1;
const pagination =
'<div class="jobs-pagination">' +
'<button onclick="setJobsPage(1)" ' + (page === 1 ? "disabled" : "") + '>First</button>' +
'<button onclick="setJobsPage(' + (page - 1) + ')" ' + (page === 1 ? "disabled" : "") + '>Previous</button>' +
'<span>Page ' + fmtInt(page) + ' of ' + fmtInt(totalPages) + '</span>' +
'<button onclick="setJobsPage(' + (page + 1) + ')" ' + (page >= totalPages ? "disabled" : "") + '>Next</button>' +
'<button onclick="setJobsPage(' + totalPages + ')" ' + (page >= totalPages ? "disabled" : "") + '>Last</button>' +
'</div>';
// "Stored outputs" mini-panel — sits between the filter row
// and the table. Shows current store size + Delete-selected /
// Delete-all buttons. Hidden entirely when no outputs are
// stored (clean dashboard for fresh installs).
const stats = state.outputStoreStats || { count: 0, total_bytes: 0 };
const selCount = (state.jobsSelected || new Set()).size;
const sizeMb = stats.total_bytes ? (stats.total_bytes / (1024 * 1024)).toFixed(1) : "0";
// Panel is always shown now (used to be hidden when stats.count
// was 0) — the "Delete EVERYTHING" button is operator-useful
// even with no stored outputs (clears the audit log for a
// going-live clean slate).
const storedPanel = `
<div style="display:flex; align-items:center; gap:12px; margin: 12px 0; padding: 10px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; font-size: 12px;">
<span style="color: var(--fg-dim);"><strong style="color: var(--fg);">${esc(fmtInt(stats.count))}</strong> stored output${stats.count === 1 ? "" : "s"} · ${esc(sizeMb)} MB</span>
<span style="color: var(--fg-faint);">·</span>
<span style="color: var(--fg-dim);"><strong id="jobs-selected-counter" style="color: var(--fg);">${selCount}</strong> selected</span>
<button id="delete-selected-btn" class="tr-btn" onclick="deleteSelectedOutputs()" ${selCount === 0 ? "disabled" : ""} title="Delete selected rows AND their audit log entries">Delete selected</button>
<button class="tr-btn" onclick="deleteAllOutputs()" style="color: var(--bad);" title="Delete every stored output JSON (audit log kept)">Delete all stored outputs</button>
<button class="tr-btn" onclick="wipeEverything()" style="color: var(--bad); border-color: var(--bad); margin-left: auto;" title="Going-live cleanup: deletes every stored output AND truncates the entire audit log. Two-step confirmation.">Delete EVERYTHING</button>
</div>
`;
const inFlightCallout = renderInFlightJobsCallout();
const discoveryStrip = renderDiscoveryDiagnosticStrip();
return discoveryStrip + inFlightCallout + testRunPanel + summary + filters + storedPanel + table + pagination;
}
// Single-line diagnostic strip at the top of the Jobs tab. Shows
// whether the in-flight job discovery poll is alive and how many
// running jobs the last poll found. Critical for debugging "I
// submitted a job from Recap and don't see a pending row" — if
// the strip says "polling: ON, last poll 2s ago, found 0 running"
// we know the discovery code is running but /admin/jobs isn't
// returning the Recap job. If it says "polling: STALE, last poll
// 45s ago" we know the interval got cancelled somewhere. If it
// says "polling: ERROR" we surface the message.
function renderDiscoveryDiagnosticStrip() {
const stats = state.discoveryStats || {};
const polling = !!_discoverPollHandle;
const lastAt = stats.lastPollAt;
const sinceMs = lastAt ? Date.now() - lastAt : null;
const since = sinceMs == null
? "never"
: sinceMs < 1000
? Math.round(sinceMs) + "ms"
: sinceMs < 60000
? Math.round(sinceMs / 1000) + "s"
: Math.round(sinceMs / 60000) + "m";
const stale = sinceMs != null && sinceMs > 10000;
const stateLabel = !polling
? '<span style="color:var(--bad);">OFF</span>'
: stale
? '<span style="color:var(--warn);">STALE</span>'
: '<span style="color:var(--good);">ON</span>';
const foundLabel = stats.lastPollFoundRunning != null
? stats.lastPollFoundRunning + ' running'
: '—';
const errBit = stats.lastError
? ' · <span style="color:var(--bad);" title="' + esc(stats.lastError) + '">poll error: ' + esc(stats.lastError.slice(0, 60)) + '</span>'
: '';
const totalBit = typeof stats.totalPolls === "number"
? ' · <span class="dim">total polls: ' + stats.totalPolls + '</span>'
: '';
// "View raw response" button — shows what /admin/jobs actually
// returned on the last poll. Critical for diagnosing
// "discovery is ON but found: 0 running" when the operator
// KNOWS a job is in flight: maybe the job has a kind the
// filter doesn't recognize, maybe status is something other
// than running/queued, maybe the response is empty entirely
// (relay routing / cookie issue). Click to expand inline.
const rawBtn = stats.lastResponseSummary
? ' · <button onclick="toggleDiscoveryDetails()" style="background: transparent; border: 1px solid var(--line-2); color: var(--accent); padding: 1px 8px; border-radius: 4px; font-size: 10px; cursor: pointer;">' +
(state.discoveryDetailsOpen ? 'hide raw' : 'view raw response') +
'</button>'
: '';
const rawJsonBlock = stats.rawResponseSample
? '<div style="margin-top:10px; padding:8px 10px; background:var(--panel-2); border:1px solid var(--line-2); border-radius:4px;">' +
'<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px;">' +
'<strong style="color:var(--fg-faint); font-size:10px; text-transform:uppercase;">Raw response sample</strong>' +
'<button onclick="copyDiscoveryRawJson()" style="background: transparent; border: 1px solid var(--line-2); color: var(--accent); padding: 1px 8px; border-radius: 4px; font-size: 10px; cursor: pointer;">📋 Copy JSON</button>' +
'<span id="discovery-raw-copy-status" style="color:var(--good); font-size:10px;"></span>' +
'</div>' +
'<div class="dim" style="margin-bottom:4px;">envelope keys: <code style="color:var(--fg);">' + esc(JSON.stringify(stats.rawResponseSample.envelope_top_level_keys)) + '</code></div>' +
'<pre style="margin:4px 0 0 0; padding:8px; background:var(--panel); border:1px solid var(--line-2); border-radius:4px; font-size:10px; color:var(--fg); overflow-x:auto; max-height:240px; line-height:1.4;">' + esc(JSON.stringify(stats.rawResponseSample.first_5_entries, null, 2)) + '</pre>' +
'</div>'
: '';
const detailsBlock = state.discoveryDetailsOpen && stats.lastResponseSummary
? '<div style="width:100%; margin-top:8px; padding:10px 12px; background:var(--panel); border:1px solid var(--line-2); border-radius:6px; font-size:11px; color:var(--fg-dim);">' +
'<div style="margin-bottom:6px;"><strong style="color:var(--fg);">/admin/jobs response summary</strong> <span class="dim">(last poll)</span></div>' +
'<div>total entries: <strong style="color:var(--fg);">' + stats.lastResponseSummary.total_entries + '</strong></div>' +
'<div>by kind: <code style="color:var(--fg);">' + esc(JSON.stringify(stats.lastResponseSummary.kinds)) + '</code></div>' +
'<div>by status: <code style="color:var(--fg);">' + esc(JSON.stringify(stats.lastResponseSummary.statuses)) + '</code></div>' +
'<div style="margin-top:6px;"><strong style="color:var(--fg-faint);">Most recent 5 jobs (any status):</strong></div>' +
(stats.lastResponseSummary.recent_jobs.length === 0
? '<div class="dim" style="margin-top:4px;">(none — the relay has no in-memory jobs at all)</div>'
: '<table style="margin-top:4px; font-size:10.5px; font-family: ui-monospace, monospace; width:100%;">' +
'<thead><tr style="color:var(--fg-faint); text-align:left;"><th style="padding:2px 8px 2px 0;">id</th><th style="padding:2px 8px;">kind</th><th style="padding:2px 8px;">status</th><th style="padding:2px 8px;">age</th><th style="padding:2px 8px;">progress</th></tr></thead>' +
'<tbody>' +
stats.lastResponseSummary.recent_jobs.map((j) =>
'<tr>' +
'<td style="padding:2px 8px 2px 0; color:var(--fg);">' + esc(j.id_short) + '</td>' +
'<td style="padding:2px 8px; color:var(--fg);">' + esc(j.kind || "—") + '</td>' +
'<td style="padding:2px 8px; color:var(--fg);">' + esc(j.status || "—") + '</td>' +
'<td style="padding:2px 8px; color:var(--fg-dim);">' + (j.age_sec != null ? j.age_sec + 's' : '—') + '</td>' +
'<td style="padding:2px 8px; color:var(--fg-dim); max-width:400px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + esc(j.progress || "") + '">' + esc(j.progress || "—") + '</td>' +
'</tr>'
).join("") +
'</tbody></table>') +
rawJsonBlock +
'</div>'
: '';
return (
'<div style="margin: 8px 0 12px; padding: 6px 12px; ' +
'background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 6px; ' +
'font-size: 10.5px; color: var(--fg-dim); display: flex; flex-wrap: wrap; gap: 14px; align-items: center;">' +
'<span><strong style="color:var(--fg-faint); text-transform:uppercase; letter-spacing:0.04em; font-size:9.5px;">Discovery</strong> ' +
stateLabel + '</span>' +
'<span class="dim">last poll: <strong style="color:var(--fg);">' + esc(since) + ' ago</strong></span>' +
'<span class="dim">found: <strong style="color:var(--fg);">' + esc(String(foundLabel)) + '</strong></span>' +
'<span class="dim">active: <strong style="color:var(--fg);">' + Object.keys(state.activeJobs || {}).length + '</strong></span>' +
totalBit +
errBit +
rawBtn +
detailsBlock +
'</div>'
);
}
function toggleDiscoveryDetails() {
state.discoveryDetailsOpen = !state.discoveryDetailsOpen;
// Full render here (not lightweight) so the expanded block
// appears immediately. Doesn't fall under the Settings-tab
// guard since this button only exists on the Jobs tab.
render();
}
// Copy the raw response JSON to the clipboard so the operator
// can paste it for diagnosis when the discovery is finding 0
// running jobs while a submission is in flight. The sample we
// ship includes the envelope's top-level keys + the first 5
// entries verbatim — enough to spot field-name mismatches,
// empty objects, or wrong response shape.
function copyDiscoveryRawJson() {
const sample = state.discoveryStats?.rawResponseSample;
if (!sample) return;
const text = JSON.stringify(sample, null, 2);
const status = document.getElementById("discovery-raw-copy-status");
const showOK = () => {
if (status) {
status.textContent = "✓ copied";
setTimeout(() => { status.textContent = ""; }, 1500);
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(showOK).catch(() => {
fallbackCopy(text, showOK);
});
} else {
fallbackCopy(text, showOK);
}
}
// ── In-flight jobs callout ──────────────────────────────────
// Dedicated section at the very top of the Jobs tab showing every
// active job (whether it came from the test-run panel or a Recap-
// app submission). Renders one card per job with the pizza-tracker
// breadcrumb + jobId + a "Source: Recap | Test-run" tag.
//
// This is visually separate from renderTestRunPanel()'s in-panel
// breadcrumb (which still exists for the test-run-submit flow's
// breadcrumb history). Lifted out so a Recap-submitted job has
// its own clear callout that doesn't look like it belongs to the
// test-run config panel.
//
// Returns "" when no jobs are in flight so the Jobs tab paints
// unchanged in the common case.
function renderInFlightJobsCallout() {
const ids = Object.keys(state.activeJobs || {});
if (ids.length === 0) return "";
// Sort newest-first so a fresh submission jumps to the top.
ids.sort((a, b) => {
const ja = state.activeJobs[a];
const jb = state.activeJobs[b];
return (jb?.startedAt || 0) - (ja?.startedAt || 0);
});
const cards = ids.map((id) => {
const job = state.activeJobs[id] || {};
const stage = typeof job.stage === "number" ? job.stage : 1;
const progress = job.progress || job.status || "Running…";
// Source classification keyed off the relay-side job.kind
// string (stored when discovery first adds the entry). The
// older heuristic — checking job.input.transcribe_backend —
// wrongly classified Recap-submitted summarize-url jobs as
// "Test run" because the summarize-url route ALSO stamps
// transcribe_backend / analyze_backend in metadata for its
// own bookkeeping.
const kind = job.kind || "";
const fromTestRun = kind === "admin-test-run";
const sourceLabel = kind === "summarize-url"
? "Recap (summarize)"
: kind === "transcribe-url"
? "Recap (transcribe)"
: fromTestRun
? "Test run"
: (kind || "unknown");
const sourceCls = fromTestRun ? "ic-source ic-source-testrun" : "ic-source";
const elapsedSec = job.startedAt
? Math.max(0, Math.floor((Date.now() - job.startedAt) / 1000))
: 0;
const elapsedFmt = elapsedSec < 60
? elapsedSec + "s"
: Math.floor(elapsedSec / 60) + "m " + (elapsedSec % 60) + "s";
return (
'<div class="ic-jobrow">' +
'<div class="ic-jobrow-header">' +
'<span class="ic-jobid">job ' + esc(job.jobIdShort || id.slice(0, 8)) + '</span>' +
'<div style="display:flex; gap:10px; align-items:center;">' +
'<span class="ic-elapsed">' + esc(elapsedFmt) + '</span>' +
'<span class="' + sourceCls + '">' + esc(sourceLabel) + '</span>' +
'</div>' +
'</div>' +
renderBreadcrumb(stage, progress) +
'</div>'
);
}).join("");
const countLabel = ids.length === 1
? '<span class="ic-count">1</span> active job'
: '<span class="ic-count">' + ids.length + '</span> active jobs';
return (
'<div class="inflight-callout">' +
'<div class="ic-header">⟳ ' + countLabel + '</div>' +
cards +
'</div>'
);
}
// ── Test-run panel ─────────────────────────────────────────
// Lets the operator queue a single test run with explicit
// backend/model overrides, or fire a 6-permutation benchmark
// suite (sequential — TX-sharing optimization for 0.2.26). Each
// run lands as a row in the Jobs table below; benchmark rows
// share a batch_id you can filter on.
function renderTestRunPanel() {
const tr = state.testRun;
const models = [
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite",
"gemini-2.5-pro",
"gemini-2.5-flash",
];
const txModelOpts = models.map((m) =>
'<option value="' + esc(m) + '"' + (tr.txModel === m ? " selected" : "") + '>' + esc(m) + '</option>'
).join("");
const anModelOpts = models.map((m) =>
'<option value="' + esc(m) + '"' + (tr.anModel === m ? " selected" : "") + '>' + esc(m) + '</option>'
).join("");
const isRunning = state.testRunStatus === "running";
const canRerun = !!state.testRunLastInputs;
// Suite size + blurb adapt to the URL: YouTube gets the 6
// Suite is currently 6 audio permutations regardless of URL —
// captions perms (7+8) are temporarily disabled pending a fix
// for the yt-dlp fetch reliability issue (see defaultPermutations
// for the disable comment). Will go back to 8 for YouTube once
// captions are reliable again.
const suiteCount = 6;
const suiteBlurb =
"Benchmark suite runs " + suiteCount + " audio permutations. Paired permutations (TX-share pairs) fire concurrently and reuse a single transcribe call, so total wall time is roughly (slowest_phase) × phase_count instead of (sum_of_all). Captions permutations are temporarily disabled pending a yt-dlp reliability fix.";
// Status line in the test-run panel. The detailed pizza-tracker
// breadcrumb used to live here for single-perm runs but it now
// renders in the dedicated "Active jobs" callout above the test-
// run panel (renderInFlightJobsCallout) — moved out so it's the
// first thing the operator sees on Jobs-tab entry regardless of
// whether the job came from this panel or from a Recap submit.
// What's left here is the lightweight text indicator that still
// earns its keep for multi-perm SUITE runs (shows N/M progress)
// and for the error / done terminal states.
const statusLine =
state.testRunStatus === "running"
? ('<div style="margin-top:10px; color: var(--accent); font-size:12px;">' +
'⟳ ' + esc(state.testRunMessage || "Running…") +
(state.testRunSuiteTotal > 1
? ' <span class="dim">(' + state.testRunSuiteIdx + ' / ' + state.testRunSuiteTotal + ')</span>'
: ' <span class="dim">— see breadcrumb above</span>') +
'</div>')
: state.testRunStatus === "error"
? '<div style="margin-top:10px; color: var(--bad); font-size:12px;">⚠ ' + esc(state.testRunMessage) + '</div>'
: state.testRunStatus === "done"
? '<div style="margin-top:10px; color: var(--good); font-size:12px;">✓ ' + esc(state.testRunMessage) + '</div>'
: '';
return `
<div style="background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin: 16px 0 24px;">
<div style="font-size: 13px; font-weight: 700; color: var(--fg); margin-bottom: 12px;">
Test run
<span class="dim" style="font-weight: 400; margin-left: 8px; font-size: 11px;">
Submit a video/podcast URL directly to the relay for benchmarking. Results land in the table below.
</span>
</div>
<div style="display: flex; gap: 10px; align-items: end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 320px;">
<label class="tr-label">Media URL</label>
<input type="text" class="tr-input"
placeholder="https://www.youtube.com/watch?v=… or podcast .mp3 URL"
value="${esc(tr.url)}"
oninput="state.testRun.url = this.value"
${isRunning ? "disabled" : ""} />
</div>
<div>
<label class="tr-label">Captions</label>
<div class="tr-toggle">
<button class="${tr.captionsMode === 'skip' ? 'active' : ''}" onclick="setTestRunCaptions('skip')" ${isRunning ? "disabled" : ""}>Skip</button>
<button class="${tr.captionsMode === 'use' ? 'active' : ''}" onclick="setTestRunCaptions('use')" ${isRunning ? "disabled" : ""} title="YouTube only — fetches captions instead of transcribing audio">Use (YT only)</button>
</div>
</div>
<div>
<label class="tr-label">Transcribe</label>
<div class="tr-toggle">
<button class="${tr.txBackend === 'gemini' ? 'active' : ''}" onclick="setTestRunBackend('tx','gemini')" ${(isRunning || tr.captionsMode === 'use') ? "disabled" : ""}>Gemini</button>
<button class="${tr.txBackend === 'hardware' ? 'active' : ''}" onclick="setTestRunBackend('tx','hardware')" ${(isRunning || tr.captionsMode === 'use') ? "disabled" : ""}>Hardware</button>
</div>
</div>
<div>
<label class="tr-label">TX model</label>
<select class="tr-input" oninput="state.testRun.txModel = this.value"
${(tr.txBackend === 'hardware' || isRunning || tr.captionsMode === 'use') ? "disabled" : ""}>${txModelOpts}</select>
</div>
<div>
<label class="tr-label">Analyze</label>
<div class="tr-toggle">
<button class="${tr.anBackend === 'gemini' ? 'active' : ''}" onclick="setTestRunBackend('an','gemini')" ${isRunning ? "disabled" : ""}>Gemini</button>
<button class="${tr.anBackend === 'hardware' ? 'active' : ''}" onclick="setTestRunBackend('an','hardware')" ${isRunning ? "disabled" : ""}>Hardware</button>
</div>
</div>
<div>
<label class="tr-label">AN model</label>
<select class="tr-input" oninput="state.testRun.anModel = this.value"
${tr.anBackend === 'hardware' || isRunning ? "disabled" : ""}>${anModelOpts}</select>
</div>
</div>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap;">
<button class="tr-btn tr-btn-primary" onclick="runTestRunSingle()" ${isRunning ? "disabled" : ""}>Run single</button>
<button class="tr-btn" onclick="runTestRunSuite()" ${isRunning ? "disabled" : ""} title="${esc(suiteBlurb)}">Run benchmark suite (${suiteCount} runs)</button>
<button class="tr-btn" onclick="rerunLastTestRun()" ${(!canRerun || isRunning) ? "disabled" : ""}>Rerun last</button>
</div>
${statusLine}
</div>
`;
}
function setTestRunBackend(which, backend) {
if (which === "tx") state.testRun.txBackend = backend;
else state.testRun.anBackend = backend;
render();
}
function setTestRunCaptions(mode) {
state.testRun.captionsMode = mode;
render();
}
// POST a single test-run + return the job_id (used by both
// single-run and suite flows). Throws on network or HTTP error.
async function submitTestRun(input) {
const r = await fetch("/admin/test-run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!r.ok) throw new Error("HTTP " + r.status + " on /admin/test-run");
const env = await r.json();
return env.result || env;
}
// Poll /admin/jobs/:id until the job hits a terminal state.
// 2s cadence so the operator sees stage-by-stage progress in the
// pizza-tracker breadcrumb above the test-run panel without
// burning excessive HTTP requests. The activeJobs map is updated
// on every tick so the synthetic table row + breadcrumb refresh
// in lockstep. Returns the final job record when complete/failed;
// throws on timeout.
async function pollTestRunJob(jobId, onProgress) {
const POLL_INTERVAL_MS = 2000;
const MAX_WAIT_MS = 30 * 60 * 1000;
const deadline = Date.now() + MAX_WAIT_MS;
let lastProgress = "";
while (true) {
if (Date.now() > deadline) throw new Error("Test run did not complete within 30 min");
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
const r = await fetch("/admin/jobs?id=" + encodeURIComponent(jobId));
if (!r.ok) continue;
const env = await r.json();
// /admin/jobs returns { entries: [...all jobs] } — find by id.
const entries = Array.isArray(env.entries) ? env.entries : (Array.isArray(env) ? env : []);
const job = entries.find((j) => j.id === jobId) || entries.find((j) => j.id?.startsWith(jobId));
if (!job) continue;
if (job.progress && job.progress !== lastProgress) {
lastProgress = job.progress;
if (onProgress) onProgress(job.progress);
}
// Update the activeJobs entry so the table's synthetic row
// and the breadcrumb stay in sync with the real job state.
const aj = state.activeJobs[jobId];
if (aj) {
aj.status = job.status;
aj.progress = job.progress || aj.progress;
aj.stage = parseStageFromProgress(aj.progress, job.status);
// Lightweight refresh: updates the global pill + (when on
// Jobs tab) the pending row's progress text. Avoids
// clobbering the Settings form if the operator is editing
// there during a tracked test-run / discovery job.
renderLightweight();
}
// Refresh the Jobs table too, so when audit rows land in the
// log the real row replaces the synthetic placeholder. Only
// when on Jobs tab — otherwise the fetch wastes cycles AND
// the resulting state.jobsData mutation triggers a body
// re-render the user can't see anyway.
if (state.activeTab === "jobs") {
loadJobs();
}
if (job.status === "complete" || job.status === "failed") return job;
}
}
// Parse the relay's progress message into a 4-stage breadcrumb
// position. Keyword-based: the relay's setProgress() calls use
// consistent verbs ("downloading…", "transcribing…", "analyze
// window N/M…") so a simple substring check is reliable. Returns
// -1 for failed jobs so the breadcrumb can flip the active stage
// to red. Defaults to stage 1 (Downloading) when the message is
// null/empty.
function parseStageFromProgress(msg, status) {
if (status === "complete") return 4;
if (status === "failed") return -1;
const m = (msg || "").toLowerCase();
if (m.includes("analyz") || m.includes("window")) return 3;
if (m.includes("transcrib") || m.includes("captions")) return 2;
if (m.includes("download") || m.includes("fetch")) return 1;
return 1;
}
// Render the 4-stage pizza-tracker breadcrumb for a single
// in-flight job. Active stage pulses; completed stages are
// solid; future stages are dim. On failure, the active stage
// flips red. Used only for single-perm runs — suites get the
// existing "N of M perms complete" indicator which is more
// useful when multiple jobs are running in parallel.
function renderBreadcrumb(stage, progressMsg) {
const labels = ["Downloading", "Transcribing", "Analyzing", "Done"];
const isError = stage === -1;
const cells = labels.map((label, i) => {
const id = i + 1;
let dotColor = "var(--fg-faint)";
let textColor = "var(--fg-faint)";
let pulse = "";
if (isError && i === 0) { dotColor = "var(--bad)"; textColor = "var(--bad)"; }
else if (!isError && id < stage) { dotColor = "var(--good)"; textColor = "var(--fg-dim)"; }
else if (!isError && id === stage) {
dotColor = stage === 4 ? "var(--good)" : "var(--accent)";
textColor = "var(--fg)";
if (stage !== 4) pulse = "animation: breadcrumb-pulse 1.2s infinite;";
}
const dot = '<span style="color:' + dotColor + ';font-size:14px;' + pulse + '">●</span>';
const text = '<span style="color:' + textColor + ';font-size:11px;font-weight:600;">' + esc(label) + '</span>';
const arrow = i < labels.length - 1
? ' <span class="dim" style="margin:0 6px;">→</span> '
: '';
return dot + ' ' + text + arrow;
}).join("");
const msg = progressMsg
? '<div style="margin-top:6px; font-size:11px; color: var(--fg-dim);">' + esc(progressMsg) + '</div>'
: "";
return '<div style="display:flex;align-items:center;flex-wrap:wrap;">' + cells + '</div>' + msg;
}
async function runTestRunSingle() {
const tr = state.testRun;
if (!tr.url) {
state.testRunStatus = "error";
state.testRunMessage = "Please enter a media URL";
render();
return;
}
const useCaptions = tr.captionsMode === "use";
const inputs = useCaptions
? {
media_url: tr.url,
captions_mode: "use",
analyze_backend: tr.anBackend,
analyze_model: tr.anBackend === "gemini" ? tr.anModel : undefined,
}
: {
media_url: tr.url,
transcribe_backend: tr.txBackend,
transcribe_model: tr.txBackend === "gemini" ? tr.txModel : undefined,
analyze_backend: tr.anBackend,
analyze_model: tr.anBackend === "gemini" ? tr.anModel : undefined,
};
state.testRunLastInputs = { type: "single", inputs: [inputs] };
await runPermutations([inputs], "single run");
}
// The benchmark-suite permutation set. 6 audio-transcribe
// permutations always; 2 captions-based permutations append
// when the URL looks like YouTube (captions are YouTube-only).
// TX-sharing optimization for pairs (4+5, 1+6, 7+8) is the next
// optimization — currently all run sequentially.
function defaultPermutations(url) {
const isYT = /youtube\.com|youtu\.be/i.test(url || "");
const base = [
{ label: "1. TX gemini-3.1-flash-lite → AN gemini-3.1-pro-preview",
transcribe_backend: "gemini", transcribe_model: "gemini-3.1-flash-lite",
analyze_backend: "gemini", analyze_model: "gemini-3.1-pro-preview" },
{ label: "2. TX gemini-3-flash-preview → AN gemini-3.1-flash-lite",
transcribe_backend: "gemini", transcribe_model: "gemini-3-flash-preview",
analyze_backend: "gemini", analyze_model: "gemini-3.1-flash-lite" },
{ label: "3. TX gemini-2.5-flash → AN gemini-2.5-pro",
transcribe_backend: "gemini", transcribe_model: "gemini-2.5-flash",
analyze_backend: "gemini", analyze_model: "gemini-2.5-pro" },
{ label: "4. TX hardware → AN hardware",
transcribe_backend: "hardware",
analyze_backend: "hardware" },
{ label: "5. TX hardware → AN gemini-3.1-flash-lite",
transcribe_backend: "hardware",
analyze_backend: "gemini", analyze_model: "gemini-3.1-flash-lite" },
{ label: "6. TX gemini-3.1-flash-lite → AN hardware",
transcribe_backend: "gemini", transcribe_model: "gemini-3.1-flash-lite",
analyze_backend: "hardware" },
];
// Captions permutations (perms 7+8) are temporarily disabled
// pending a fix for the yt-dlp fetch reliability issues —
// observed on multiple videos where yt-dlp claims no .vtt
// subtitle file exists even when the video clearly has captions
// on YouTube. Re-enabled once the captions path is reliable;
// for now the suite runs 6 audio permutations regardless of
// whether the URL is YouTube or podcast.
return base;
}
async function runTestRunSuite() {
const tr = state.testRun;
if (!tr.url) {
state.testRunStatus = "error";
state.testRunMessage = "Please enter a media URL";
render();
return;
}
const perms = defaultPermutations(tr.url);
const permutations = perms.map((p) => ({
type: tr.url.includes("youtu") ? "youtube" : "podcast",
captions_mode: p.captions_mode,
transcribe_backend: p.transcribe_backend,
transcribe_model: p.transcribe_model,
analyze_backend: p.analyze_backend,
analyze_model: p.analyze_model,
title: p.label,
}));
state.testRunLastInputs = { type: "suite", mediaUrl: tr.url, permutations };
await runSuiteServerSide({ mediaUrl: tr.url, permutations });
}
// Server-side suite runner — POSTs once to /admin/test-run-suite,
// gets back the batch_id + list of pre-minted job_ids, then
// polls /admin/jobs-history?batch_id=X to track progress. Crucial
// property: the server runs the suite regardless of whether
// this browser tab stays open. We persist the active batch_id
// in localStorage so a refresh / re-open auto-resumes polling
// instead of orphaning the run.
const ACTIVE_BATCH_KEY = "relay.dashboard.activeBatchId";
async function runSuiteServerSide({ mediaUrl, permutations }) {
state.testRunStatus = "running";
state.testRunMessage = "submitting suite to relay…";
state.testRunSuiteIdx = 0;
state.testRunSuiteTotal = permutations.length;
render();
let batchId;
try {
const r = await fetch("/admin/test-run-suite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ media_url: mediaUrl, permutations }),
});
if (!r.ok) throw new Error("HTTP " + r.status + " on /admin/test-run-suite");
const env = await r.json();
const result = env.result || env;
batchId = result.batch_id;
} catch (err) {
state.testRunStatus = "error";
state.testRunMessage = "suite submit failed: " + (err?.message || err);
render();
return;
}
state.testRunBatchId = batchId;
try { localStorage.setItem(ACTIVE_BATCH_KEY, batchId); } catch {}
// Filter the Jobs table to this batch so rows show up as they
// land — no need for the operator to set the filter manually.
state.jobsFilters.batch_id = batchId;
state.jobsPage = 1;
loadJobs();
await trackBatchProgress(batchId, permutations.length);
}
// Poll /admin/jobs-history filtered to a batch_id until every
// job in the batch has reached a terminal status. Returns when
// all jobs are complete or failed. Cancellation: clears the
// localStorage active-batch key on done so a refresh doesn't
// re-attach.
async function trackBatchProgress(batchId, expectedTotal) {
const POLL_MS = 5000;
const MAX_WAIT_MS = 4 * 60 * 60 * 1000; // 4h safety cap
const deadline = Date.now() + MAX_WAIT_MS;
while (true) {
if (Date.now() > deadline) {
state.testRunStatus = "error";
state.testRunMessage = "suite tracking timed out (4h) — relay may still be running";
render();
return;
}
await new Promise((res) => setTimeout(res, POLL_MS));
let jobs;
try {
const qs = new URLSearchParams({
days: "1",
batch_id: batchId,
page_size: "100",
});
const r = await fetch("/admin/jobs-history?" + qs.toString());
if (!r.ok) continue;
const d = await r.json();
jobs = d.jobs || [];
} catch {
continue;
}
// Refresh the Jobs table (the operator may have just opened
// the tab and want to see live updates).
loadJobs();
const done = jobs.filter(
(j) => j.overall_status === "success" || j.overall_status === "partial" || j.overall_status === "failed"
).length;
state.testRunSuiteIdx = done;
state.testRunSuiteTotal = Math.max(expectedTotal, jobs.length);
state.testRunMessage =
"batch " + batchId.slice(0, 8) + ": " + done + " of " + state.testRunSuiteTotal + " complete";
render();
if (done >= expectedTotal && jobs.length >= expectedTotal) {
state.testRunStatus = "done";
state.testRunMessage =
"benchmark suite complete (" + done + " permutations)";
try { localStorage.removeItem(ACTIVE_BATCH_KEY); } catch {}
render();
return;
}
}
}
// On Jobs-tab open, check localStorage for an active batch left
// running from a previous browser session and resume tracking
// its progress automatically.
// Survey the relay's in-memory job list for any admin-test-run
// jobs still running, and reattach the pizza-tracker / synthetic
// pending row to each of them. Called at boot so hard-refresh /
// multi-device viewing doesn't lose track of a job in flight.
// Idempotent — if state.activeJobs already has a job, this won't
// add a duplicate poll loop (we check before kicking off
// pollTestRunJob).
async function tryResumeActiveSingleRuns() {
let envelope;
try {
const r = await fetch("/admin/jobs");
if (!r.ok) return;
envelope = await r.json();
} catch { return; }
const jobs = Array.isArray(envelope.entries) ? envelope.entries
: Array.isArray(envelope) ? envelope : [];
const active = jobs.filter((j) =>
(j.status === "running" || j.status === "queued") &&
(j.kind === "admin-test-run" || j.kind === "summarize-url" || j.kind === "transcribe-url")
);
if (active.length === 0) return;
// Set testRunStatus so the existing pizza-tracker render path
// engages. testRunSuiteTotal stays at 1 so the breadcrumb (not
// the suite "N of M" text) is shown.
state.testRunStatus = "running";
state.testRunMessage = "resuming " + active.length + " in-flight run" + (active.length === 1 ? "" : "s");
state.testRunSuiteIdx = 0;
state.testRunSuiteTotal = active.length;
for (const job of active) {
if (state.activeJobs[job.id]) continue; // already tracking
state.activeJobs[job.id] = {
input: job.metadata || {},
jobIdShort: job.id.slice(0, 8),
startedAt: job.started_at || Date.now(),
status: job.status,
progress: job.progress || "resumed — waiting for next relay update…",
stage: parseStageFromProgress(job.progress, job.status),
};
// Kick off the poll for this job. Same poll function the
// foreground run uses; when it returns terminal, the active
// entry is dropped via the same finally block (we mirror
// that logic here in a lightweight wrapper).
(async (jobId) => {
try {
await pollTestRunJob(jobId, (msg) => {
state.testRunMessage = msg;
renderLightweight();
});
} catch (err) {
console.warn("resume-poll for", jobId.slice(0, 8), "failed:", err?.message || err);
} finally {
delete state.activeJobs[jobId];
state.testRunSuiteIdx += 1;
if (Object.keys(state.activeJobs).length === 0) {
state.testRunStatus = "done";
state.testRunMessage = "in-flight run(s) completed";
}
renderLightweight();
// Only refresh Jobs data when the operator can actually
// see it. Otherwise we just waste a fetch and trigger a
// render that might clobber the Settings form.
if (state.activeTab === "jobs") {
loadJobs();
}
}
})(job.id);
}
renderLightweight();
}
// Periodic in-flight-job discovery while the Jobs tab is open.
// Polls /admin/jobs every 5s; for any in-flight job (admin-test,
// summarize-url, transcribe-url) NOT yet being tracked in
// state.activeJobs, attach a poll loop so a synthetic pending
// row appears in the table immediately. This is how Recap-app
// submissions reach the relay's Jobs table without the operator
// having to refresh — the relay accepted the POST, the dashboard
// discovers it on the next tick, the row appears and updates as
// the pipeline runs. Cancelled when the operator leaves the Jobs
// tab (no point burning polls on a hidden tab).
let _discoverPollHandle = null;
// Runs the discovery body once. Extracted from the interval so we
// can ALSO fire it immediately on Jobs-tab entry (instead of
// waiting up to 5s for the first interval tick). Critical when
// a Recap-submitted job is short (~2min) — without this, a job
// can start and finish during the wait window and the operator
// never sees the live pending row at all. Same body as the
// setInterval handler below; kept DRY by reference.
async function runInFlightDiscoveryOnce() {
try {
const r = await fetch("/admin/jobs");
if (!r.ok) return;
const env = await r.json();
const jobs = Array.isArray(env.entries) ? env.entries
: Array.isArray(env) ? env : [];
let added = 0;
for (const job of jobs) {
if (job.status !== "running" && job.status !== "queued") continue;
const okKind = job.kind === "admin-test-run" ||
job.kind === "summarize-url" ||
job.kind === "transcribe-url";
if (!okKind) continue;
if (state.activeJobs[job.id]) continue;
state.activeJobs[job.id] = {
// Store the job's relay-side kind so the dedicated
// in-flight callout can label the source correctly
// (test-run vs Recap vs transcribe-only). Previously the
// callout inferred source from input.transcribe_backend,
// but summarize-url jobs also stamp that field, so every
// Recap submission was mislabeled as "Test run".
kind: job.kind,
input: job.metadata || {},
jobIdShort: job.id.slice(0, 8),
startedAt: job.started_at || Date.now(),
status: job.status,
progress: job.progress || "discovered — awaiting next update…",
stage: parseStageFromProgress(job.progress, job.status),
};
added += 1;
(async (jobId) => {
try {
await pollTestRunJob(jobId, (msg) => {
if (Object.keys(state.activeJobs).length === 1) {
state.testRunMessage = msg;
}
renderLightweight();
});
} catch {}
finally {
delete state.activeJobs[jobId];
renderLightweight();
// Only refresh the historical jobs table when the
// operator is actually looking at it — otherwise
// skip the fetch + render to avoid clobbering Settings.
if (state.activeTab === "jobs") {
loadJobs();
}
}
})(job.id);
}
// Bookkeeping for the diagnostic indicator at the top of
// the Jobs tab. Updated even when we found zero new jobs so
// the operator can see at a glance whether polling is alive
// (lastPollAt updates every 3s) vs stuck (no recent value).
state.discoveryStats = {
lastPollAt: Date.now(),
lastPollFoundRunning: jobs.filter((j) =>
(j.status === "running" || j.status === "queued") &&
(j.kind === "admin-test-run" || j.kind === "summarize-url" || j.kind === "transcribe-url")
).length,
totalPolls: (state.discoveryStats?.totalPolls || 0) + 1,
lastError: null,
// Stash the FULL raw response (truncated to first 5 entries
// to keep state size sane) so the operator can copy it for
// diagnostics. When the summary shows {unknown: N} for kind
// and status, the raw response will reveal whether the
// entries are truly empty objects, have unexpected field
// names, or there's something else going on.
rawResponseSample: {
envelope_top_level_keys: Object.keys(env || {}),
first_5_entries: jobs.slice(0, 5),
},
// Store the raw response so the operator can pop a "view
// last response" button to see exactly what /admin/jobs
// returned. Helps diagnose "discovery shows 0 running but
// I submitted a job" — is it because the response is
// empty, because the job has a different kind/status, or
// because of a routing/cookie issue?
lastResponseSummary: {
total_entries: jobs.length,
kinds: jobs.reduce((acc, j) => {
const k = j.kind || "unknown";
acc[k] = (acc[k] || 0) + 1;
return acc;
}, {}),
statuses: jobs.reduce((acc, j) => {
const s = j.status || "unknown";
acc[s] = (acc[s] || 0) + 1;
return acc;
}, {}),
// Newest 5 jobs (regardless of status) so the operator
// can see if their submission appears AT ALL.
recent_jobs: jobs
.slice()
.sort((a, b) => (b.updated_at || b.started_at || 0) - (a.updated_at || a.started_at || 0))
.slice(0, 5)
.map((j) => ({
id_short: (j.id || "").slice(0, 8),
kind: j.kind,
status: j.status,
progress: j.progress,
started_at: j.started_at,
age_sec: j.started_at ? Math.round((Date.now() - j.started_at) / 1000) : null,
})),
},
};
if (added > 0) {
if (Object.keys(state.activeJobs).length === 1 && state.testRunStatus !== "running") {
state.testRunStatus = "running";
state.testRunSuiteTotal = 1;
state.testRunSuiteIdx = 0;
state.testRunMessage = "in-flight job discovered (from Recap or another client)";
}
}
// Lightweight refresh so the diagnostic stats / elapsed
// clocks update without clobbering the Settings form when
// the operator is mid-edit. See renderLightweight() docs.
renderLightweight();
} catch (err) {
state.discoveryStats = {
...(state.discoveryStats || {}),
lastPollAt: Date.now(),
lastError: err?.message || String(err),
totalPolls: (state.discoveryStats?.totalPolls || 0) + 1,
};
renderLightweight();
}
}
function startInFlightDiscoveryPoll() {
if (_discoverPollHandle) return;
// Fire immediately so the operator doesn't wait 5s for the
// first poll. Then continue at 5s intervals. The tab-gate has
// been removed (was: `if (state.activeTab !== "jobs") return`)
// — discovery now runs even from Overview/Settings so when the
// operator finally clicks into Jobs, the pending row is already
// in state.activeJobs and renders on first paint. Cost: one
// small JSON fetch every 5s, regardless of which tab is open.
runInFlightDiscoveryOnce();
// 3s instead of the old 5s so a fast 90s job is more likely to
// be caught mid-flight rather than missed entirely. Body is the
// shared helper above so we don't carry two copies of the same
// logic.
_discoverPollHandle = setInterval(runInFlightDiscoveryOnce, 3000);
}
// Previously stopped when leaving the Jobs tab. Now a no-op kept
// for call-site compatibility — discovery runs continuously while
// the operator is authed. Discovery is cheap (one small GET every
// 3s) and the cost of MISSING a Recap-submitted job is much higher
// than the cost of a few extra polls per minute.
function stopInFlightDiscoveryPoll() {
if (_discoverPollHandle) {
clearInterval(_discoverPollHandle);
_discoverPollHandle = null;
}
}
function tryResumeActiveBatch() {
let batchId;
try { batchId = localStorage.getItem(ACTIVE_BATCH_KEY); } catch {}
if (!batchId) return;
// Don't double-attach if we're already running.
if (state.testRunStatus === "running") return;
state.testRunBatchId = batchId;
state.testRunStatus = "running";
state.testRunMessage = "resuming progress for batch " + batchId.slice(0, 8) + "…";
state.jobsFilters.batch_id = batchId;
render();
// Use a defensive expectedTotal — the poll will discover the
// actual count from the first jobs-history response.
trackBatchProgress(batchId, 1).catch(() => {});
}
async function rerunLastTestRun() {
if (!state.testRunLastInputs) return;
const last = state.testRunLastInputs;
if (last.type === "suite") {
// Re-fire the server-side suite with a fresh batch_id (the
// server mints it for us; we just resubmit the permutations
// and media_url).
await runSuiteServerSide({
mediaUrl: last.mediaUrl,
permutations: last.permutations,
});
} else {
await runPermutations(last.inputs, "rerun (single)");
}
}
// Group permutations into phases. Permutations within a phase
// are fired CONCURRENTLY; phases run SEQUENTIALLY. Pairs that
// share TX configuration get grouped so the relay's TX-share
// cache lets the second member skip its own transcribe step.
// Groups by a tx-fingerprint: same fingerprint = same phase.
function txFingerprint(p) {
if (p.captions_mode === "use") return "captions:" + (p.media_url || "");
return "tx:" + (p.transcribe_backend || "") + ":" + (p.transcribe_model || "") + ":" + (p.media_url || "");
}
function groupIntoPhases(inputs) {
const phases = [];
const seen = new Map();
for (const p of inputs) {
const fp = txFingerprint(p);
if (seen.has(fp)) {
phases[seen.get(fp)].push(p);
} else {
seen.set(fp, phases.length);
phases.push([p]);
}
}
return phases;
}
// Permutation runner. Groups by TX fingerprint into phases:
// each phase fires its permutations in parallel (the relay's
// TX-share cache ensures the underlying transcribe runs only
// once per phase). Phases themselves run sequentially. On any
// single permutation failure the suite continues with the next
// permutation — matches the "don't abort on error" rule.
async function runPermutations(inputs, label) {
state.testRunStatus = "running";
state.testRunMessage = "starting " + label + "…";
state.testRunSuiteIdx = 0;
state.testRunSuiteTotal = inputs.length;
render();
const phases = groupIntoPhases(inputs);
let completed = 0;
for (let phaseIdx = 0; phaseIdx < phases.length; phaseIdx++) {
const phase = phases[phaseIdx];
state.testRunMessage =
phase.length > 1
? "phase " + (phaseIdx + 1) + "/" + phases.length + ": " + phase.length + " permutations in parallel (shared TX)"
: (inputs.length > 1
? "phase " + (phaseIdx + 1) + "/" + phases.length + ": " + (phase[0].title || "")
: "running…");
render();
// Fire every permutation in this phase concurrently. Each
// submits, polls to completion, then resolves. We collect
// results via Promise.allSettled so a single failure doesn't
// abort the others.
await Promise.allSettled(
phase.map(async (input) => {
let submittedJobId = null;
try {
const sub = await submitTestRun(input);
submittedJobId = sub.job_id;
// Register the job in activeJobs so the synthetic row
// appears in the Jobs table immediately AND the
// breadcrumb has data to render. Drained at the end of
// this block when the poll terminates.
state.activeJobs[sub.job_id] = {
input,
jobIdShort: sub.job_id.slice(0, 8),
startedAt: Date.now(),
status: "queued",
progress: "submitted — waiting for relay…",
stage: 1,
};
render();
// Refresh table right away so the synthetic row lands
// alongside any others without waiting for the first
// 2s poll tick.
loadJobs();
await pollTestRunJob(sub.job_id, (msg) => {
state.testRunMessage =
inputs.length > 1
? "phase " + (phaseIdx + 1) + "/" + phases.length + " — " + (input.title || "perm") + ": " + msg
: msg;
render();
});
} catch (err) {
console.warn("permutation '" + (input.title || "?") + "' failed:", err);
} finally {
if (submittedJobId) {
delete state.activeJobs[submittedJobId];
}
completed += 1;
state.testRunSuiteIdx = completed;
render();
// Final refresh so the real row replaces the (now-removed)
// synthetic one without waiting for the next user action.
loadJobs();
}
})
);
}
state.testRunStatus = "done";
state.testRunMessage =
inputs.length > 1
? "benchmark suite complete (" + inputs.length + " permutations across " + phases.length + " phases)"
: "test run complete";
render();
}
// Small formatting helpers used by the Jobs tab. tile() is shared
// with the Overview tab — defined later in this file.
function fmtDate(ms) {
if (!ms) return "—";
const d = new Date(ms);
return d.toISOString().replace("T", " ").slice(0, 16);
}
function fmtDuration(ms) {
if (!ms || ms < 0) return "—";
const s = Math.round(ms / 1000);
if (s < 60) return s + "s";
if (s < 3600) return Math.floor(s / 60) + "m " + (s % 60) + "s";
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h + "h " + m + "m";
}
// Always render rate metrics (ms/min, ms/MB, ms/MB-of-download)
// as seconds with one decimal. Operator wants a uniform-shape
// value in every cell so columns visually align; sub-second
// values just appear as e.g. "0.4s" rather than the mixed
// "412ms" we used to emit. Returns the cell HTML including the
// dim "—" fallback when null/NaN.
function fmtMsOrDash(ms) {
if (ms == null || !Number.isFinite(ms)) return '<span class="dim">—</span>';
return (ms / 1000).toFixed(1) + "s";
}
// Tile-level label: same value shape but a brief suffix so the
// operator dashboard's summary tiles read naturally.
function fmtMsPerMin(ms) {
if (ms == null || !Number.isFinite(ms)) return "—";
return (ms / 1000).toFixed(1) + "s / min";
}
function fmtPct(x) {
if (x == null || !Number.isFinite(x)) return "—";
return (x * 100).toFixed(1) + "%";
}
function fmtUsd(x) {
if (x == null || !Number.isFinite(x)) return "—";
if (x < 0.01) return "$" + (x * 100).toFixed(3) + "¢";
return "$" + x.toFixed(2);
}
// BTCPay setup widget — three states:
// 1. Already connected → green "✓ Connected" with Reconnect option
// 2. Local BTCPay detected → green "✓ BTCPay detected locally → Connect" (1 click)
// 3. No local detection → fallback "Connect BTCPay" with URL prompt
let _btcpayDiscoveredUrl = null;
async function renderBtcpaySetupArea() {
const area = document.getElementById("btcpay-setup-area");
if (!area) return;
let status, discovery;
try {
const [s, d] = await Promise.all([
fetch("/admin/btcpay/status").then((r) => (r.ok ? r.json() : null)),
fetch("/admin/btcpay/discover").then((r) => (r.ok ? r.json() : null)),
]);
status = s;
discovery = d;
} catch {
return;
}
if (status?.configured) {
// Cache the discovered URL even when already connected, so
// Reconnect can fire the one-click flow instead of falling
// back to the URL prompt.
if (discovery?.found && discovery?.browser_url) {
_btcpayDiscoveredUrl = discovery.browser_url;
}
area.innerHTML =
'<div style="display:flex;align-items:center;gap:10px;padding:8px 14px;background:rgba(134,239,172,0.08);border:1px solid rgba(134,239,172,0.30);border-radius:8px;font-size:12px;color:var(--good);">' +
'<span style="font-weight:600;">✓ BTCPay connected</span>' +
'<span style="color:var(--fg-dim);font-weight:400;">' +
esc(status.base_url || "") + ' &middot; store ' + esc((status.store_id || "").slice(0, 8)) + '…' +
'</span>' +
'<button onclick="rescanInvoices()" style="margin-left:auto;background:transparent;border:1px solid var(--line-2);color:var(--fg-dim);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;" title="Scan BTCPay for paid invoices that didn\'t credit. Use after fixing a broken webhook.">Rescan paid</button>' +
'<button onclick="reconnectBtcpay()" style="background:transparent;border:1px solid var(--line-2);color:var(--fg-dim);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;">Reconnect</button>' +
'</div>';
return;
}
if (discovery?.found && discovery?.browser_url) {
_btcpayDiscoveredUrl = discovery.browser_url;
area.innerHTML =
'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:rgba(99,102,241,0.10);border:1px solid rgba(99,102,241,0.35);border-radius:8px;font-size:12px;color:var(--fg);">' +
'<span style="font-weight:600;color:var(--accent);">⚡ BTCPay detected locally</span>' +
'<span style="color:var(--fg-dim);font-weight:400;">' +
esc(discovery.browser_url) +
' &middot; click to authorize Recap to issue invoices on your store.' +
'</span>' +
'<button onclick="connectBtcpay(true)" style="margin-left:auto;background:var(--accent);color:var(--bg);border:none;padding:6px 14px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer;">Connect (1 click) →</button>' +
'</div>';
} else {
area.innerHTML =
'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:rgba(99,102,241,0.10);border:1px solid rgba(99,102,241,0.35);border-radius:8px;font-size:12px;color:var(--fg);">' +
'<span style="font-weight:600;color:var(--accent);">⚡ BTCPay not connected</span>' +
'<span style="color:var(--fg-dim);font-weight:400;">Connect a BTCPay store to let users top up their credit balance via Lightning.</span>' +
'<button onclick="connectBtcpay(false)" style="margin-left:auto;background:var(--accent);color:var(--bg);border:none;padding:6px 14px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer;">Connect BTCPay →</button>' +
'</div>';
}
}
async function connectBtcpay(useDiscovered) {
let url = _btcpayDiscoveredUrl;
if (!useDiscovered || !url) {
url = prompt("Your BTCPay base URL (e.g. https://btcpay.keysat.xyz):");
if (!url || !/^https?:\/\//i.test(url)) return;
url = url.trim();
}
try {
const r = await fetch("/admin/btcpay/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ btcpay_url: url }),
});
const data = await r.json();
if (!r.ok || !data.authorize_url) {
alert("Couldn't start setup: " + (data.message || data.error || r.status));
return;
}
// Open BTCPay's authorize page. After the operator approves,
// BTCPay POST-redirects back to /admin/btcpay/callback which
// returns the picker page; that page calls /admin/btcpay/finalize
// to wire everything up.
window.open(data.authorize_url, "_blank");
} catch (err) {
alert("Setup failed: " + err.message);
}
}
function reconnectBtcpay() {
if (!confirm("Reconnect will overwrite the stored BTCPay credentials. Continue?")) return;
connectBtcpay(!!_btcpayDiscoveredUrl);
}
async function rescanInvoices() {
try {
const r = await fetch("/admin/btcpay/rescan-invoices", { method: "POST" });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) {
alert(
"Rescan failed: " + (data.message || data.error || r.status)
);
return;
}
const lines = [
`Credited ${data.credited} invoice(s).`,
`Already-processed: ${data.already_processed}.`,
`Skipped (not recap_credits): ${data.skipped}.`,
];
if (Array.isArray(data.details) && data.details.length > 0) {
lines.push("", "Details:");
for (const d of data.details) {
lines.push(
` ${d.credits} credits → install ${d.install}… (new balance: ${d.new_balance})`
);
}
}
alert(lines.join("\n"));
} catch (err) {
alert("Rescan request failed: " + err.message);
}
}
function marginTile(label, value, cls) {
return '<div class="tile">' +
'<div class="tile-label">' + esc(label) + '</div>' +
'<div class="tile-value ' + esc(cls) + '">' + fmtMoney(value) + '</div>' +
'<div class="tile-sub">' + (value >= 0 ? "Profit after Gemini API cost" : "Loss after Gemini API cost") + '</div>' +
'</div>';
}
function tile(label, value, sub, accent) {
const cls = accent === "money" ? "tile-money" : accent === "good" ? "tile-good" : "";
return '<div class="tile">' +
'<div class="tile-label">' + esc(label) + '</div>' +
'<div class="tile-value ' + cls + '">' + value + '</div>' +
(sub ? '<div class="tile-sub">' + esc(sub) + '</div>' : '') +
'</div>';
}
// ── Users tab ───────────────────────────────────────────────────
// A flat view of the credit ledger: every user/install + their
// current balance, with a per-row "grant free credits" action.
// Balances are COMPUTED server-side (/admin/credits) since the
// ledger stores consumed counters, not a remaining number.
async function loadCredits() {
state.creditsLoading = true;
if (state.activeTab === "users") render();
try {
const r = await fetch("/admin/credits", { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
state.creditsData = Array.isArray(data.rows) ? data.rows : [];
} catch (err) {
state.creditsData = [];
console.warn("loadCredits failed:", err);
}
state.creditsLoading = false;
if (state.activeTab === "users") render();
}
// Credits consumed in the current cycle: Core spends a lifetime
// budget, paid tiers spend a monthly one.
function usedCount(r) {
return (r.tier_snapshot === "pro" || r.tier_snapshot === "max")
? (r.monthly_consumed || 0)
: (r.lifetime_consumed || 0);
}
// null = unlimited tier (no cap). Render it as a word, not a number.
function fmtCredits(v) {
return v == null ? '<span class="dim">Unlimited</span>' : fmtInt(v);
}
const USERS_COLS = [
{ key: "credit_key", label: "Key", val: (r) => r.credit_key },
{ key: "type", label: "Type", val: (r) => r.type },
{ key: "tier_snapshot", label: "Tier", val: (r) => r.tier_snapshot || "core" },
{ key: "remaining", label: "Remaining", num: true, val: (r) => r.remaining == null ? Infinity : r.remaining },
{ key: "purchased", label: "Purchased", num: true, val: (r) => r.purchased || 0 },
{ key: "total", label: "Total", num: true, val: (r) => r.total == null ? Infinity : r.total },
{ key: "used", label: "Used (cycle)", num: true, val: (r) => usedCount(r) },
{ key: "last_active_at", label: "Last active", val: (r) => new Date(r.last_active_at || 0).getTime() },
];
function sortUsers(col) {
const s = state.creditsSort;
if (s.col === col) { s.dir = s.dir === "asc" ? "desc" : "asc"; }
else { s.col = col; s.dir = (col === "credit_key" || col === "type") ? "asc" : "desc"; }
refreshUsersTable();
}
function filterUsers(v) {
state.creditsQuery = v;
refreshUsersTable();
}
// Re-render ONLY the table (sort/filter) so the search box outside
// the wrap keeps focus + caret between keystrokes.
function refreshUsersTable() {
const wrap = document.getElementById("users-table-wrap");
if (wrap) wrap.innerHTML = usersTableHtml();
}
function usersTableHtml() {
const rows = (state.creditsData || []).slice();
const q = (state.creditsQuery || "").trim().toLowerCase();
const filtered = q
? rows.filter((r) => (r.credit_key || "").toLowerCase().includes(q))
: rows;
if (filtered.length === 0) {
return '<div class="empty">' + (q ? "No users match “" + esc(q) + "”." : "No users yet.") + '</div>';
}
const { col, dir } = state.creditsSort;
const colDef = USERS_COLS.find((c) => c.key === col) || USERS_COLS[0];
const mul = dir === "asc" ? 1 : -1;
filtered.sort((a, b) => {
const av = colDef.val(a), bv = colDef.val(b);
if (typeof av === "number" && typeof bv === "number") return (av - bv) * mul;
return String(av).localeCompare(String(bv)) * mul;
});
const thead = USERS_COLS.map((c) => {
const ind = c.key === col ? (dir === "asc" ? " ▲" : " ▼") : "";
return '<th class="' + (c.num ? "num " : "") + '" style="cursor:pointer;" onclick="sortUsers(\'' + c.key + '\')">'
+ esc(c.label) + '<span style="opacity:0.7;font-size:9px;">' + ind + '</span></th>';
}).join("") + '<th>Grant</th>';
const tbody = filtered.map((r) => {
const typeBadge = '<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;'
+ 'background:rgba(165,180,252,0.12);border:1px solid var(--line-2);color:var(--fg-dim);">'
+ esc(r.type) + '</span>';
const grantCell = '<div style="display:flex;gap:5px;align-items:center;">'
+ '<input type="number" min="1" step="1" placeholder="N" class="grant-amt" '
+ 'style="width:62px;padding:4px 6px;font-size:12px;background:var(--bg);border:1px solid var(--line-2);border-radius:5px;color:var(--fg);" />'
+ '<button class="grant-btn" data-key="' + esc(r.credit_key) + '" onclick="grantCredits(this)" '
+ 'style="padding:4px 10px;font-size:11px;background:transparent;border:1px solid var(--line-2);border-radius:5px;color:var(--accent);cursor:pointer;">Grant</button>'
+ '</div>';
return '<tr>'
+ '<td><code title="' + esc(r.credit_key) + '">' + esc(shortId(r.credit_key)) + '</code></td>'
+ '<td>' + typeBadge + '</td>'
+ '<td>' + tierPill(r.tier_snapshot) + '</td>'
+ '<td class="num">' + fmtCredits(r.remaining) + '</td>'
+ '<td class="num">' + fmtInt(r.purchased || 0) + '</td>'
+ '<td class="num">' + fmtCredits(r.total) + '</td>'
+ '<td class="num" title="' + (r.capped === "monthly" ? "monthly allowance" : "lifetime allowance") + '">' + fmtInt(usedCount(r)) + '</td>'
+ '<td><span class="dim">' + esc(fmtTs(r.last_active_at)) + '</span></td>'
+ '<td>' + grantCell + '</td>'
+ '</tr>';
}).join("");
return '<table><thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table>';
}
async function grantCredits(btn) {
const key = btn.dataset.key;
const input = btn.parentElement.querySelector(".grant-amt");
const amount = parseInt(input && input.value, 10);
if (!Number.isInteger(amount) || amount <= 0) {
alert("Enter a positive whole number of credits.");
return;
}
if (!confirm("Grant " + amount + " free credit(s) to " + key + "?\n\nThese never expire and are spent AFTER the user's tier allowance.")) {
return;
}
btn.disabled = true;
try {
const r = await fetch("/admin/credits/grant", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credit_key: key, amount }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || ("HTTP " + r.status));
// Refetch enriched rows so Remaining/Total/Purchased reflect the grant.
await loadCredits();
} catch (err) {
alert("Grant failed: " + (err?.message || err));
btn.disabled = false;
}
}
function renderUsersTab() {
const data = state.creditsData;
let body;
if (data == null) {
body = '<div class="loading">' + (state.creditsLoading ? "Loading users…" : "No data.") + '</div>';
} else {
const counts = data.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
const summary = '<div style="font-size:11px;color:var(--fg-dim);margin-bottom:12px;">'
+ fmtInt(data.length) + ' user(s) — '
+ (counts.cloud || 0) + ' cloud · ' + (counts.license || 0) + ' license · ' + (counts.install || 0) + ' install. '
+ 'Grants land in the never-expires top-up bucket (spent after the tier allowance).'
+ '</div>';
const search = '<input type="text" placeholder="Filter by key…" value="' + esc(state.creditsQuery || "") + '" '
+ 'oninput="filterUsers(this.value)" '
+ 'style="margin-bottom:12px;width:260px;padding:6px 10px;font-size:12px;background:var(--bg);border:1px solid var(--line-2);border-radius:5px;color:var(--fg);" />';
body = summary + search + '<div id="users-table-wrap">' + usersTableHtml() + '</div>';
}
root.innerHTML =
'<h1>Recap Relay — Operator Dashboard</h1>' +
tabsHtml() +
'<div style="max-width:1100px; padding:12px 0;">' +
body +
'</div>';
}
function table(headers, rows) {
if (rows.length === 0) return '<div class="empty">No data in this range.</div>';
const thead = headers.map(h => '<th>' + esc(h) + '</th>').join("");
const tbody = rows.map(r =>
'<tr>' + r.map(c => {
if (c && typeof c === "object" && "html" in c) {
return '<td class="' + (c.num ? "num" : "") + '">' + c.html + '</td>';
}
return '<td>' + (typeof c === "string" ? c : esc(c)) + '</td>';
}).join("") + '</tr>'
).join("");
return '<table><thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table>';
}
</script>
</body>
</html>