6681 lines
326 KiB
HTML
6681 lines
326 KiB
HTML
<!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") return saved;
|
||
} catch {}
|
||
return "overview";
|
||
})(),
|
||
// 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();
|
||
}
|
||
// 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||
}
|
||
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 {
|
||
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("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();
|
||
}
|
||
// 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/<id>.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: "Steve gave a business update; John followed up with questions; Hank chimed in toward the end."" ' +
|
||
'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 & 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 & 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 + ' ' + 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 & 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 & 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 & 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" / "N–M 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(' · ') + '</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 || "") + ' · 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) +
|
||
' · 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>';
|
||
}
|
||
|
||
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>
|