dashboard.html
Rama main · réplica de lectura del repositorio original.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Squidbrake</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23FFF8F2'/%3E%3Cpath fill='%231E2A23' d='M16 30V21.5C16 12.4 23.2 5 32 5C40.8 5 48 12.4 48 21.5V30Z'/%3E%3Cg fill='none' stroke='%231E2A23' stroke-width='5' stroke-linecap='round'%3E%3Cpath d='M23 34V48.2C23 51.4 21.7 53.9 19.6 55.8'/%3E%3Cpath d='M32 34V44.6'/%3E%3Cpath d='M41 34V48.2C41 51.4 42.3 53.9 44.4 55.8'/%3E%3C/g%3E%3Ccircle fill='%231E2A23' cx='32' cy='52.6' r='2.9'/%3E%3Crect fill='%23F2622E' x='11' y='24.5' width='42' height='11' rx='5.5'/%3E%3Ccircle fill='%232FD574' cx='41.2' cy='30' r='2.5'/%3E%3C/svg%3E">
<style>
:root {
color-scheme: light;
--page: #f4f3f0;
--surface-1: #fcfcfb;
--surface-2: #f0efec;
--border: #e2e1dc;
--grid: #e8e7e3;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #7a7974;
--accent: #2a78d6;
--series-1: #2a78d6;
--hover-col: rgba(42, 120, 214, 0.08);
--good: #0ca30c;
--warning: #fab219;
--serious: #ec835a;
--critical: #d03b3b;
--neutral: #8a8984;
--shadow: 0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.04);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) {
color-scheme: dark;
--page: #121211;
--surface-1: #1a1a19;
--surface-2: #242422;
--border: #2f2f2c;
--grid: #2a2a28;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e86;
--accent: #3987e5;
--series-1: #3987e5;
--hover-col: rgba(57, 135, 229, 0.12);
--neutral: #8f8e86;
--shadow: none;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #121211;
--surface-1: #1a1a19;
--surface-2: #242422;
--border: #2f2f2c;
--grid: #2a2a28;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e86;
--accent: #3987e5;
--series-1: #3987e5;
--hover-col: rgba(57, 135, 229, 0.12);
--neutral: #8f8e86;
--shadow: none;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; }
body {
background:
radial-gradient(900px 320px at 0% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
var(--page);
color: var(--text-primary);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select { font: inherit; color: inherit; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 28px 24px 56px; }
@media (max-width: 640px) { .wrap { padding: 14px 16px 40px; } }
/* header */
header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
header h1 { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 680; margin: 0; letter-spacing: -0.03em; }
header h1 .mark { flex: none; color: var(--text-primary); }
header h1 .name { display: flex; flex-direction: column; line-height: 1.05; }
header h1 .name a { color: inherit; text-decoration: none; }
header h1 #ver { font-size: 11px; font-weight: 500; letter-spacing: 0; color: var(--text-secondary); text-decoration: none; }
header h1 #ver:hover, header h1 .newver:hover { text-decoration: underline; }
header h1 .newver { font-size: 11px; font-weight: 600; letter-spacing: 0; color: var(--accent); text-decoration: none; }
.conn { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12.5px; }
.conn .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral); }
.conn.ok .dot { background: var(--good); }
.conn.err .dot { background: var(--critical); }
header .spacer { flex: 1; }
.btn {
border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px;
padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
.btn:hover { background: var(--surface-2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn:focus-visible, input:focus-visible, select:focus-visible, .seg button:focus-visible, tr:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px;
}
.toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 13px; cursor: pointer; user-select: none; }
/* filter row */
.filters {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 16px;
background: var(--surface-1); border: 1px solid var(--border); border-radius: 12px; padding: 8px;
box-shadow: var(--shadow);
}
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface-1); }
.seg button { border: 0; background: none; padding: 6px 12px; cursor: pointer; color: var(--text-secondary); }
.seg button + button { border-left: 1px solid var(--border); }
.seg button[aria-pressed="true"] { background: var(--surface-2); color: var(--text-primary); font-weight: 600; }
.filters input, .filters select {
border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px; padding: 6px 10px; min-width: 0;
}
.filters input[type=search] { flex: 1 1 220px; }
.filters .f-sm { flex: 0 1 150px; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border);
border-radius: 999px; padding: 2px 4px 2px 10px; font-size: 12.5px; color: var(--text-secondary);
}
.chip b { color: var(--text-primary); font-weight: 600; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip button { border: 0; background: none; cursor: pointer; color: var(--text-muted); padding: 0 4px; font-size: 14px; line-height: 1; }
/* cards */
.card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); }
.card h2 { font-size: 13px; font-weight: 600; margin: 0; color: var(--text-secondary); }
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 14px 16px 6px; }
.card-head .sub { font-size: 12px; color: var(--text-muted); }
.linkish { border: 0; background: none; color: var(--accent); cursor: pointer; padding: 0; font-size: 12.5px; }
.tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
@media (max-width: 860px) { .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 520px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.tile { padding: 12px 14px; cursor: pointer; text-align: left; font: inherit; color: inherit; }
.tile:hover { background: var(--surface-2); }
.tile[aria-pressed="true"] { border-color: var(--accent); }
.tile .lbl { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-secondary); }
.tile .val { font-size: 26px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; margin-top: 2px; }
.tile .pct { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.row2 { display: grid; grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr); gap: 12px; margin-bottom: 12px; }
@media (max-width: 960px) { .row2 { grid-template-columns: minmax(0, 1fr); } }
/* status glyph */
.st { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); white-space: nowrap; }
.st i {
font-style: normal; width: 16px; height: 16px; border-radius: 50%; display: inline-grid; place-items: center;
font-size: 10px; font-weight: 700; color: #fff; background: var(--c, var(--neutral)); flex: none;
}
.st.completed { --c: var(--good); }
.st.failed { --c: var(--serious); }
.st.denied { --c: var(--critical); }
.st.pending { --c: var(--neutral); }
.st.awaiting_approval { --c: var(--warning); }
.st.awaiting_approval i { color: #3d2c00; }
/* approval queue */
.approvals { margin-bottom: 12px; border-color: var(--warning); border-width: 1px 1px 1px 4px; }
.approvals .card-head h2 { color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
.approvals .count { background: var(--warning); color: #3d2c00; border-radius: 999px; padding: 0 8px; font-size: 12px; font-weight: 700; }
.aq { list-style: none; margin: 0; padding: 0 8px 8px; max-height: 420px; overflow: auto; }
.aq li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; padding: 10px 8px; border-top: 1px solid var(--grid); }
.aq li:first-child { border-top: 0; }
.aq li.shortcut-selected { background: var(--hover-col); outline: 2px solid var(--accent); outline-offset: -2px; }
.aq .arm { grid-column: 1 / -1; font-size: 13px; font-weight: 600; color: var(--text-primary); background: var(--surface-2);
border-left: 3px solid var(--warning); border-radius: 6px; padding: 6px 10px; }
.aq .who { font-size: 12.5px; color: var(--text-secondary); }
.aq .why { font-size: 13px; }
.aq .inp { grid-column: 1 / -1; font: 12px/1.4 var(--mono); color: var(--text-secondary); background: var(--surface-2);
border-radius: 6px; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aq .side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.aq .acts, .appr-acts { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.aq .acts input, .appr-acts input { border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px; padding: 5px 8px; width: 180px; min-width: 0; }
.btn.approve { background: var(--good); border-color: var(--good); color: #fff; font-weight: 600; }
.btn.reject { color: var(--critical); border-color: var(--critical); font-weight: 600; }
.btn:disabled { opacity: .5; cursor: default; }
.clock { font-size: 12.5px; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }
.clock.soon { color: var(--critical); font-weight: 600; }
.hint { font-size: 12px; color: var(--text-muted); }
#slackSetup summary, #emailSetup summary { cursor: pointer; font-weight: 500; }
.sub-h { font-size: 13px; font-weight: 600; margin: 18px 0 6px; }
.packs { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 8px; }
.packs .pack, .suggest { border: 1px solid var(--border, rgba(127,127,127,.25)); border-radius: 8px; padding: 10px 12px; }
.packs .pack p { margin: 4px 0 8px; font-size: 12px; color: var(--text-muted); }
.suggest { display: flex; gap: 10px; align-items: center; margin: 6px 0; }
.suggest .grow { flex: 1; }
#slackSetup .steps { margin: 10px 0 12px; padding-left: 18px; display: grid; gap: 6px; }
#slackSetup .manifest { max-height: 180px; overflow: auto; margin: 6px 0; padding: 10px; font-size: 11px; border-radius: 6px;
background: var(--bg-subtle, rgba(127,127,127,.08)); white-space: pre; }
@media (max-width: 640px) {
.aq li { grid-template-columns: minmax(0, 1fr); }
.aq .side { align-items: stretch; }
.aq .acts { justify-content: stretch; }
.aq .acts input { flex: 1 1 100%; width: auto; }
.aq .acts .btn { flex: 1; justify-content: center; }
}
.toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--text-primary); color: var(--page);
padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 40; max-width: calc(100vw - 32px); box-shadow: 0 6px 24px rgba(0,0,0,.2);
}
/* chart */
.chart-box { position: relative; padding: 4px 12px 10px; }
#chart svg { display: block; width: 100%; height: 200px; overflow: visible; }
#chart .axis { fill: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
#chart .gridline { stroke: var(--grid); stroke-width: 1; }
#chart .baseline { stroke: var(--border); stroke-width: 1; }
#chart .bar { fill: var(--series-1); }
#chart .hovercol { fill: transparent; }
#chart .hovercol.on { fill: var(--hover-col); }
.empty { color: var(--text-muted); text-align: center; padding: 40px 12px; font-size: 13px; }
#noRows.empty {
background: var(--surface-1); border: 1px dashed var(--border); border-radius: 16px;
margin: 4px 0 8px; padding: 48px 20px;
}
#noRows .empty-title { color: var(--text-primary); font-size: 16px; font-weight: 650; letter-spacing: -0.02em; margin-bottom: 4px; }
.tip {
position: absolute; pointer-events: none; background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 12.5px; box-shadow: 0 4px 16px rgba(0,0,0,.12);
white-space: nowrap; z-index: 5; display: none;
}
.tip .t { color: var(--text-secondary); margin-bottom: 4px; }
.tip .n { font-weight: 650; font-variant-numeric: tabular-nums; }
.tip .r { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.tip .r .sw { width: 10px; height: 10px; border-radius: 3px; background: var(--series-1); }
.mini-table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.mini-table th, .mini-table td { text-align: right; padding: 4px 8px; border-bottom: 1px solid var(--grid); }
.mini-table th:first-child, .mini-table td:first-child { text-align: left; }
.mini-wrap { max-height: 220px; overflow: auto; margin: 0 12px 12px; }
/* top tools */
.toplist { list-style: none; margin: 0; padding: 4px 8px 12px; max-height: 250px; overflow: auto; }
.toplist li button {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 10px; width: 100%; text-align: left;
border: 0; background: none; padding: 6px 8px; border-radius: 6px; cursor: pointer;
}
.toplist li button:hover { background: var(--surface-2); }
.toplist .nm { font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toplist .ct { font-size: 12.5px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.toplist .track { grid-column: 1 / -1; height: 4px; }
.toplist .fill { height: 4px; border-radius: 0 4px 4px 0; background: var(--series-1); min-width: 2px; }
/* events table */
.tbl-wrap { overflow-x: auto; }
table.events { width: 100%; border-collapse: collapse; font-size: 13px; }
table.events th {
text-align: left; font-weight: 600; font-size: 12px; color: var(--text-muted); padding: 8px 12px;
border-bottom: 1px solid var(--border); white-space: nowrap; position: sticky; top: 0; background: var(--surface-1);
}
table.events td { padding: 8px 12px; border-bottom: 1px solid var(--grid); vertical-align: middle; white-space: nowrap; }
table.events tbody tr { cursor: pointer; }
table.events tbody tr:hover { background: var(--surface-2); }
table.events tbody tr.sel { background: var(--hover-col); }
table.events tr.fresh td { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: var(--hover-col); } to { background: transparent; } }
.mono { font-family: var(--mono); font-size: 12.5px; }
.muted { color: var(--text-muted); }
.sec { color: var(--text-secondary); }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.trunc { max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.pill-link { border: 0; background: none; padding: 0; cursor: pointer; color: var(--text-secondary); text-decoration: underline dotted; text-underline-offset: 3px; }
.pill-link:hover { color: var(--accent); }
.foot { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; color: var(--text-muted); font-size: 12.5px; gap: 8px; flex-wrap: wrap; }
/* agents + chains */
.chain-title { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.chain-title h2 { color: var(--text-primary); font-size: 15px; letter-spacing: -0.02em; }
.agent-strip { display: flex; gap: 8px; overflow-x: auto; padding: 0 2px 12px; }
.agent-chip {
display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--border); background: var(--surface-1);
border-radius: 999px; padding: 4px 12px 4px 4px; cursor: pointer; white-space: nowrap; color: var(--text-primary);
box-shadow: var(--shadow);
}
.agent-chip:hover { background: var(--surface-2); }
.agent-chip[aria-pressed="true"] { border-color: var(--accent); background: var(--hover-col); }
.agent-chip .ct { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.avatar {
--h: 210; --hl: 38%;
width: 36px; height: 36px; border-radius: 12px; flex: none; display: inline-grid; place-items: center;
background: linear-gradient(160deg, hsl(var(--h) 62% calc(var(--hl) + 8%)), hsl(var(--h) 52% var(--hl)));
color: #fff; font-weight: 700; font-size: 13px; letter-spacing: .02em;
font-family: system-ui, sans-serif; vertical-align: middle;
box-shadow: inset 0 1px 0 rgba(255,255,255,.28);
}
.avatar.sm { width: 24px; height: 24px; border-radius: 8px; font-size: 10px; }
#eventsCard { background: transparent; border: 0; box-shadow: none; }
#eventsCard .card-head { padding: 2px 2px 10px; flex-wrap: wrap; align-items: center; }
#eventsCard .foot { padding-left: 2px; padding-right: 2px; }
#callsWrap { background: var(--surface-1); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow); }
.chains { display: flex; flex-direction: column; gap: 12px; padding: 0 0 4px; }
.chain {
--h: 210; --chain-bg: var(--surface-1);
min-width: 0;
border: 1px solid var(--border); border-radius: 16px; background: var(--chain-bg);
box-shadow: var(--shadow);
border-left: 4px solid hsl(var(--h) 52% 46%); padding: 16px 16px 12px;
}
.chain.fresh { animation: flash 1.6s ease-out; }
.chain.loose { border-left-style: dashed; }
.chain.awaiting_approval { --chain-bg: color-mix(in srgb, var(--warning) 12%, var(--surface-1)); }
.chain.denied { --chain-bg: color-mix(in srgb, var(--critical) 8%, var(--surface-1)); }
.chain.failed { --chain-bg: color-mix(in srgb, var(--serious) 9%, var(--surface-1)); }
.chain-head { display: flex; gap: 12px; align-items: flex-start; }
.chain-id { min-width: 0; flex: 1; }
.chain-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 650; font-size: 15px; letter-spacing: -0.02em; }
.chain-name .agent-name { border: 0; background: none; padding: 0; cursor: pointer; font: inherit; font-weight: 650; color: inherit; }
.chain-name .agent-name:hover { color: var(--accent); }
.chain-meta { font-size: 12.5px; color: var(--text-secondary); margin-top: 3px; display: flex; flex-wrap: wrap; gap: 0; align-items: center; }
.chain-side { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center; }
.tally {
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-variant-numeric: tabular-nums;
color: var(--text-secondary); background: color-mix(in srgb, var(--chain-bg) 70%, var(--surface-2));
border-radius: 999px; padding: 2px 8px 2px 3px;
}
.tally i {
font-style: normal; width: 16px; height: 16px; border-radius: 50%; display: inline-grid; place-items: center;
font-size: 9px; font-weight: 700; color: #fff; background: var(--c, var(--neutral));
}
.tally.completed { --c: var(--good); }
.tally.failed { --c: var(--serious); }
.tally.denied { --c: var(--critical); }
.tally.pending { --c: var(--neutral); }
.tally.awaiting_approval { --c: var(--warning); }
.tally.awaiting_approval i { color: #3d2c00; }
.loose-note { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
.flow { display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding: 14px 2px 4px; }
.flow .edge { width: 36px; flex: none; align-self: flex-start; height: 2px; margin-top: 10px; background: var(--border); }
.flow .edge::after { content: none; }
.node {
position: relative; flex: 0 0 124px; min-width: 104px; border: 0; background: transparent;
border-radius: 10px; padding: 26px 6px 4px; text-align: center; cursor: pointer;
}
.node::before {
content: ""; position: absolute; top: 2px; left: 50%; transform: translateX(-50%);
width: 16px; height: 16px; border-radius: 50%; background: var(--c, var(--neutral));
box-shadow: 0 0 0 4px var(--chain-bg); z-index: 1;
}
.node:hover .nm { color: var(--accent); }
.node.sel::before { outline: 2px solid var(--accent); outline-offset: 3px; }
.node .nm { display: block; font-family: var(--mono); font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node .meta { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node.more { flex-basis: 76px; min-width: 72px; }
.node.more::before { background: var(--surface-2); box-shadow: 0 0 0 4px var(--chain-bg), inset 0 0 0 1.5px var(--border); }
.node.completed { --c: var(--good); }
.node.failed { --c: var(--serious); }
.node.denied { --c: var(--critical); }
.node.pending { --c: var(--neutral); }
.node.awaiting_approval { --c: var(--warning); }
.node.awaiting_approval::before { box-shadow: 0 0 0 4px var(--chain-bg), 0 0 0 7px color-mix(in srgb, var(--warning) 35%, transparent); }
.spark { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin-top: 12px; padding-top: 2px; }
.spark-dot {
width: 11px; height: 11px; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: var(--c, var(--neutral));
box-shadow: 0 0 0 2px var(--chain-bg);
}
.spark-dot.sel { outline: 2px solid var(--accent); outline-offset: 2px; }
.spark-dot.completed { --c: var(--good); }
.spark-dot.failed { --c: var(--serious); }
.spark-dot.denied { --c: var(--critical); }
.spark-dot.pending { --c: var(--neutral); }
.spark-dot.awaiting_approval { --c: var(--warning); border-radius: 3px; }
.chain-focus {
margin-top: 12px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--chain-bg) 55%, var(--surface-2));
border: 1px solid var(--border); font-size: 13px;
}
.chain-focus .st-in { margin-top: 4px; }
.chain-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.aq-main { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
.aq-copy { min-width: 0; }
@media (max-width: 640px) {
.chain-head { flex-wrap: wrap; }
.chain-side { justify-content: flex-start; }
}
/* drawer */
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.28); z-index: 20; display: none; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 100vw); background: var(--surface-1);
border-left: 1px solid var(--border); z-index: 21; transform: translateX(100%); transition: transform .18s ease;
display: flex; flex-direction: column;
}
.drawer.open { transform: none; }
.drawer.open + .backdrop, .backdrop.on { display: block; }
.dr-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px 18px 12px; border-bottom: 1px solid var(--border); }
.dr-head h3 { margin: 0; font-family: var(--mono); font-size: 15px; display: flex; align-items: center; gap: 8px; }
.dr-head h3 span { word-break: break-all; }
.dr-head .x { margin-left: auto; }
.dr-body { overflow: auto; padding: 14px 18px 24px; }
.kv { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 6px 12px; font-size: 13px; margin-bottom: 16px; }
.kv dt { color: var(--text-muted); }
.kv dd { margin: 0; word-break: break-word; }
.blk h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin: 16px 0 6px; display: flex; justify-content: space-between; }
.blk pre {
margin: 0; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
font: 12.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 360px; overflow: auto;
}
.banner { border-radius: 8px; padding: 8px 12px; font-size: 13px; margin-bottom: 14px; border: 1px solid var(--border); background: var(--surface-2); }
.banner.denied { border-left: 3px solid var(--critical); }
.banner.failed { border-left: 3px solid var(--serious); }
.banner.awaiting { border-left: 3px solid var(--warning); }
.banner.approved { border-left: 3px solid var(--good); }
.banner .appr-acts { margin-top: 10px; justify-content: flex-start; }
/* key dialog */
dialog { border: 1px solid var(--border); border-radius: 12px; background: var(--surface-1); color: var(--text-primary); padding: 20px; width: min(420px, calc(100vw - 32px)); }
dialog::backdrop { background: rgba(0,0,0,.35); }
dialog h3 { margin: 0 0 6px; font-size: 16px; }
dialog p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
dialog input { width: 100%; border: 1px solid var(--border); background: var(--page); border-radius: 8px; padding: 8px 10px; }
.shortcut-help .mini-table td { text-align: left; }
.shortcut-help kbd { display: inline-block; min-width: 28px; padding: 2px 7px; border: 1px solid var(--border); border-bottom-width: 2px;
border-radius: 5px; background: var(--surface-2); font: 12px var(--mono); text-align: center; }
dialog .acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.err-msg { color: var(--critical); font-size: 12.5px; margin-top: 8px; min-height: 1em; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
/* story + history warnings */
.signals { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; margin: 2px 0 10px; }
.brief { border-radius: 8px; padding: 8px 10px; font-size: 13px; border: 1px solid var(--border); border-left: 4px solid var(--accent, #2a78d6);
background: var(--surface-2); white-space: pre-wrap; margin-top: 6px; }
.brief .who { font-size: 11.5px; color: var(--text-muted); margin-top: 4px; }
.proof-ok { color: var(--good); font-weight: 600; } .proof-bad { color: var(--critical); font-weight: 600; }
.meter { height: 6px; border-radius: 999px; background: var(--surface-2); overflow: hidden; margin-top: 4px; min-width: 80px; }
.meter > i { display: block; height: 100%; background: var(--good); }
.meter.over > i { background: var(--critical); }
.signal { border-radius: 8px; padding: 8px 10px; font-size: 13px; border: 1px solid var(--border); border-left-width: 4px; background: var(--surface-2); }
.signal.block { border-left-color: var(--critical); }
.signal.review { border-left-color: var(--serious); }
.signal.warn { border-left-color: var(--warning); }
.story-d { grid-column: 1 / -1; font-size: 13px; }
.story-d summary { cursor: pointer; color: var(--accent); font-size: 12.5px; }
.rail { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; }
.rail li { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; }
.rail .tick { display: flex; flex-direction: column; align-items: center; }
.rail .tick .line { flex: 1; width: 2px; background: var(--border); min-height: 10px; }
.rail li:last-child .line { visibility: hidden; }
.rail .body { padding-bottom: 12px; min-width: 0; }
.rail li.on .body { background: var(--hover-col); border-radius: 8px; padding: 6px 8px 8px; margin: -6px -8px 0; }
.rail .st-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.st-in { color: var(--text-secondary); font: 12px/1.4 var(--mono); margin-top: 2px; word-break: break-word; }
.st-out { color: var(--text-primary); font-size: 12.5px; margin-top: 2px; background: var(--surface-2); border-radius: 6px; padding: 5px 8px; word-break: break-word; }
/* tabs + header actions */
.tabs { display: flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 2px; }
.tabs a { color: var(--text-secondary); text-decoration: none; padding: 5px 12px; border-radius: 7px; font-size: 13.5px; }
.tabs a:hover { color: var(--text-primary); }
.tabs a[aria-current="page"] { background: var(--surface-1); color: var(--text-primary); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.btn.danger { background: var(--critical); border-color: var(--critical); color: #fff; font-weight: 600; }
.btn.danger:hover { background: var(--critical); filter: brightness(1.08); }
.stop-banner { background: var(--critical); color: #fff; border-radius: 10px; padding: 12px 16px; margin-bottom: 14px;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.stop-banner b { font-size: 15px; }
.shadow-banner { background: var(--surface-1); border: 1px dashed var(--serious); color: var(--text-primary);
border-radius: 10px; padding: 10px 16px; margin-bottom: 14px; font-size: 13.5px; }
.tag.would { border-color: var(--serious); color: var(--serious); margin-left: 6px; }
.stop-banner .btn { background: #fff; color: var(--critical); border-color: #fff; margin-left: auto; font-weight: 600; }
.lede { color: var(--text-secondary); margin: 0 16px 12px; font-size: 13.5px; max-width: 900px; }
.saved { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 0 0 14px; padding: 12px 16px;
border: 1px solid var(--border); border-left: 4px solid #16803c; border-radius: 0 12px 12px 0; background: var(--surface-1); }
.saved b { font-size: 15px; } .saved span { color: var(--text-secondary); font-size: 13px; }
.saved a { margin-left: auto; }
.saved.plan { border-left-color: #b45309; }
code { font-family: var(--mono); font-size: .92em; background: var(--surface-2); padding: 1px 5px; border-radius: 5px; }
/* forms */
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 16px; padding: 0 16px 16px; align-items: end; }
.form-grid.wide { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.form-grid label { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
.form-grid label.full, .form-grid .full { grid-column: 1 / -1; }
.form-grid label.check { flex-direction: row; align-items: center; font-weight: 400; color: var(--text-primary); font-size: 13.5px; }
.form-grid input:not([type=checkbox]), .form-grid select { border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px;
padding: 7px 10px; font-weight: 400; color: var(--text-primary); min-width: 0; width: 100%; }
.form-grid .hint { font-weight: 400; }
.inline { display: flex; gap: 8px; }
.inline input { flex: 1; }
.form-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.newkey { margin: 0 16px 16px; padding: 12px 14px; border: 1px solid var(--good); border-radius: 10px; background: var(--surface-2); font-size: 13.5px; }
.newkey code { user-select: all; font-size: 13px; word-break: break-all; }
.access { display: inline-flex; gap: 6px; align-items: center; }
.tag { display: inline-block; font-size: 11.5px; padding: 1px 8px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-secondary); margin-right: 4px; }
.tag.admin { border-color: var(--accent); color: var(--accent); }
/* reports */
.report-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.report-bar .spacer { flex: 1; }
.row2.even { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@media (max-width: 960px) { .row2.even { grid-template-columns: minmax(0, 1fr); } }
.audit-ok { display: flex; gap: 12px; align-items: flex-start; padding: 4px 16px 16px; }
.audit-ok .big { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 20px; flex: none; }
.audit-ok.ok .big { background: var(--good); }
.audit-ok.bad .big { background: var(--critical); }
.audit-ok b { display: block; font-size: 15px; }
.fp { font-family: var(--mono); font-size: 12px; color: var(--text-muted); word-break: break-all; }
.print-head { display: none; }
td.wrap { white-space: normal; max-width: 420px; }
/* dialogs */
dialog.wide-dlg { width: min(460px, calc(100vw - 32px)); }
.qr { display: grid; place-items: center; background: #fff; border-radius: 12px; padding: 12px; margin: 8px 0 10px; }
.qr img { width: 240px; height: 240px; image-rendering: pixelated; }
.warn { color: var(--critical); font-size: 13px; }
dialog input#stopReason { width: 100%; border: 1px solid var(--border); background: var(--page); border-radius: 8px; padding: 8px 10px; }
@media print {
header, .filters, .report-bar, .stop-banner, .drawer, .backdrop, #toast, .view:not(#view-reports) { display: none !important; }
body { background: #fff; color: #000; }
.card { box-shadow: none; break-inside: avoid; }
.print-head { display: block; margin-bottom: 12px; }
.print-head h1 { font-size: 20px; margin: 0 0 4px; }
td button, th:last-child:empty { display: none; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><svg class="mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true"><path fill="currentColor" d="M16 30V21.5C16 12.4 23.2 5 32 5C40.8 5 48 12.4 48 21.5V30Z"/><g fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"><path d="M23 34V48.2C23 51.4 21.7 53.9 19.6 55.8"/><path d="M32 34V44.6"/><path d="M41 34V48.2C41 51.4 42.3 53.9 44.4 55.8"/></g><circle fill="currentColor" cx="32" cy="52.6" r="2.9"/><rect fill="#F2622E" x="11" y="24.5" width="42" height="11" rx="5.5"/><circle fill="#2FD574" cx="41.2" cy="30" r="2.5"/></svg><span class="name"><a href="#activity" id="home" title="Activity">Squidbrake</a>
<a id="ver" target="_blank" rel="noopener" href="https://github.com/batrapulkit/squidbrake/releases"
title="Latest release"></a></span></h1>
<span class="conn" id="conn"><span class="dot"></span><span id="connText">Connecting…</span></span>
<nav class="tabs" id="tabs" aria-label="Sections">
<a href="#activity" data-view="activity">Activity</a>
<a href="#team" data-view="team">Team</a>
<a href="#reports" data-view="reports">Reports</a>
<a href="#settings" data-view="settings" data-admin hidden>Settings</a>
</nav>
<span class="spacer"></span>
<label class="toggle"><input type="checkbox" id="auto" checked> Live</label>
<button class="btn" id="refresh" title="Refresh">↻</button>
<button class="btn" id="phoneBtn" title="Approve from your phone">📱 Phone</button>
<button class="btn" id="notifyBtn" aria-pressed="false">🔔 <span id="notifyText">Alerts off</span></button>
<button class="btn" id="keyBtn" title="Sign in with a different key">⚿ <span id="meName">Key</span></button>
<button class="btn" id="modeBtn" data-admin hidden aria-pressed="false"
title="Observe: nothing is blocked or held, only recorded for a report later. Enforce: rules block and hold.">👁 <span id="modeText">Observe off</span></button>
<button class="btn danger" id="stopBtn" title="Block every agent action right now" hidden>⏹ Stop agents</button>
</header>
<div class="stop-banner" id="stopBanner" role="alert" hidden></div>
<div class="shadow-banner" id="shadowBanner" hidden></div>
<div id="view-activity" class="view">
<div class="saved" id="savedCard" hidden><div><b id="savedTitle"></b><br><span>Each one was stopped before it ran.</span></div>
<a class="btn" id="savedShare" target="_blank" rel="noopener">Share on LinkedIn</a></div>
<div class="saved plan" id="planCard" hidden><div><b id="planTitle"></b><br><span id="planText"></span></div>
<a class="btn" id="planLink" target="_blank" rel="noopener">Start a Team trial</a></div>
<div class="filters" role="search">
<div class="seg" id="range" aria-label="Time range">
<button data-r="1h">1h</button><button data-r="24h">24h</button><button data-r="7d">7d</button><button data-r="30d">30d</button><button data-r="1y">1y</button>
</div>
<input type="search" id="q" placeholder="Search tool, agent, chain…" aria-label="Search">
<select id="status" class="f-sm" aria-label="Status">
<option value="">All statuses</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="denied">Denied</option>
<option value="pending">Pending</option>
<option value="awaiting_approval">Awaiting approval</option>
</select>
<input id="kind" class="f-sm" placeholder="Kind" aria-label="Kind" list="kinds">
<datalist id="kinds"><option value="tool_call"><option value="action"><option value="http_request"></datalist>
<input id="source" class="f-sm" placeholder="Agent" aria-label="Agent">
<input id="session_id" class="f-sm" placeholder="Chain" aria-label="Chain">
<div class="chips" id="chips"></div>
</div>
<section class="card approvals" id="approvals" aria-labelledby="aqTitle" hidden>
<div class="card-head">
<h2 id="aqTitle">Needs approval <span class="count" id="aqCount"></span></h2>
<span class="hint" id="aqHint"></span>
</div>
<ul class="aq" id="aq"></ul>
</section>
<section class="tiles" id="tiles" aria-label="Totals"></section>
<div class="row2">
<section class="card">
<div class="card-head">
<h2 id="chartTitle">Calls per hour</h2>
<button class="linkish" id="tableToggle" aria-expanded="false">View as table</button>
</div>
<div class="chart-box">
<div id="chart"></div>
<div class="tip" id="tip"></div>
</div>
<div class="mini-wrap" id="chartTable" hidden></div>
</section>
<section class="card">
<div class="card-head"><h2>Top tools</h2><span class="sub">click to filter</span></div>
<ul class="toplist" id="top"></ul>
</section>
</div>
<section class="card" id="eventsCard">
<div class="card-head">
<div class="chain-title"><h2 id="evTitle">Chains</h2><span class="sub" id="evCount"></span></div>
<div class="seg" id="evView" role="group" aria-label="Activity layout">
<button type="button" data-v="chains" aria-pressed="true">Chains</button>
<button type="button" data-v="calls">Calls</button>
</div>
</div>
<div class="agent-strip" id="agentStrip" aria-label="Agents" hidden></div>
<div id="chains" class="chains"></div>
<div class="tbl-wrap" id="callsWrap" hidden>
<table class="events">
<thead><tr>
<th>Time</th><th>Status</th><th>Name</th><th>Kind</th><th>Agent</th><th>Chain</th><th class="num">Duration</th><th>Rule</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="empty" id="noRows" hidden>No events match these filters.</div>
<div class="foot">
<span id="footInfo"></span>
<button class="btn" id="more" hidden>Load older</button>
</div>
</section>
</div>
<!-- ============================================================ TEAM -->
<div id="view-team" class="view" hidden>
<section class="card">
<div class="card-head"><h2>People and agents</h2><span class="sub" id="teamSub"></span></div>
<p class="lede">Each person and each AI agent gets its own key. <b>Viewers</b> can look at everything and change
nothing; <b>approvers</b> also approve held actions and can stop agents; <b>admins</b> manage the team and settings;
<b>owners</b> also decide who is an admin or owner. Agents can only ask permission and report what they did.
Roles such as <code>finance</code> let rules require a specific team to approve.</p>
<p class="lede">Want your teammates to approve from anywhere, on one shared dashboard, their phone or Slack?
<a href="https://squidbrake.com/signup?plan=team&from=invite" target="_blank" rel="noopener"><b>Start a 14-day Team
trial</b></a>: we host it for your team, no card to start. Or
<a href="https://calendly.com/pulkitbatra2024/new-meeting-1" target="_blank" rel="noopener">book 15 minutes with the founder</a>.</p>
<div class="tbl-wrap">
<table class="events team">
<thead><tr><th>Name</th><th>Type</th><th>Access</th><th>Roles</th><th>Added</th><th></th></tr></thead>
<tbody id="teamRows"></tbody>
</table>
</div>
</section>
<section class="card" id="addMemberCard" data-admin hidden>
<div class="card-head"><h2>Add someone</h2></div>
<form class="form-grid" id="addMember">
<label>Name <input name="name" required maxlength="64" placeholder="e.g. priya or billing-agent" pattern="[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}"></label>
<label>Type
<select name="kind"><option value="person">Person</option><option value="agent">AI agent</option></select></label>
<label class="person-only">Access
<select name="access"><option value="approver">Approver: can approve</option><option value="viewer">Viewer: can view only</option>
<option value="admin">Admin: manages team and settings</option><option value="owner">Owner: also manages admins</option></select></label>
<label class="person-only">Teams (optional) <input name="roles" placeholder="e.g. finance, ops"></label>
<label class="agent-only" hidden>Works for
<select name="owner" id="ownerSel"><option value="">nobody in particular</option></select>
<span class="hint">With second-person approval on, this person can't approve what their own agent asks for.</span></label>
<div class="form-actions"><button class="btn primary" type="submit">Add and create key</button></div>
</form>
<div class="newkey" id="newKey" hidden></div>
</section>
</div>
<!-- ============================================================ REPORTS -->
<div id="view-reports" class="view" hidden>
<div class="report-bar">
<div class="seg" id="repRange" aria-label="Report period">
<button data-d="1">Today</button><button data-d="7">7 days</button><button data-d="30">30 days</button><button data-d="90">90 days</button>
</div>
<span class="spacer"></span>
<button class="btn" id="csvBtn">⬇ Download audit CSV</button>
<button class="btn" id="jsonlBtn">⬇ Download JSONL</button>
<button class="btn" id="evidenceBtn" title="A printable page for your auditor: controls in place, what happened, and the requirements it speaks to">📄 Evidence pack</button>
<button class="btn" id="printBtn">🖨 Print / save PDF</button>
</div>
<div class="print-head" id="printHead"></div>
<section class="tiles" id="repTiles" aria-label="Report totals"></section>
<section class="card">
<div class="card-head"><h2>What it caught</h2><span class="sub" id="caughtSub"></span>
<span class="spacer"></span><button class="btn" id="copyWeekly" type="button">Copy weekly report</button>
<button class="btn" id="shareWeek" type="button" title="A public card with these counts only, to post">Share my week</button></div>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Outcome</th><th>What the agent tried</th><th>Why</th>
<th>What it would have done</th></tr></thead><tbody id="caughtList"></tbody></table></div>
<p class="hint">Also sent every Monday to Slack, Teams or email when they're set up in Settings.</p>
</section>
<div class="row2 even">
<section class="card" id="auditCard"></section>
<section class="card">
<div class="card-head"><h2>Approvals by person</h2></div>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Approver</th><th class="num">Approved</th><th class="num">Rejected</th></tr></thead>
<tbody id="repApprovers"></tbody></table></div>
</section>
</div>
<section class="card">
<div class="card-head"><h2>Agents</h2><span class="sub">stop a single agent without touching the others</span></div>
<div class="tbl-wrap"><table class="events"><thead><tr>
<th>Agent</th><th class="num">Actions</th><th class="num">Blocked</th><th class="num">Held</th><th class="num">Approved</th>
<th class="num">Rejected</th><th class="num">Failed</th><th>Last seen</th><th></th></tr></thead>
<tbody id="repAgents"></tbody></table></div>
</section>
<section class="card" id="spendCard" hidden>
<div class="card-head"><h2>Spend limits</h2><span class="sub">model tokens this period; an agent over its limit is paused until it resets</span></div>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Limit</th><th>Agent</th><th>Used</th><th></th><th>Resets</th></tr></thead>
<tbody id="spendRows"></tbody></table></div>
<p class="hint">Set in rules.yaml under <code>budgets:</code>. Claude Code reports its tokens by itself; other agents send them to <code>POST /v1/usage</code>.</p>
</section>
<div class="row2 even">
<section class="card">
<div class="card-head"><h2>Blocked by rules</h2></div>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Rule</th><th>Why</th><th class="num">Times</th></tr></thead>
<tbody id="repBlocked"></tbody></table></div>
</section>
<section class="card">
<div class="card-head"><h2>Most used tools</h2></div>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Tool</th><th class="num">Calls</th></tr></thead>
<tbody id="repTools"></tbody></table></div>
</section>
</div>
<section class="card">
<div class="card-head"><h2>Audit trail</h2><span class="sub">every decision, change and export, newest first</span></div>
<div class="tbl-wrap"><table class="events"><thead><tr><th>When</th><th>Who</th><th>What</th><th>Details</th></tr></thead>
<tbody id="repAudit"></tbody></table></div>
</section>
</div>
<!-- ============================================================ SETTINGS -->
<div id="view-settings" class="view" hidden>
<section class="card" id="teamAgents" hidden>
<div class="card-head"><h2>Agents across the team</h2><span class="sub" id="teamAgentsSub"></span></div>
<p class="lede">Each computer's <code>squidbrake doctor</code> says which agents it has. Anything marked
<b>not connected</b> works without Squidbrake: nothing it does is checked or recorded.</p>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Computer</th><th>Connected</th><th>Not connected</th>
<th>MCP servers not guarded</th><th>Reported</th></tr></thead><tbody id="teamAgentsRows"></tbody></table></div>
</section>
<section class="card" id="agentsHere" hidden>
<div class="card-head"><h2>Agents on this computer</h2></div>
<p class="lede">Choose which agents go through Squidbrake. Turning one off takes Squidbrake out of that agent only;
the others stay as they are. Quit and reopen the agent afterwards so it picks the change up.</p>
<div id="agentsList"></div>
</section>
<section class="card">
<div class="card-head"><h2>Notifications</h2></div>
<p class="lede">When an agent needs approval, the gateway can ping you on your phone and in Slack. Each message has a
secure one-tap link to approve or reject that one action; the link stops working once it's decided.</p>
<form class="form-grid wide" id="settingsForm">
<label class="full">Public address of this gateway
<span class="hint">Links in notifications point here, so it must open from your phone (your cloud URL or tunnel link).</span>
<span class="inline"><input name="public_url" placeholder="https://gateway.yourcompany.com">
<button type="button" class="btn" id="useOrigin">Use this page's address</button></span></label>
<label class="full">Phone notifications (free ntfy app)
<span class="hint">Install “ntfy” from the App Store / Google Play, tap +, and subscribe to this private topic name.
Anyone who knows the topic can see the notifications, so keep it random.</span>
<span class="inline"><input name="ntfy_topic" placeholder="leave empty to turn off" pattern="[A-Za-z0-9_-]{0,64}">
<button type="button" class="btn" id="genTopic">Generate private topic</button></span></label>
<label class="full">Slack or Discord incoming webhook
<span class="hint">Paste a Slack or Discord webhook URL. Discord formatting is detected automatically. Leave empty to turn off.</span>
<input name="slack_webhook" placeholder="Webhook URL"></label>
<label class="full">Microsoft Teams
<span class="hint">In the channel: Workflows → "Post to a channel when a webhook request is received" → copy its URL here.
Each approval arrives as a card with a button to the one-tap page.</span>
<input name="teams_webhook" placeholder="https://…logic.azure.com/workflows/… (leave empty to turn off)"></label>
<details class="full" id="emailSetup">
<summary>Email (approvals and the weekly report)</summary>
<div class="form-grid wide" style="margin-top:10px">
<label class="full">Send to <input name="email_to" placeholder="ops@yourcompany.com, cto@yourcompany.com"></label>
<label>SMTP server <input name="smtp_host" placeholder="smtp.gmail.com"></label>
<label>Port <input name="smtp_port" type="number" min="1" max="65535" placeholder="587"></label>
<label>Username <input name="smtp_user" autocomplete="off"></label>
<label>Password <input name="smtp_password" type="password" autocomplete="off"></label>
<label class="full">From <input name="smtp_from" placeholder="squidbrake@yourcompany.com"></label>
<p class="hint full">An email carries the link to the review page only. Mail scanners open links, so nothing
in an email approves or rejects by being opened.</p>
</div>
</details>
<details class="full" id="slackSetup">
<summary>Approve and Reject right in Slack (buttons)</summary>
<ol class="hint steps">
<li>Set the public address above first: Slack sends each click there.</li>
<li><a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noopener">Create a Slack app</a>
→ From a manifest → pick your workspace → paste this:
<pre id="slackManifest" class="manifest"></pre> <button type="button" class="btn" id="copyManifest">Copy manifest</button></li>
<li>Install it to your workspace and pick the channel for approvals. Paste the webhook URL it gives you above.</li>
<li>From the app's Basic Information page, copy the Signing Secret here:</li>
</ol>
<label>Slack app signing secret <input name="slack_signing_secret" type="password" autocomplete="off" placeholder="from Basic Information"></label>
</details>
<label>One-tap links approve as
<select name="notify_as" id="notifyAs"></select>
<span class="hint">Whoever can see the notification can use its link, so pick the person who owns that phone / channel.</span></label>
<label class="check"><input type="checkbox" name="weekly_digest"> Post a weekly summary to Slack (Mondays 9:00)</label>
<label class="check full"><input type="checkbox" name="second_person"> Second-person approval: nobody approves what their own agent asked for
<span class="hint">Set who each agent works for in Team. Approvals then always come from someone else, which is what auditors look for.
Rolling it out to a team? <a href="https://pilots.squidbrake.com/team?from=dashboard" target="_blank" rel="noopener">We'll help</a>.</span></label>
<div class="form-actions full">
<button class="btn primary" type="submit">Save</button>
<button class="btn" type="button" id="testNotify">Send a test notification</button>
<span class="hint" id="settingsMsg"></span>
</div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>Approve with Face ID or fingerprint</h2><span class="sub" id="devSub"></span></div>
<p class="lede">Register your phone or laptop as a passkey. Each approval you make with it is signed by the device
and kept in the audit trail as proof of who approved what; anyone with an export can check the signature.</p>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Device</th><th>Person</th><th>Added</th><th>Last used</th><th></th></tr></thead>
<tbody id="devRows"></tbody></table></div>
<div class="form-actions" style="margin-top:10px">
<button class="btn primary" type="button" id="devAdd">Set up Face ID / fingerprint on this device</button>
<span class="hint" id="devMsg"></span></div>
<label class="check full" style="margin-top:10px" data-admin hidden><input type="checkbox" id="devRequired"> Approvals need Face ID or fingerprint
<span class="hint">Approvals from Slack buttons, phone notification buttons and plain links are refused; people approve on the page,
on a registered device. Rejecting never needs it. Register your own device first.</span></label>
</section>
<section class="card">
<div class="card-head"><h2>AI approval briefs</h2></div>
<p class="lede">A short, plain-English risk note on each action waiting for a person, written with your team's own
Anthropic key. It only advises: the rules decide what's held and people decide what runs. What it reads is what this
dashboard shows (redacted, previews capped); with no key, nothing is sent anywhere.</p>
<form class="form-grid wide" id="aiForm">
<label class="check full"><input type="checkbox" name="ai_briefs"> Write a brief for each held action</label>
<label class="full">Anthropic API key <input name="ai_key" type="password" autocomplete="off" placeholder="sk-ant-…">
<span class="hint" id="aiKeyHint">Kept on this gateway and never shown again.</span></label>
<label>Model <input name="ai_model" placeholder="claude-opus-5-5"></label>
<div class="form-actions full"><button class="btn primary" type="submit">Save</button>
<button class="btn" type="button" id="aiForget">Remove key</button><span class="hint" id="aiMsg"></span></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>Send to your SIEM</h2></div>
<p class="lede">Every decision, as it happens: who, which tool, allowed, held or blocked and why, who approved. In batches,
in the background, so a slow SIEM never slows an agent.</p>
<form class="form-grid wide" id="siemForm">
<label>Format <select name="siem_format"><option value="json">Any HTTP endpoint (JSON)</option>
<option value="splunk">Splunk (HTTP Event Collector)</option><option value="datadog">Datadog (logs)</option>
<option value="otlp">OpenTelemetry collector (OTLP logs, its /v1/logs URL)</option></select></label>
<label class="full">URL <input name="siem_url" placeholder="https://splunk.example.com:8088/services/collector/event"></label>
<label class="full">Token <input name="siem_token" type="password" autocomplete="off"
placeholder="HEC token, Datadog API key, or a Bearer token"></label>
<div class="form-actions full"><button class="btn primary" type="submit">Save</button>
<button class="btn" type="button" id="siemTest">Send a test record</button><span class="hint" id="siemMsg"></span></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>Customers</h2><span class="sub" id="custCount"></span></div>
<p class="lede">Agents copy what they read: a ticket's customer ends up in a pull request description or a public issue.
When an agent puts text where others can read it (a PR, an issue or comment, a gist, a chat post) and it names a
customer on this list, it waits for a person. Card numbers, social security numbers, IBANs and lists of people's
emails or phone numbers are held there too, with no list needed.</p>
<form class="form-grid wide" id="custForm">
<label class="full">Customer names, one per line
<textarea name="customers" rows="6" placeholder="Acme Corp Globex initech.com"></textarea>
<span class="hint">Whole names, any case. Add the forms people write: the company, its domain, a product name.
Kept on this gateway; only admins see the list.</span></label>
<div class="form-actions full"><button class="btn primary" type="submit">Save list</button>
<span class="hint" id="custMsg"></span></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>Agents that connect by URL</h2><span class="sub">MCP</span></div>
<p class="lede">ChatGPT and claude.ai connectors, Devin, n8n and other cloud agents use MCP servers by URL. Add the app's
MCP server here and give the agent its Squidbrake URL instead: every call is checked like any other and recorded as
the agent that made it.</p>
<div class="tbl-wrap"><table class="events"><thead><tr><th>Name</th><th>URL for your agents</th><th>The app's server</th>
<th>Status</th><th></th></tr></thead><tbody id="mcpList"></tbody></table></div>
<p class="hint">The agent signs in with one of its keys (Team, add an AI agent): <code>Authorization: Bearer gw_…</code>.
If the app only takes a URL, add <code>?key=gw_…</code> to it, and treat that URL like a password.</p>
<form class="form-grid wide" id="mcpForm">
<label class="full">App <select name="app" id="mcpApp"><option value="">Another MCP server (fill in below)</option></select>
<span class="hint" id="mcpAppHint">Pick an app and only its token is left to paste.</span></label>
<label>Name <input name="name" placeholder="github" pattern="[a-z0-9][a-z0-9\-]{0,39}" required>
<span class="hint">Lowercase, used in the URL and in rules, e.g. github.create_issue</span></label>
<label>The app's MCP server URL <input name="url" placeholder="https://api.githubcopilot.com/mcp/" required></label>
<label>How it signs in <select name="auth" id="mcpAuth">
<option value="header">A token in a header</option>
<option value="oauth">Sign in to the app (OAuth)</option></select></label>
<label data-auth="header">Header it needs (optional) <input name="hname" placeholder="Authorization"></label>
<label data-auth="header">Header value <input name="hvalue" type="password" placeholder="Bearer ghp_…" autocomplete="off">
<span class="hint">Kept on this gateway and never shown again.</span></label>
<p class="hint full" data-auth="oauth" hidden>After adding it, click <b>Connect</b> and sign in to the app. Redirect URL, if the
app asks for one: <code id="mcpRedirect"></code></p>
<label data-auth="oauth" hidden>Your OAuth app's client ID (optional) <input name="client_id" autocomplete="off"
placeholder="only if the app needs its own OAuth app"></label>
<label data-auth="oauth" hidden>Client secret <input name="client_secret" type="password" autocomplete="off"></label>
<label data-auth="oauth" hidden>Scopes (optional) <input name="scope" placeholder="as the app's docs list them"></label>
<div class="form-actions full"><button class="btn primary" type="submit">Add server</button>
<span class="hint" id="mcpMsg"></span></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>Rules</h2></div>
<p class="lede">What agents may do is decided by <code>rules.yaml</code> on the gateway: allow, block, or hold for a person
(optionally a specific role). Edits apply within seconds. Use <b>Test a rule</b> to check what would happen.</p>
<div id="ruleSuggest"></div>
<h3 class="sub-h">Starter packs</h3>
<p class="hint">Rules for a kind of agent, added to your rules.yaml after the read rule, so blocks above still win. A backup is kept.</p>
<div id="rulePacks" class="packs"></div>
<h3 class="sub-h">History</h3>
<p class="hint">Every version of rules.yaml that decided something, newest first. <b>Changes</b> shows what's different
today; <b>Put back</b> restores that version (a backup of today's is kept, and it's in the audit trail).</p>
<div id="ruleHistory"></div>
<pre id="ruleDiff" class="mono" hidden style="max-height:320px;overflow:auto;font-size:12px;background:var(--page);border:1px solid var(--border);border-radius:8px;padding:8px"></pre>
<h3 class="sub-h">Test a rule</h3>
<form class="form-grid" id="ruleTest">
<label>Tool name <input name="name" placeholder="e.g. stripe.create_refund" required></label>
<label>Agent (source) <input name="source" placeholder="optional"></label>
<label class="full">Input (JSON) <input name="input" placeholder='{"amount": 250}'></label>
<div class="form-actions full"><button class="btn" type="submit">Test a rule</button><span class="hint" id="ruleResult"></span></div>
</form>
</section>
</div>
</div>
<dialog id="phoneDlg" class="wide-dlg">
<form method="dialog">
<h3>Approve from your phone</h3>
<p>Scan with your phone's camera. It opens your approvals inbox, signed in as <b id="phoneAs"></b>.
Anyone who scans this can act as you, so don't share it.</p>
<p class="warn" id="phoneWarn" hidden>No public address is set, so this only works on the same Wi-Fi/PC. An admin can set it in Settings.</p>
<div class="qr"><img id="phoneQr" alt="QR code for your phone approvals page"></div>
<p class="hint">For instant pop-ups, also turn on phone notifications in Settings. On this computer, 🔔 Alerts gives you a desktop pop-up.</p>
<div class="acts"><button class="btn primary" value="close">Done</button></div>
</form>
</dialog>
<dialog id="stopDlg">
<form method="dialog" id="stopForm">
<h3>Stop agents?</h3>
<p id="stopWhat">Every action any agent tries will be blocked immediately, until an admin resumes them. Actions already
running aren't interrupted.</p>
<input id="stopReason" placeholder="Reason (shown to the agents and in the audit trail)" maxlength="500" aria-label="Reason">
<label style="display:block;margin-top:10px">For how long
<select id="stopFor" aria-label="For how long">
<option value="">Until someone turns it off</option>
<option value="15">15 minutes</option>
<option value="60">1 hour</option>
<option value="240">4 hours</option>
</select></label>
<div class="acts">
<button class="btn" type="button" id="stopCancel">Cancel</button>
<button class="btn danger" type="submit">⏹ Stop now</button>
</div>
</form>
</dialog>
<dialog id="noteDlg">
<form method="dialog" id="noteForm">
<h3>Send a note</h3>
<p id="noteWhat">The agent gets it with its next action, and it's kept in the audit trail. It steers; it can't approve
anything.</p>
<input id="noteText" placeholder="e.g. use the staging database, not prod" maxlength="2000" aria-label="Note" required
style="width:100%;border:1px solid var(--border);background:var(--page);border-radius:8px;padding:8px 10px">
<div class="acts">
<button class="btn" type="button" id="noteCancel">Cancel</button>
<button class="btn primary" type="submit">✉ Send</button>
</div>
</form>
</dialog>
<aside class="drawer" id="drawer" aria-hidden="true" aria-labelledby="drTitle">
<div class="dr-head">
<div>
<h3 id="drTitle"></h3>
<div id="drSub" class="muted" style="font-size:12.5px;margin-top:4px"></div>
</div>
<button class="btn x" id="drClose" aria-label="Close">✕</button>
</div>
<div class="dr-body" id="drBody"></div>
</aside>
<div class="backdrop" id="backdrop"></div>
<dialog id="shortcutDlg" class="shortcut-help">
<h3>Approval queue shortcuts</h3>
<table class="mini-table">
<tbody>
<tr><td><kbd>j</kbd></td><td>Select the next waiting request (nothing is selected until you choose)</td></tr>
<tr><td><kbd>k</kbd></td><td>Previous waiting request</td></tr>
<tr><td><kbd>a</kbd></td><td>Approve the selected request: press <kbd>a</kbd> twice within 3 seconds</td></tr>
<tr><td><kbd>r</kbd></td><td>Reject: focus the note field, then press Enter to reject (Esc to cancel)</td></tr>
<tr><td><kbd>?</kbd></td><td>Show this list</td></tr>
</tbody>
</table>
<div class="acts"><button class="btn primary" type="button" id="shortcutDone">Done</button></div>
</dialog>
<dialog id="keyDlg">
<form method="dialog" id="keyForm">
<h3>Gateway API key</h3>
<p>Paste your key (it starts with <code>gw_</code>). The admin key was printed when the server first started; anyone with access to the server can make a new one with <code>python server.py add-key NAME --approver</code>. It's kept in this browser only.</p>
<input type="password" id="keyInput" autocomplete="off" placeholder="secret" aria-label="API key">
<div class="err-msg" id="keyErr"></div>
<div class="acts">
<a class="btn" href="/sso/login" id="ssoBtn" hidden>Sign in with SSO</a>
<button class="btn" type="button" id="keyCancel">Cancel</button>
<button class="btn primary" type="submit">Connect</button>
</div>
</form>
</dialog>
<script>
(() => {
"use strict";
const $ = (s) => document.querySelector(s);
const RANGES = {
"1h": { hours: 1, bucket: "hour", label: "Calls per hour" },
"24h": { hours: 24, bucket: "hour", label: "Calls per hour" },
"7d": { hours: 168, bucket: "hour", label: "Calls per hour" },
"30d": { hours: 720, bucket: "day", label: "Calls per day" },
"1y": { hours: 8760, bucket: "day", label: "Calls per day" },
};
const FILTERS = ["q", "status", "kind", "source", "session_id", "name", "client"];
const STATUS = {
completed: { label: "Completed", icon: "✓" },
failed: { label: "Failed", icon: "!" },
denied: { label: "Denied", icon: "✕" },
pending: { label: "Pending", icon: "…" },
awaiting_approval: { label: "Awaiting approval", icon: "?" },
};
// ---------- state (mirrored into the URL so views are linkable)
const params = new URLSearchParams(location.search);
const state = { range: RANGES[params.get("range")] ? params.get("range") : "24h", f: {} };
FILTERS.forEach((k) => { if (params.get(k)) state.f[k] = params.get(k); });
let rows = [], nextBefore = null, selectedId = null, lastStats = null, gen = 0, liveBusy = false;
let listMode = "chains";
try { if (localStorage.getItem("gw.list") === "calls") listMode = "calls"; } catch {}
const openChains = new Set();
const closedChains = new Set();
let me = null, queue = [], drawerEvent = null, view = "activity";
let shortcutQueueId = null, shortcutIndexHint = 0, approveArm = null, queueIds = []; // approveArm: {id, timer} while waiting for the second `a`
const linkedEvent = params.get("event"); // /dashboard?event=<id> (webhook links) opens that event
const store = {
get() { try { return localStorage.getItem("gw.key") || ""; } catch { return ""; } },
set(v) { try { v ? localStorage.setItem("gw.key", v) : localStorage.removeItem("gw.key"); } catch {} },
};
let apiKey = store.get();
// /dashboard#key=... signs this browser in (the fragment never reaches the server) and is then removed.
const frag = new URLSearchParams(location.hash.slice(1)), fragKey = frag.get("key");
if (fragKey) {
apiKey = fragKey.trim();
store.set(apiKey);
}
// #connected=cursor,codex: opened by Squidbrake itself right after it connected agents installed since setup
const joined = (frag.get("connected") || "").split(",").filter((a) => /^[a-z0-9-]{1,40}$/.test(a));
if (fragKey || joined.length) history.replaceState(null, "", location.pathname + location.search);
if (joined.length) {
const names = { "claude-code": "Claude Code", "cursor": "Cursor", "codex": "Codex", "gemini-cli": "Gemini CLI",
"vscode": "VS Code Copilot", "antigravity": "Antigravity", "qwen-code": "Qwen Code", "factory": "Factory Droid", "kiro": "Kiro" };
const list = joined.map((a) => names[a] || a).join(", ");
setTimeout(() => toast(`${list} ${joined.length > 1 ? "are" : "is"} now checked by Squidbrake. Restart ${joined.length > 1 ? "them" : "it"} once to load the hook.`, 15000));
}
try { $("#auto").checked = localStorage.getItem("gw.auto") !== "0"; } catch {}
function syncUrl() {
const p = new URLSearchParams();
if (state.range !== "24h") p.set("range", state.range);
FILTERS.forEach((k) => state.f[k] && p.set(k, state.f[k]));
const qs = p.toString();
history.replaceState(null, "", location.pathname + (qs ? "?" + qs : ""));
}
// ---------- api
class AuthError extends Error {}
async function api(path, query = {}) {
const url = new URL(path, location.origin);
Object.entries(query).forEach(([k, v]) => v != null && v !== "" && url.searchParams.set(k, v));
const res = await fetch(url, { headers: apiKey ? { "X-Gateway-Key": apiKey } : {} });
if (res.status === 401) throw new AuthError("unauthorized");
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function apiSend(method, path, body) {
const res = await fetch(new URL(path, location.origin), {
method,
headers: { "Content-Type": "application/json", ...(apiKey ? { "X-Gateway-Key": apiKey } : {}) },
body: method === "DELETE" ? undefined : JSON.stringify(body || {}),
});
let data = null;
try { data = await res.json(); } catch {}
if (!res.ok) {
const d = data && data.detail;
throw new Error(Array.isArray(d) ? d.map((x) => x.msg).join("; ") : d || `${res.status} ${res.statusText}`);
}
return data;
}
const apiPost = (path, body) => apiSend("POST", path, body);
// ---------- passkeys (Face ID, fingerprint): sign approvals with this device
const fromB64u = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
const toB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const passkeysWork = () => !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
let myDevices = [], devicesRequired = false;
async function loadDevices() {
try { const r = await api("/v1/devices"); myDevices = r.devices.filter((d) => d.person === (me && me.client)); devicesRequired = r.required; return r; }
catch { myDevices = []; return null; }
}
// The device's signature over exactly this decision, or null when this browser has no registered passkey.
async function deviceProof(eventId, outcome) {
if (!myDevices.length || !passkeysWork()) {
if (devicesRequired && outcome === "approve") throw new Error("Approvals need Face ID or fingerprint: set it up in Settings on this device, or approve from your registered phone.");
return null;
}
const ch = await apiPost(`/v1/events/${encodeURIComponent(eventId)}/challenge`, { outcome });
const cred = await navigator.credentials.get({ publicKey: { challenge: fromB64u(ch.challenge), rpId: ch.rp_id,
userVerification: "required", timeout: 60000, allowCredentials: ch.allow_credentials.map((id) => ({ type: "public-key", id: fromB64u(id) })) } });
return { credential_id: toB64u(cred.rawId), statement: ch.statement, authenticator_data: toB64u(cred.response.authenticatorData),
client_data_json: toB64u(cred.response.clientDataJSON), signature: toB64u(cred.response.signature) };
}
async function registerDevice(name) {
const o = await apiPost("/v1/devices/register/start");
const cred = await navigator.credentials.create({ publicKey: { ...o, challenge: fromB64u(o.challenge),
user: { ...o.user, id: fromB64u(o.user.id) }, excludeCredentials: o.excludeCredentials.map((c) => ({ ...c, id: fromB64u(c.id) })) } });
return apiPost("/v1/devices/register/finish", { name, challenge: o.challenge, credential_id: toB64u(cred.rawId),
client_data_json: toB64u(cred.response.clientDataJSON), attestation_object: toB64u(cred.response.attestationObject) });
}
let toastTimer;
function toast(msg, ms = 3500) {
let t = $("#toast");
if (!t) { t = el("div", { id: "toast", class: "toast", role: "status" }); document.body.append(t); }
t.textContent = msg; t.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => (t.hidden = true), ms);
}
function sinceIso() {
return new Date(Date.now() - RANGES[state.range].hours * 3600e3).toISOString();
}
function setConn(kind, text) {
$("#conn").className = "conn " + kind;
$("#connText").textContent = text;
}
// ---------- formatting
const nf = new Intl.NumberFormat();
const pad = (n) => String(n).padStart(2, "0");
function fmtTime(iso) {
const d = new Date(iso), now = new Date();
const t = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
if (d.toDateString() === now.toDateString()) return t;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + t;
}
function fmtAgo(iso) {
const s = Math.round((Date.now() - new Date(iso)) / 1000);
if (s < 60) return `${Math.max(s, 0)}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function fmtDur(ms) {
if (ms == null) return "";
if (ms < 1) return "<1 ms";
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
}
function el(tag, attrs = {}, ...kids) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === "class") n.className = v;
else if (k === "text") n.textContent = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else n.setAttribute(k, v);
}
kids.flat().forEach((c) => c != null && n.append(c.nodeType ? c : document.createTextNode(String(c))));
return n;
}
function statusEl(s) {
const m = STATUS[s] || { label: s || "unknown", icon: "?" };
return el("span", { class: "st " + s }, el("i", { "aria-hidden": "true", text: m.icon }), m.label);
}
// ---------- agent identity + chains
// A chain is one conversation (session). Calls with no session are grouped under the agent and marked
// with a dashed border so they are not read as one conversation. The same agent name always gets the same color.
function agentOf(e) {
return ((e && (e.source || e.client)) || "unknown").trim() || "unknown";
}
function agentField(e) { return e && e.source ? "source" : "client"; }
function agentHue(name) {
let h = 2166136261;
for (let i = 0; i < name.length; i++) h = Math.imul(h ^ name.charCodeAt(i), 16777619);
return (Math.abs(h) % 24) * 15;
}
function monogram(name) {
const parts = String(name).split(/[^A-Za-z0-9]+/).filter(Boolean);
const raw = parts.length >= 2 ? parts[0][0] + parts[1][0] : String(name).slice(0, 2);
return raw.toUpperCase();
}
function avatarEl(name, size) {
const n = name || "unknown";
const h = agentHue(n);
return el("span", {
class: "avatar" + (size === "sm" ? " sm" : ""),
style: `--h:${h};--hl:${h > 40 && h < 90 ? 32 : 38}%`,
title: n, "aria-hidden": "true", text: monogram(n),
});
}
function shortId(id) {
if (!id) return "";
return id.length <= 14 ? id : id.slice(0, 6) + "…" + id.slice(-4);
}
function chainTone(steps) {
for (const s of ["awaiting_approval", "denied", "failed", "pending"]) {
if (steps.some((e) => e.status === s)) return s;
}
return "completed";
}
function groupChains(list) {
const sessions = new Map(), loose = new Map();
for (const e of list) {
if (e.session_id) {
const g = sessions.get(e.session_id) || { key: "s:" + e.session_id, session: e.session_id, loose: false, steps: [] };
g.steps.push(e);
sessions.set(e.session_id, g);
} else {
const name = agentOf(e);
const g = loose.get(name) || { key: "loose:" + name, session: null, loose: true, steps: [] };
g.steps.push(e);
loose.set(name, g);
}
}
const chains = [...sessions.values(), ...loose.values()];
for (const c of chains) {
c.steps.sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.id < b.id ? -1 : 1);
c.latest = c.steps[c.steps.length - 1];
c.started = c.steps[0].created_at;
c.agent = agentOf(c.latest);
c.field = agentField(c.latest);
c.client = c.latest.client;
c.tone = chainTone(c.steps);
c.also = [...new Set(c.steps.map(agentOf))].filter((n) => n !== c.agent);
}
chains.sort((a, b) => a.latest.created_at < b.latest.created_at ? 1 : a.latest.created_at > b.latest.created_at ? -1 : 0);
return chains;
}
function setAgentFilter(name, field) {
const on = state.f[field] === name;
delete state.f.source; delete state.f.client;
setFilter(field, on ? "" : name);
}
function toggleChain(key, openNow) {
if (openNow) { openChains.delete(key); closedChains.add(key); }
else { closedChains.delete(key); openChains.add(key); }
renderRows();
}
function edgeEl() { return el("span", { class: "edge", "aria-hidden": "true" }); }
function stepButton(s) {
const m = STATUS[s.status] || { label: s.status || "unknown", icon: "?" };
return el("button", {
type: "button",
class: "node " + (s.status || "") + (s.id === selectedId ? " sel" : ""),
"data-id": s.id,
title: `${s.name} · ${m.label}`,
"aria-label": `${s.name}, ${m.label}`,
onclick: (ev) => { ev.stopPropagation(); openDrawer(s.id); },
},
el("span", { class: "nm", text: s.name }),
el("span", { class: "meta", text: fmtTime(s.created_at) + (s.would ? (s.would === "deny" ? " · would block" : " · would hold") : "") }),
);
}
function flowEl(steps, chainKey) {
const cap = 8;
const truncated = steps.length > cap;
const shown = truncated ? [steps[0], ...steps.slice(-(cap - 1))] : steps;
const skipped = steps.length - shown.length;
const box = el("div", { class: "flow", role: "list", "aria-label": "Calls in this chain, oldest first" });
const add = (node) => { if (box.childNodes.length) box.append(edgeEl()); box.append(node); };
shown.forEach((s, i) => {
if (i === 1 && skipped > 0) {
add(el("button", {
type: "button", class: "node more", title: "Show every step",
onclick: (ev) => { ev.stopPropagation(); toggleChain(chainKey, false); },
}, el("span", { class: "nm", text: `+${skipped}` }), el("span", { class: "meta", text: "earlier" })));
}
add(stepButton(s));
});
return box;
}
function sparkEl(steps) {
return el("div", { class: "spark", role: "list", "aria-label": "Whole chain" }, steps.map((s, i) => {
const m = STATUS[s.status] || { label: s.status || "" };
return el("button", {
type: "button",
class: "spark-dot " + (s.status || "") + (s.id === selectedId ? " sel" : ""),
"data-id": s.id,
title: `${i + 1}. ${s.name} · ${m.label}`,
"aria-label": `Step ${i + 1} of ${steps.length}: ${s.name}, ${m.label}`,
onclick: (ev) => { ev.stopPropagation(); openDrawer(s.id); },
});
}));
}
function tallyEls(steps) {
const order = ["awaiting_approval", "denied", "failed", "pending", "completed"];
const counts = {};
for (const s of steps) counts[s.status] = (counts[s.status] || 0) + 1;
return order.filter((k) => counts[k]).map((k) => {
const m = STATUS[k] || { label: k, icon: "?" };
return el("span", { class: "tally " + k, title: m.label }, el("i", { "aria-hidden": "true", text: m.icon }), String(counts[k]));
});
}
function chainMeta(c) {
const bits = [];
if (c.loose) bits.push(el("span", { text: "No session" }));
else bits.push(el("button", {
type: "button", class: "pill-link", title: c.session, text: "chain " + shortId(c.session),
onclick: () => setFilter("session_id", state.f.session_id === c.session ? "" : c.session),
}));
if (c.client && c.client !== c.agent) bits.push(el("span", { text: "key " + c.client }));
bits.push(el("span", { text: `${c.steps.length} call${c.steps.length === 1 ? "" : "s"}` }));
if (c.steps.length > 1) {
const ms = new Date(c.latest.created_at) - new Date(c.started);
if (ms >= 1000) bits.push(el("span", { text: "ran " + fmtDur(ms) }));
}
bits.push(el("span", { text: fmtAgo(c.latest.created_at) }));
const wrap = el("div", { class: "chain-meta" });
bits.forEach((b, i) => { if (i) wrap.append(" · "); wrap.append(b); });
return wrap;
}
function focusStep(steps) {
return steps.find((s) => s.status === "awaiting_approval")
|| [...steps].reverse().find((s) => s.status === "denied" || s.status === "failed")
|| null;
}
function railEl(steps, currentId) {
return el("ol", { class: "rail" }, steps.map((s, i) => el("li", { class: (s.id === currentId ? "on " : "") + (s.status || ""), "data-id": s.id },
el("div", { class: "tick", "aria-hidden": "true" },
el("span", { class: "st " + (s.status || "") }, el("i", { text: (STATUS[s.status] || { icon: "?" }).icon })),
el("span", { class: "line" })),
el("div", { class: "body" },
el("div", { class: "st-line" },
el("button", { type: "button", class: "pill-link mono step-name", text: s.name, onclick: () => openDrawer(s.id) }),
el("span", { class: "muted", text: `${i + 1}/${steps.length}` }),
el("span", { class: "muted", text: (STATUS[s.status] || {}).label || s.status || "" }),
el("span", { class: "muted", text: fmtTime(s.created_at) }),
s.would ? el("span", { class: "tag would", text: s.would === "deny" ? "would block" : "would hold" }) : null,
s.decided_by && s.decided_by !== "timeout" ? el("span", { class: "muted", text: `${s.decision === "allow" ? "approved" : "rejected"} by ${s.decided_by}${s.decision_note ? ": “" + s.decision_note + "”" : ""}` }) : null),
summarizeInput(s.input) ? el("div", { class: "st-in", text: summarizeInput(s.input) }) : null,
summarizeOutput(s.output) ? el("div", { class: "st-out", text: summarizeOutput(s.output) }) : null,
s.status === "denied" && s.reason ? el("div", { class: "st-in", text: s.reason }) : null,
s.error ? el("div", { class: "st-in", text: s.error }) : null,
),
)));
}
function chainCard(c, freshIds) {
const open = !closedChains.has(c.key) && (openChains.has(c.key) || (!!state.f.session_id && c.session === state.f.session_id));
const hue = agentHue(c.agent);
const fresh = c.steps.some((s) => freshIds.has(s.id));
const oldestLoaded = rows.reduce((a, e) => e.created_at < a ? e.created_at : a, rows[0].created_at);
const maybeMore = !c.loose && nextBefore && c.steps[0].created_at === oldestLoaded;
const focus = open ? null : focusStep(c.steps);
const kids = [
el("div", { class: "chain-head" },
avatarEl(c.agent),
el("div", { class: "chain-id" },
el("div", { class: "chain-name" },
el("button", {
type: "button", class: "agent-name", title: `Filter by ${c.agent}`, text: c.agent,
onclick: () => setAgentFilter(c.agent, c.field),
}),
c.also.length ? el("span", { class: "muted", style: "font-weight:400;font-size:12px", text: "with " + c.also.join(", ") }) : null),
chainMeta(c),
c.loose ? el("div", { class: "loose-note", text: c.steps.length > 1
? "These calls have no session, so they are not one conversation. Grouped under the agent."
: "This call has no session, so it isn’t part of a chain." }) : null,
),
el("div", { class: "chain-side" }, tallyEls(c.steps), c.session ? sessionControl(c.session, true) : null),
),
];
if (c.steps.length > 8) kids.push(sparkEl(c.steps));
if (!open) kids.push(flowEl(c.steps, c.key));
if (focus) {
const m = STATUS[focus.status] || { label: focus.status, icon: "?" };
kids.push(el("div", { class: "chain-focus" },
el("span", { class: "st " + focus.status }, el("i", { "aria-hidden": "true", text: m.icon }), m.label),
" ",
el("button", { type: "button", class: "pill-link mono", text: focus.name, onclick: () => openDrawer(focus.id) }),
summarizeInput(focus.input) ? el("div", { class: "st-in", text: summarizeInput(focus.input) }) : null,
focus.reason ? el("div", { class: "sec", style: "margin-top:4px", text: focus.reason }) : null,
));
}
if (open && c.steps.length > 1) kids.push(railEl(c.steps, selectedId));
if (c.steps.length > 1 || maybeMore) {
kids.push(el("div", { class: "chain-foot" },
c.steps.length > 1 ? el("button", {
type: "button", class: "linkish", text: open ? "Hide details" : "Show details",
onclick: () => toggleChain(c.key, open),
}) : el("span"),
maybeMore ? el("span", { class: "hint", text: "Earlier calls in this chain may still be on the next page." }) : null,
));
}
return el("article", {
class: ["chain", c.tone, c.loose ? "loose" : "", fresh ? "fresh" : ""].filter(Boolean).join(" "),
style: `--h:${hue}`,
"data-chain": c.key,
"aria-label": `${c.agent}, ${c.loose ? "loose calls" : "chain " + (c.session || "")}, ${c.steps.length} calls`,
}, kids);
}
function renderAgentStrip(chains) {
const box = $("#agentStrip");
const map = new Map();
for (const c of chains) {
const key = c.field + ":" + c.agent;
const a = map.get(key) || { name: c.agent, field: c.field, chains: 0, calls: 0 };
a.chains += 1; a.calls += c.steps.length;
map.set(key, a);
}
const agents = [...map.values()].sort((a, b) => b.calls - a.calls);
box.hidden = agents.length === 0;
box.replaceChildren(...agents.map((a) => {
const on = state.f[a.field] === a.name;
return el("button", {
type: "button", class: "agent-chip", "aria-pressed": String(on),
title: on ? `Clear ${a.name}` : `Show only ${a.name}`,
onclick: () => setAgentFilter(a.name, a.field),
}, avatarEl(a.name, "sm"), el("span", { text: a.name }),
el("span", { class: "ct", text: a.chains > 1 ? `${a.chains} chains · ${a.calls} calls` : `${a.calls} call${a.calls === 1 ? "" : "s"}` }));
}));
}
function applyListMode() {
const chainsOn = listMode === "chains";
$("#chains").hidden = !chainsOn;
$("#callsWrap").hidden = chainsOn;
$("#evTitle").textContent = chainsOn ? "Chains" : "Calls";
document.querySelectorAll("#evView button").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.v === listMode)));
}
// ---------- filters UI
function renderFilters() {
document.querySelectorAll("#range button").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.r === state.range)));
["q", "status", "kind", "source", "session_id"].forEach((k) => {
const input = $("#" + k);
if (document.activeElement !== input) input.value = state.f[k] || "";
});
// exact-match filters without their own input show as removable chips
const chips = $("#chips");
chips.replaceChildren();
["name", "client"].forEach((k) => {
if (!state.f[k]) return;
chips.append(el("span", { class: "chip" }, `${k}: `, el("b", { text: state.f[k] }),
el("button", { "aria-label": `Remove ${k} filter`, text: "×", onclick: () => setFilter(k, "") })));
});
$("#chartTitle").textContent = RANGES[state.range].label;
}
function setFilter(k, v) {
if (v) state.f[k] = v; else delete state.f[k];
syncUrl(); renderFilters(); reload();
}
// ---------- tiles
function renderTiles(stats) {
const by = stats.by_status || {};
const total = Object.values(by).reduce((a, b) => a + b, 0);
const tiles = $("#tiles");
tiles.replaceChildren();
const mk = (key, label, value, lblNode) => el("button", {
class: "card tile", "aria-pressed": String((state.f.status || "") === key),
onclick: () => setFilter("status", state.f.status === key ? "" : key),
title: key ? `Show only ${label.toLowerCase()}` : "Show all statuses",
},
el("div", { class: "lbl" }, lblNode || label),
el("div", { class: "val", text: nf.format(value) }),
el("div", { class: "pct", text: key && total ? `${((value / total) * 100).toFixed(value / total < 0.01 && value ? 2 : 1)}%` : `last ${state.range}` }),
);
tiles.append(mk("", "Total calls", total));
for (const s of ["awaiting_approval", "completed", "failed", "denied", "pending"]) tiles.append(mk(s, STATUS[s].label, by[s] || 0, statusEl(s)));
}
// ---------- timeline chart
function buildBuckets(range) {
const { hours, bucket } = RANGES[range];
const out = [];
if (bucket === "hour") {
const end = new Date(); end.setUTCMinutes(0, 0, 0);
for (let i = hours - 1; i >= 0; i--) {
const d = new Date(end.getTime() - i * 3600e3);
out.push({ key: d.toISOString().slice(0, 13), start: d, ms: 3600e3 });
}
} else {
const end = new Date(); end.setUTCHours(0, 0, 0, 0);
for (let i = Math.ceil(hours / 24) - 1; i >= 0; i--) {
const d = new Date(end.getTime() - i * 86400e3);
out.push({ key: d.toISOString().slice(0, 10), start: d, ms: 86400e3 });
}
}
return out;
}
function niceTicks(max) {
if (max <= 0) return [0, 1];
const raw = max / 4, mag = 10 ** Math.floor(Math.log10(raw)), f = raw / mag;
const step = Math.max(1, (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * mag);
const top = Math.ceil(max / step) * step, ticks = [];
for (let v = 0; v <= top + 1e-9; v += step) ticks.push(v);
return ticks;
}
function bucketLabel(b, bucket, long) {
const d = b.start;
if (bucket === "day") return d.toLocaleDateString(undefined, long ? { weekday: "short", month: "short", day: "numeric" } : { month: "short", day: "numeric" });
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (!long) return hm;
const e = new Date(d.getTime() + b.ms);
return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${hm}–${pad(e.getHours())}:${pad(e.getMinutes())}`;
}
function renderChart(stats) {
const { bucket } = RANGES[state.range];
const buckets = buildBuckets(state.range);
const byKey = Object.fromEntries((stats.timeline || []).map((t) => [t.bucket, t]));
buckets.forEach((b) => Object.assign(b, byKey[b.key] || { count: 0, denied: 0, failed: 0 }));
const box = $("#chart");
const total = buckets.reduce((a, b) => a + b.count, 0);
renderChartTable(buckets, bucket);
if (!total) { box.replaceChildren(el("div", { class: "empty", text: "No calls in this period." })); return; }
const W = Math.max(box.clientWidth, 280), H = 200;
const m = { t: 10, r: 4, b: 24, l: 40 };
const pw = W - m.l - m.r, ph = H - m.t - m.b;
const ticks = niceTicks(Math.max(...buckets.map((b) => b.count)));
const top = ticks[ticks.length - 1];
const y = (v) => m.t + ph - (v / top) * ph;
const colW = pw / buckets.length;
const gap = colW > 6 ? 2 : colW > 3 ? 1 : 0;
const bw = Math.max(colW - gap, 1);
const NS = "http://www.w3.org/2000/svg";
const s = (tag, a) => { const n = document.createElementNS(NS, tag); for (const k in a) n.setAttribute(k, a[k]); return n; };
const svg = s("svg", { viewBox: `0 0 ${W} ${H}`, role: "img",
"aria-label": `${RANGES[state.range].label}: ${nf.format(total)} calls in the last ${state.range}. Use "View as table" for exact values.` });
for (const t of ticks) {
svg.append(s("line", { class: t === 0 ? "baseline" : "gridline", x1: m.l, x2: W - m.r, y1: y(t), y2: y(t) }));
const lbl = s("text", { class: "axis", x: m.l - 8, y: y(t) + 4, "text-anchor": "end" });
lbl.textContent = nf.format(t);
svg.append(lbl);
}
const every = Math.max(1, Math.ceil(buckets.length / Math.max(2, Math.floor(pw / 70))));
const hovers = [];
buckets.forEach((b, i) => {
const x = m.l + i * colW + gap / 2;
const hc = s("rect", { class: "hovercol", x: m.l + i * colW, y: m.t, width: colW, height: ph });
hovers.push(hc);
svg.append(hc);
if (b.count > 0) {
const h = Math.max(ph - (y(b.count) - m.t), 1.5), yt = m.t + ph - h;
const r = Math.min(4, bw / 2, h);
svg.append(s("path", { class: "bar",
d: `M${x},${yt + h}V${yt + r}A${r},${r} 0 0 1 ${x + r},${yt}H${x + bw - r}A${r},${r} 0 0 1 ${x + bw},${yt + r}V${yt + h}Z` }));
}
if (i % every === 0 && (i + every <= buckets.length || buckets.length <= 2)) {
const lbl = s("text", { class: "axis", x: m.l + i * colW + colW / 2, y: H - 6, "text-anchor": "middle" });
lbl.textContent = bucketLabel(b, bucket, false);
svg.append(lbl);
}
});
// one hit layer for the whole plot: bigger than any bar, works for 1px bars too
const hit = s("rect", { x: m.l, y: m.t, width: pw, height: ph, fill: "transparent" });
const tip = $("#tip");
let cur = -1;
const hide = () => { tip.style.display = "none"; if (cur >= 0) hovers[cur].classList.remove("on"); cur = -1; };
hit.addEventListener("mousemove", (ev) => {
const rect = svg.getBoundingClientRect(), sx = W / rect.width;
const i = Math.min(buckets.length - 1, Math.max(0, Math.floor(((ev.clientX - rect.left) * sx - m.l) / colW)));
if (i !== cur) {
if (cur >= 0) hovers[cur].classList.remove("on");
hovers[i].classList.add("on"); cur = i;
const b = buckets[i];
tip.replaceChildren(
el("div", { class: "t", text: bucketLabel(b, bucket, true) }),
el("div", { class: "r" }, el("span", { class: "sw" }), el("span", { class: "n", text: nf.format(b.count) }), "calls"),
b.denied ? el("div", { class: "r" }, statusEl("denied"), el("span", { class: "n", text: nf.format(b.denied) })) : null,
b.failed ? el("div", { class: "r" }, statusEl("failed"), el("span", { class: "n", text: nf.format(b.failed) })) : null,
);
tip.style.display = "block";
}
const boxRect = box.parentElement.getBoundingClientRect();
let left = ev.clientX - boxRect.left + 14;
if (left + tip.offsetWidth > boxRect.width - 4) left = ev.clientX - boxRect.left - tip.offsetWidth - 14;
tip.style.left = Math.max(4, left) + "px";
tip.style.top = Math.max(0, ev.clientY - boxRect.top - tip.offsetHeight - 10) + "px";
});
hit.addEventListener("mouseleave", hide);
hit.addEventListener("click", () => {
if (cur < 0) return;
const b = buckets[cur];
// Zoom the event list to that bucket.
state.window = { since: b.start.toISOString(), before: new Date(b.start.getTime() + b.ms).toISOString(), label: bucketLabel(b, bucket, true) };
reloadEvents();
});
svg.style.cursor = "pointer";
svg.append(hit);
box.replaceChildren(svg);
}
function renderChartTable(buckets, bucket) {
const wrap = $("#chartTable");
const tbl = el("table", { class: "mini-table" },
el("thead", {}, el("tr", {}, el("th", { text: bucket === "day" ? "Day" : "Hour" }), el("th", { text: "Calls" }), el("th", { text: "Denied" }), el("th", { text: "Failed" }))),
el("tbody", {}, buckets.slice().reverse().filter((b) => b.count).map((b) =>
el("tr", {}, el("td", { text: bucketLabel(b, bucket, true) }), el("td", { text: nf.format(b.count) }), el("td", { text: nf.format(b.denied) }), el("td", { text: nf.format(b.failed) })))),
);
wrap.replaceChildren(tbl);
}
// ---------- top tools
function renderTop(stats) {
const list = $("#top"), top = stats.top_names || [];
list.replaceChildren();
if (!top.length) { list.append(el("li", { class: "empty", text: "Nothing yet." })); return; }
const max = top[0].count;
top.forEach((t) => list.append(el("li", {},
el("button", { title: `Filter by ${t.name}`, onclick: () => setFilter("name", state.f.name === t.name ? "" : t.name) },
el("span", { class: "nm", text: t.name }),
el("span", { class: "ct", text: nf.format(t.count) }),
el("span", { class: "track" }, el("span", { class: "fill", style: `display:block;width:${(t.count / max) * 100}%` })),
))));
}
// ---------- events table
function rowEl(e, fresh) {
const link = (k, v) => v ? el("button", { class: "pill-link trunc", title: `Filter by ${k}: ${v}`, text: v,
onclick: (ev) => { ev.stopPropagation(); setFilter(k, v); } }) : el("span", { class: "muted", text: "—" });
const tr = el("tr", { tabindex: "0", "data-id": e.id, class: [fresh ? "fresh" : "", e.id === selectedId ? "sel" : ""].join(" ").trim() || null,
onclick: () => openDrawer(e.id), onkeydown: (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); openDrawer(e.id); } } },
el("td", { title: new Date(e.created_at).toLocaleString() }, el("span", { text: fmtTime(e.created_at) }), " ", el("span", { class: "muted", text: fmtAgo(e.created_at) })),
el("td", {}, statusEl(e.status), e.would ? el("span", { class: "tag would", text: e.would === "deny" ? "would block" : "would hold" }) : null),
el("td", { class: "mono trunc", title: e.name, text: e.name }),
el("td", { class: "sec", text: e.kind }),
el("td", {}, avatarEl(agentOf(e), "sm"), " ", link(agentField(e), agentOf(e))),
el("td", { class: "mono" }, link("session_id", e.session_id)),
el("td", { class: "num sec", text: fmtDur(e.duration_ms) }),
el("td", { class: "mono sec trunc", title: e.reason || "", text: e.rule_id || "" }),
);
return tr;
}
function renderRows(freshIds = new Set()) {
const tbody = $("#rows");
tbody.replaceChildren(...rows.map((e) => rowEl(e, freshIds.has(e.id))));
const chains = groupChains(rows);
$("#chains").replaceChildren(...chains.map((c) => chainCard(c, freshIds)));
renderAgentStrip(chains);
applyListMode();
$("#noRows").hidden = rows.length > 0;
const narrowed = Object.keys(state.f).some((k) => state.f[k]) || state.window;
$("#noRows").replaceChildren(
el("div", { class: "empty-title", text: narrowed ? "Nothing matches" : (listMode === "chains" ? "No chains yet" : "No calls yet") }),
el("div", { text: narrowed
? "Try a wider time range, or clear a filter."
: "When an agent works through a session, the whole run shows up here as one chain." }),
);
$("#more").hidden = !nextBefore;
const chainN = chains.length;
$("#evCount").textContent = rows.length
? (listMode === "chains"
? `${nf.format(chainN)} chain${chainN === 1 ? "" : "s"} · ${nf.format(rows.length)} call${rows.length === 1 ? "" : "s"}${nextBefore ? "" : " · all loaded"}`
: `${nf.format(rows.length)} shown${nextBefore ? "" : " · all loaded"}`)
: "";
const info = $("#footInfo");
info.replaceChildren();
if (state.window) {
info.append(`Showing ${state.window.label} `,
el("button", { class: "linkish", text: "× back to full range", onclick: () => { state.window = null; reloadEvents(); } }));
} else info.append(listMode === "chains" ? `Chains newest first · range ${state.range}` : `Newest first · range ${state.range}`);
}
function eventQuery(before) {
const w = state.window;
return { ...state.f, since: w ? w.since : sinceIso(), before: before || (w ? w.before : null), limit: 100 };
}
// Every load carries the generation it started in; a response from an older generation
// (filters changed while it was in flight) is dropped instead of overwriting the new view.
async function loadEvents(append, g) {
const data = await api("/v1/events", eventQuery(append ? nextBefore : null));
if (g !== gen) return;
rows = append ? rows.concat(data.events) : data.events;
nextBefore = data.next_before;
renderRows();
}
// Live refresh: merge the newest page into what's loaded instead of resetting scroll/paging.
async function refreshEvents(g) {
if (state.window) return;
const data = await api("/v1/events", eventQuery(null));
if (g !== gen) return;
const byId = new Map(rows.map((r, i) => [r.id, i]));
const fresh = [];
for (const e of data.events) {
if (byId.has(e.id)) rows[byId.get(e.id)] = e;
else fresh.push(e);
}
const cutoff = sinceIso();
rows = fresh.concat(rows).filter((r) => r.created_at >= cutoff.slice(0, 23));
if (!rows.length) nextBefore = data.next_before;
renderRows(new Set(rows.length === fresh.length ? [] : fresh.map((e) => e.id)));
}
async function loadStats(g) {
const { hours, bucket } = RANGES[state.range];
// Tiles always show every status (they are the status filter); chart + top tools follow all filters.
const { status, ...rest } = state.f;
const [all, filtered] = await Promise.all([
api("/v1/stats", { ...rest, hours, bucket }),
status ? api("/v1/stats", { ...state.f, hours, bucket }) : null,
]);
if (g !== gen) return;
lastStats = filtered || all;
renderTiles(all); renderChart(lastStats); renderTop(lastStats);
}
async function run(g, fn) {
try {
await fn();
if (g === gen) setConn("ok", "Connected");
} catch (e) {
if (g !== gen) return;
if (e instanceof AuthError) { setConn("err", "API key required"); openKeyDialog(!!apiKey); }
else setConn("err", e.message.startsWith("403") ? "Not allowed with this key" : "Can't reach gateway");
}
}
// User actions start a new generation and always run.
// What Squidbrake stopped in the last 7 days, worth showing (and sharing): only when it stopped something
async function loadSaved() {
const a = (await api("/v1/reports/week")).actions || {}, n = (a.blocked || 0) + (a.rejected || 0);
const card = $("#savedCard");
if (!card) return;
card.hidden = !n;
if (!n) return;
$("#savedTitle").textContent = `Squidbrake stopped ${n} risky agent action${n === 1 ? "" : "s"} in the last 7 days`;
const text = `My AI coding agents tried ${n} risky action${n === 1 ? "" : "s"} this week (deleting files, force-pushing, `
+ `reading secrets). Squidbrake stopped ${n === 1 ? "it" : "each one"} before it ran. `
+ `https://squidbrake.com/go/share`;
$("#savedShare").href = `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent(text)}`;
}
const reload = () => {
state.window = null; const g = ++gen;
loadSaved().catch(() => {});
return run(g, () => Promise.all([loadMe().then(() => Promise.all([loadQueue(), loadControls(), loadView()])),
loadStats(g), loadEvents(false, g)]));
};
const reloadEvents = () => { const g = ++gen; return run(g, () => loadEvents(false, g)); };
// Live ticks never overlap each other and yield to user actions.
const live = async () => {
if (liveBusy) return;
liveBusy = true;
const g = gen;
try {
await run(g, () => Promise.all([loadQueue(), loadControls(), loadStats(g), refreshEvents(g),
view === "reports" ? loadReports() : null]));
} finally { liveBusy = false; }
};
// ---------- approvals
async function loadMe() {
me = await api("/v1/me");
if (me.kind === "agent") { // it gets in, but every read is 403: say why, not "can't reach gateway"
openKeyDialog(true, `"${me.client}" is an agent's key: agents send actions, they can't open the dashboard. ` +
"Paste a person's key (make one with: squidbrake add-key YOUR-NAME --approver).");
throw new AuthError("agent key");
}
// the Team plan (license.py): what blocks and holds is free; a team's features need a license after the grace date
const lic = me.license, card = $("#planCard");
if (lic && card) {
card.hidden = lic.licensed;
const list = lic.features.join(", ");
$("#planTitle").textContent = lic.team_features ? `From ${lic.grace_until}, these are part of the Team plan` : "Free plan";
$("#planText").textContent = lic.team_features
? `${list[0].toUpperCase()}${list.slice(1)}. Blocking and holding stay free.`
: `${list[0].toUpperCase()}${list.slice(1)} are part of the Team plan. Blocking and holding are on.`;
$("#planLink").href = lic.upgrade_url;
}
const ver = $("#ver");
if (ver && me.version) {
// this version's release page on GitHub: what changed, and how to install it (or any older one) again
const page = (v) => `https://github.com/batrapulkit/squidbrake/releases/tag/v${encodeURIComponent(v)}`;
ver.textContent = `v${me.version}`;
ver.href = page(me.version);
ver.title = "This version on GitHub: what changed, and how to install it or an older one";
ver.parentElement.querySelector(".newver")?.remove();
if (me.latest) ver.after(el("a", { class: "newver", href: page(me.latest), target: "_blank", rel: "noopener",
title: `Version ${me.latest} is out: run the install line again, or pipx upgrade squidbrake`, text: `new: v${me.latest}` }));
}
$("#meName").textContent = me.auth_enabled === false ? "Local" : me.client;
$("#keyBtn").hidden = me.auth_enabled === false;
document.querySelectorAll("[data-admin]").forEach((n) => (n.hidden = !me.is_admin));
if (me.can_approve) loadDevices();
$("#stopBtn").hidden = !(me.can_approve || me.is_admin);
const shadowAll = me.mode === "shadow", shadowSome = (me.shadow_agents || []).length > 0;
$("#modeBtn").setAttribute("aria-pressed", String(shadowAll));
$("#modeText").textContent = shadowAll ? "Observe on" : "Observe off";
$("#shadowBanner").hidden = !(shadowAll || shadowSome);
$("#shadowBanner").replaceChildren(el("b", { text: "👁 Shadow mode" }), " ", el("span", { text: shadowAll
? "is on: nothing is blocked or held. Squidbrake records what it would have done (see Reports). Click 👁 Observe on (top right) to enforce again."
: `for ${me.shadow_agents.join(", ")}: their actions aren't blocked or held, only recorded. Everyone else is enforced.` }));
if (me.plan === "free") {
const left = Math.max(0, Math.ceil((new Date(me.trial_ends) - Date.now()) / 86400000));
$("#shadowBanner").hidden = false;
$("#shadowBanner").replaceChildren(el("b", { text: me.trial_over ? "Your free 14 days have ended" : `Free plan: ${left} day${left === 1 ? "" : "s"} left` }),
" ", el("span", { text: me.trial_over ? "Everything is still recorded, but nothing is blocked or held. " : "One person; agents unlimited. " }),
el("a", { href: me.upgrade_url, target: "_blank", rel: "noopener", text: me.trial_over ? "Keep it on" : "Add your team" }));
}
if (!me.is_admin && view === "settings") showView("activity");
}
// Why this viewer can't decide on e, or null if they can.
function cannotDecide(e) {
if (!me) return "Checking permissions…";
if (!me.can_approve) return `Key "${me.client}" can't approve. Use an approver key.`;
if (me.auth_enabled && e.client === me.client) return "This request came from your own key; someone else must decide.";
if ((e.approvals || []).some((a) => a.by === me.client))
return `You approved. Waiting for ${(e.required_approvals || 1) - e.approvals.length} more approval(s) from someone else.`;
if (me.auth_enabled && e.approvers && e.approvers.length && !e.approvers.includes(me.client))
return `Only ${e.approvers.join(", ")} can decide this one.`;
return null;
}
function clockEl(e) {
return el("span", { class: "clock", "data-deadline": e.approval_deadline, "data-then": e.approval_on_timeout || "deny" });
}
function tickClocks() {
const now = Date.now();
document.querySelectorAll(".clock[data-deadline]").forEach((c) => {
const s = Math.round((new Date(c.dataset.deadline) - now) / 1000);
const then = c.dataset.then === "allow" ? "auto-allows" : "auto-denies";
c.textContent = s > 0 ? `${then} in ${Math.floor(s / 60)}:${pad(s % 60)}` : `timed out, ${then.replace("auto-", "")}…`;
c.classList.toggle("soon", s <= 60);
});
}
function decisionControls(e, compact) {
const why = cannotDecide(e);
if (why) return el("span", { class: "hint", text: why });
const isQ = e.kind === "question"; // ask_user: the note is the answer the agent gets
const note = el("input", { placeholder: isQ ? "Your answer (the agent gets this)" : "Note (optional)",
"aria-label": isQ ? `Answer to ${agentOf(e)}` : `Note for ${e.name}`, maxlength: "2000",
onkeydown: (ev) => {
if (ev.key === "Escape") { ev.stopPropagation(); note.blur(); }
else if (ev.key === "Enter" && !ev.isComposing && compact) { ev.preventDefault(); note.closest("div").querySelector(isQ ? ".btn.approve" : ".btn.reject").click(); }
} });
const go = (outcome) => async (ev) => {
if (outcome === "approve" && e.cannot_undo &&
!confirm(`This can't be undone once it runs:\n\n${e.reason || e.name}\n\nApprove anyway?`)) return;
const btns = ev.target.parentElement.querySelectorAll("button");
btns.forEach((b) => (b.disabled = true));
try {
const device_proof = await deviceProof(e.id, outcome);
const r = await apiPost(`/v1/events/${encodeURIComponent(e.id)}/${outcome}`, { note: note.value.trim() || null, device_proof });
toast(isQ ? (outcome === "approve" ? "Answer sent" : "Declined")
: r && r.decision === "review" ? `Your approval is in: ${e.name} needs someone else too`
: `${outcome === "approve" ? "Approved" : "Rejected"} ${e.name}`);
shortcutIndexHint = Math.max(0, queue.findIndex((q) => q.id === e.id)); // the next `j` lands on the request that moved up
queue = queue.filter((q) => q.id !== e.id);
shortcutQueueId = null; cancelApprove(); // never carry a selection over to the next request
renderQueue();
if (drawerEvent && drawerEvent.id === e.id) refreshDrawer();
live();
} catch (err) {
toast(String(err.message || err));
btns.forEach((b) => (b.disabled = false));
loadQueue().catch(() => {});
}
};
return el("div", { class: compact ? "acts" : "appr-acts" }, note,
el("button", { class: "btn reject", onclick: go("reject") }, isQ ? "✕ Decline" : "✕ Reject"),
el("button", { class: "btn approve", onclick: go("approve") }, isQ ? "✓ Send answer" : "✓ Approve"));
}
function preview(v) {
if (v == null) return "(no input)";
const s = typeof v === "string" ? v : JSON.stringify(v);
return s.length > 240 ? s.slice(0, 240) + "…" : s;
}
// ---------- approval queue keyboard shortcuts
function isTypingTarget(t) {
return !!t && (t.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName));
}
function queueRow(id) { return id ? $("#aq").querySelector(`li[data-id="${CSS.escape(id)}"]`) : null; }
function updateShortcutSelection() {
document.querySelectorAll("#aq li[data-id]").forEach((li) => {
const on = li.dataset.id === shortcutQueueId;
li.classList.toggle("shortcut-selected", on);
li.setAttribute("aria-selected", String(on));
});
}
// Only the person picks a request (j/k or a click); the dashboard never selects one for them.
function setQueueSelection(id) {
if (approveArm && approveArm.id !== id) cancelApprove();
shortcutQueueId = id;
updateShortcutSelection();
}
function selectQueueIndex(i) {
if (!queue.length) { setQueueSelection(null); return; }
setQueueSelection(queue[Math.max(0, Math.min(i, queue.length - 1))].id);
shortcutIndexHint = 0;
const li = queueRow(shortcutQueueId);
if (li) li.scrollIntoView({ block: "nearest" });
}
function moveQueueSelection(dir) {
const i = queue.findIndex((q) => q.id === shortcutQueueId);
if (i >= 0) selectQueueIndex(Math.max(0, Math.min(i + dir, queue.length - 1)));
else selectQueueIndex(dir > 0 ? shortcutIndexHint : shortcutIndexHint - 1); // nothing selected: start at the top, or just after the last decision
}
// Approving takes two presses of `a` on the same request within APPROVE_CONFIRM_MS.
const APPROVE_CONFIRM_MS = 3000;
function cancelApprove() {
if (!approveArm) return;
clearTimeout(approveArm.timer);
const li = queueRow(approveArm.id);
if (li) li.querySelectorAll(".arm").forEach((n) => n.remove());
approveArm = null;
}
function approveSelected() {
const id = shortcutQueueId, li = queueRow(id);
const btn = li && li.querySelector(".btn.approve");
if (!btn || btn.disabled) return;
if (approveArm && approveArm.id === id) { cancelApprove(); btn.click(); return; } // second press: approve
cancelApprove();
const q = queue.find((x) => x.id === id);
li.append(el("div", { class: "arm", role: "status", text: `Press a again to approve ${q ? q.name : "this request"}` }));
approveArm = { id, timer: setTimeout(cancelApprove, APPROVE_CONFIRM_MS) };
}
function focusRejectNote() {
const note = queueRow(shortcutQueueId) && queueRow(shortcutQueueId).querySelector(".acts input");
if (note) { note.scrollIntoView({ block: "nearest" }); note.focus(); }
}
function shortcutsBlocked(ev) {
return ev.ctrlKey || ev.metaKey || ev.altKey || ev.isComposing || isTypingTarget(ev.target) ||
!!document.querySelector("dialog[open]") || selectedId; // a dialog or the detail drawer has the user's attention
}
async function loadQueue() {
const data = await api("/v1/events", { status: "awaiting_approval", limit: 200 });
queue = data.events.sort((a, b) => (a.approval_deadline || "").localeCompare(b.approval_deadline || ""));
renderQueue();
notifyQueue();
// Someone else (or the timeout) decided the event that's open in the drawer.
if (drawerEvent && drawerEvent.status === "awaiting_approval" && !queue.some((q) => q.id === drawerEvent.id)) refreshDrawer();
}
// Reconcile by id so a live refresh never wipes a note someone is typing.
function renderQueue() {
const box = $("#approvals"), list = $("#aq");
box.hidden = queue.length === 0;
$("#aqCount").textContent = queue.length;
const keys = " · j/k move · a approve · r reject · ? shortcuts";
$("#aqHint").textContent = me && !me.can_approve ? `Viewing as "${me.client}" (not an approver)` + keys.replace(" · a approve · r reject", "") : "oldest deadline first" + keys;
// No automatic selection. If the selected request is gone (someone else decided it, or it timed out), just deselect.
if (shortcutQueueId && !queue.some((q) => q.id === shortcutQueueId)) {
shortcutIndexHint = Math.max(0, queueIds.indexOf(shortcutQueueId));
shortcutQueueId = null;
}
const ids = queue.map((q) => q.id);
if (ids.join("|") !== queueIds.join("|")) cancelApprove(); // any change to the queue cancels a pending approval
queueIds = ids;
if (approveArm && (approveArm.id !== shortcutQueueId || !queueRow(approveArm.id))) cancelApprove();
document.title = queue.length ? `(${queue.length}) Needs approval · Squidbrake` : "Squidbrake";
// Rows are reused across refreshes, but rebuilt when the viewer (API key) changes,
// because the approve/reject controls depend on who is looking.
const viewer = me ? me.client : "";
const keep = new Set(queue.map((q) => q.id));
list.querySelectorAll("li[data-id]").forEach((li) => { if (!keep.has(li.dataset.id) || li.dataset.viewer !== viewer) li.remove(); });
let prev = null;
for (const e of queue) {
let li = list.querySelector(`li[data-id="${CSS.escape(e.id)}"]`);
if (!li) {
li = el("li", { "data-id": e.id, "data-viewer": viewer, "aria-selected": "false",
onclick: (ev) => { if (!ev.target.closest("button, input, select, textarea, a")) setQueueSelection(e.id); } },
el("div", { class: "aq-main" },
avatarEl(agentOf(e), "sm"),
el("div", { class: "aq-copy" },
el("div", {}, el("button", { class: "pill-link mono", text: e.name, title: "Open details", onclick: () => openDrawer(e.id) })),
el("div", { class: "why" }, e.reason || "", e.rule_id ? el("span", { class: "muted mono", text: ` · ${e.rule_id}` }) : null),
el("div", { class: "who" }, [agentOf(e), e.session_id && `chain ${e.session_id}`, e.client && e.client !== agentOf(e) ? `key ${e.client}` : null, `asked ${fmtAgo(e.created_at)}`].filter(Boolean).join(" · ")),
e.session_id ? el("button", { type: "button", class: "linkish", text: "Show chain", onclick: (ev) => {
ev.stopPropagation();
listMode = "chains";
try { localStorage.setItem("gw.list", "chains"); } catch {}
setFilter("session_id", e.session_id);
} }) : null,
),
),
el("div", { class: "side" }, clockEl(e), decisionControls(e, true)),
e.kind === "question" && e.input && e.input.question
? el("div", { class: "why", style: "font-size:14px", text: `❓ ${e.input.question}` })
: el("div", { class: "inp", title: preview(e.input), text: preview(e.input) }),
e.justification ? el("div", { class: "why" }, el("b", { text: "Why, says the agent: " }), e.justification) : null,
(e.required_approvals || 1) > 1 ? el("div", { class: "why" }, el("b", { text: `${(e.approvals || []).length} of ${e.required_approvals} approvals` }),
(e.approvals || []).length ? `: ${e.approvals.map((a) => a.by).join(", ")}` : ": needs different people; one rejection stops it") : null,
briefEl(e),
signalsEl(e.signals),
storyDetails(e),
);
}
if (prev ? prev.nextSibling !== li : list.firstChild !== li) prev ? prev.after(li) : list.prepend(li);
prev = li;
}
tickClocks();
updateShortcutSelection();
}
// ---------- desktop alerts: a pop-up when a new request joins the queue, so nobody has to keep this tab in view.
// The queue is polled every 5s even in a background tab (see the wiring below), which is what feeds this.
const deskPref = {
get() { try { return localStorage.getItem("gw.notify") === "1"; } catch { return false; } },
set(v) { try { localStorage.setItem("gw.notify", v ? "1" : "0"); } catch {} },
};
let notifySeen = null, notifySeenFor = null; // ids already shown to this viewer; null until the first load, which never pops up
let notifyOpen = []; // [{n, ids}]: closed once none of its requests is still waiting
function deskState() {
if (!("Notification" in window)) return "unsupported";
if (!window.isSecureContext) return "insecure";
if (Notification.permission === "denied") return "blocked";
return Notification.permission === "granted" && deskPref.get() ? "on" : "off";
}
const DESK_TITLES = {
on: "You get a desktop pop-up when an action needs approval. Click to turn it off.",
off: "Get a desktop pop-up when an action needs approval, even while you work in another app",
blocked: "This browser blocks notifications from this page. Allow them in the site settings (left of the address bar), then click again.",
insecure: "Browsers only show notifications on https or localhost. Open the dashboard at http://localhost:<port> or your https address.",
unsupported: "This browser can't show desktop notifications. Phone notifications in Settings still work.",
};
function renderNotifyBtn() {
const s = deskState(), b = $("#notifyBtn");
b.setAttribute("aria-pressed", String(s === "on"));
b.title = DESK_TITLES[s];
$("#notifyText").textContent = s === "on" ? "Alerts on" : "Alerts off";
}
$("#notifyBtn").addEventListener("click", async () => {
const s = deskState();
if (s === "on") { deskPref.set(false); renderNotifyBtn(); toast("Desktop alerts off."); return; }
if (s !== "off") { renderNotifyBtn(); toast(DESK_TITLES[s], 8000); return; }
const p = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
deskPref.set(p === "granted");
renderNotifyBtn();
toast(p === "granted" ? "Desktop alerts on. Keep this tab open; it can stay in the background." : DESK_TITLES[deskState()], 8000);
});
renderNotifyBtn();
function notifyQueue() {
const viewer = me ? me.client : "", waiting = new Set(queueIds);
notifyOpen = notifyOpen.filter((o) => o.ids.some((id) => waiting.has(id)) || (o.n.close(), false)); // decided elsewhere: take it down
if (notifySeen === null || notifySeenFor !== viewer) { notifySeen = waiting; notifySeenFor = viewer; return; } // first look: no pop-up for what was already there
const fresh = queue.filter((q) => !notifySeen.has(q.id));
notifySeen = waiting;
if (!fresh.length || !(me && me.can_approve)) return;
if (document.visibilityState === "visible" && document.hasFocus()) return; // they're looking at the page already
if (deskState() !== "on") { renderNotifyBtn(); return; } // permission may have been revoked since
const one = fresh.length === 1 ? fresh[0] : null;
let n;
try {
n = new Notification(one ? `Needs approval: ${one.name}` : `${fresh.length} actions need approval`, {
body: one ? [agentOf(one), one.reason].filter(Boolean).join(" · ") : fresh.slice(0, 5).map((q) => `${q.name} (${agentOf(q)})`).join("\n"),
tag: one ? `sb-${one.id}` : "sb-batch",
requireInteraction: true, // stays up until you act on it, or until it's decided somewhere else
});
} catch { return; } // Android Chrome only allows notifications from a service worker
n.onclick = () => {
window.focus(); n.close();
showView("activity");
if (one && queueRow(one.id)) { setQueueSelection(one.id); queueRow(one.id).scrollIntoView({ block: "nearest" }); }
else $("#approvals").scrollIntoView({ block: "start" });
};
notifyOpen.push({ n, ids: fresh.map((q) => q.id) });
}
// ---------- drawer
async function openDrawer(id) {
cancelApprove();
selectedId = id;
document.querySelectorAll("#rows tr, .node, .spark-dot").forEach((n) => n.classList.toggle("sel", n.dataset.id === id));
document.querySelectorAll(".rail li").forEach((n) => n.classList.toggle("on", n.dataset.id === id));
const d = $("#drawer");
d.classList.add("open"); d.setAttribute("aria-hidden", "false"); $("#backdrop").classList.add("on");
$("#drTitle").textContent = "Loading…"; $("#drSub").textContent = ""; $("#drBody").replaceChildren();
try {
const e = await api(`/v1/events/${encodeURIComponent(id)}`);
if (selectedId !== id) return;
renderDrawer(e);
} catch (err) {
$("#drTitle").textContent = "Couldn't load event";
$("#drBody").replaceChildren(el("p", { class: "muted", text: String(err.message || err) }));
}
$("#drClose").focus();
}
async function refreshDrawer() {
const id = drawerEvent && drawerEvent.id;
if (!id) return;
try {
const e = await api(`/v1/events/${encodeURIComponent(id)}`);
if (selectedId === id) renderDrawer(e);
} catch {}
}
function closeDrawer() {
const d = $("#drawer");
d.classList.remove("open"); d.setAttribute("aria-hidden", "true"); $("#backdrop").classList.remove("on");
const tr = selectedId && (
document.querySelector(`#rows tr[data-id="${CSS.escape(selectedId)}"]`) ||
document.querySelector(`.node[data-id="${CSS.escape(selectedId)}"]`));
selectedId = null; drawerEvent = null;
document.querySelectorAll("#rows tr.sel, .node.sel, .spark-dot.sel").forEach((t) => t.classList.remove("sel"));
document.querySelectorAll(".rail li.on").forEach((n) => n.classList.remove("on"));
if (tr) tr.focus();
}
function approvalBanner(e) {
const when = e.decided_at ? ` · ${new Date(e.decided_at).toLocaleString()}` : "";
const note = e.decision_note && e.decided_by !== "timeout" ? el("div", { class: "sec", style: "margin-top:4px" }, "“", e.decision_note, "”") : null;
if (e.status === "awaiting_approval") {
return el("div", { class: "banner awaiting" },
el("div", {}, el("b", { text: "Waiting for a human: " }), e.reason || "", e.rule_id ? el("span", { class: "muted mono", text: ` (${e.rule_id})` }) : null),
el("div", { style: "margin-top:4px" }, clockEl(e),
e.approvers && e.approvers.length ? el("span", { class: "hint", text: ` · approvers: ${e.approvers.join(", ")}` }) : null),
decisionControls(e, false));
}
if (!e.decided_by) return null;
if (e.decided_by === "timeout") {
return el("div", { class: "banner " + (e.decision === "allow" ? "approved" : "denied") },
el("b", { text: `Approval timed out, ${e.decision === "allow" ? "allowed" : "denied"} automatically` }), when,
el("div", { class: "muted", style: "margin-top:4px", text: `Rule: ${e.reason || e.rule_id || ""}` }));
}
return el("div", { class: "banner " + (e.decision === "allow" ? "approved" : "denied") },
el("b", { text: `${e.decision === "allow" ? "Approved" : "Rejected"} by ${e.decided_by}` }), when,
e.decided_with ? el("span", { class: "tag", style: "margin-left:6px", title: "Signed by the device; the signature is in the audit trail", text: `✓ ${e.decided_with}` }) : null, note,
el("div", { class: "muted", style: "margin-top:4px", text: `Held by: ${e.reason || ""}${e.rule_id ? " (" + e.rule_id + ")" : ""}` }));
}
// A person's verdict on a hold or block: "this was legit" counts as a false positive in the weekly report
function feedbackEl(e) {
const stopped = e.would || (e.approval_deadline && e.status !== "awaiting_approval") || (e.status === "denied" && !e.decided_by);
if (!stopped) return null;
const box = el("div", { class: "hint", style: "margin:6px 0" });
const mark = (verdict) => async () => {
try {
await apiPost(`/v1/events/${encodeURIComponent(e.id)}/feedback`, { verdict });
e.feedback = verdict; box.replaceWith(feedbackEl(e) || el("span"));
toast(verdict === "legit" ? "Marked: this should have run" : "Marked: stopping it was right");
} catch (err) { toast(err.message || String(err)); }
};
box.append(e.feedback ? `You said: ${e.feedback === "legit" ? "this was legit (a false positive)" : "stopping it was right"}. ` : "Was stopping this right? ",
el("button", { class: "linkish", text: "This was legit", onclick: mark("legit") }), " · ",
el("button", { class: "linkish", text: "Stopping it was right", onclick: mark("right") }));
return box;
}
// ---------- the story behind an action: history-check warnings + what the agent did just before
const SIGNAL_TITLES = { effect: "What it changes",
impersonation: "Possible scam", payment_request_in_message: "Payment requested in a message",
repeat_of_rejected: "Already rejected before", duplicate_change: "Done before",
catastrophic_command: "Destructive command", irreversible_command: "Can't be undone", hidden_command: "Hidden code", secret_command: "Shows a secret", rename_command: "Renames or moves", written_then_run_command: "Script written, then run", edited_then_run_command: "Script changed, then run", sequence: "Pattern", untrusted_destination: "Address from outside content", after_untrusted: "After reading outside content",
after_untrusted_setup_edit: "Tainted setup change: after reading outside content",
};
// The AI brief: advice for the person deciding, written with the team's own key. Never part of the decision.
function briefEl(e) {
if (!e.brief) return null;
if (e.brief.error) return el("div", { class: "hint", text: `AI brief unavailable: ${e.brief.error}` });
return el("div", { class: "brief" }, el("b", { text: "AI brief (advice only): " }), e.brief.text,
el("div", { class: "who", text: `${e.brief.model || ""} · the rules held it; you decide` }));
}
function snapshotEl(e) {
const s = e.snapshot;
if (!s || !(s.id || s.skipped || s.want)) return null;
if (s.skipped) return el("div", { class: "banner failed" }, el("b", { text: "No undo copy: " }), s.skipped);
if (!s.id) return e.status === "awaiting_approval" ? el("div", { class: "hint", text: `A copy will be kept before it runs (${s.want}).` }) : null;
const kids = [el("b", { text: "Undo copy kept before it ran: " }), s.summary || s.ref || ""];
if (s.kind === "local") kids.push(el("div", { class: "hint", text: `On ${agentOf(e)}'s computer: squidbrake undo ${s.ref}${e.name === "Edit" || e.name === "Write" ? " --force" : ""}` }));
else kids.push(" ", el("button", { class: "linkish", text: "download the copy", onclick: async () => {
try {
const r = await api(`/v1/events/${encodeURIComponent(e.id)}/snapshots`);
const url = URL.createObjectURL(new Blob([JSON.stringify(r.snapshots, null, 2)], { type: "application/json" }));
el("a", { href: url, download: `squidbrake-undo-${e.id}.json` }).click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (err) { toast(err.message); }
} }));
return el("div", { class: "banner approved" }, ...kids);
}
function proofEl(e) {
if (!e.proof_status) return null;
const ok = e.proof_status === "confirmed";
return el("div", { class: "banner " + (ok ? "approved" : "denied") },
el("b", { class: ok ? "proof-ok" : "proof-bad", text: ok ? "✓ Checked: it really happened. " : e.proof_status === "mismatch" ? "✕ Not actually done. " : "! Couldn't check. " }),
(e.proof && e.proof.message) || "", " ",
el("button", { class: "linkish", text: "check again", onclick: async () => {
try { await apiPost(`/v1/events/${encodeURIComponent(e.id)}/proof`); refreshDrawer(); } catch (err) { toast(err.message); }
} }));
}
function signalsEl(signals) {
if (!signals || !signals.length) return null;
return el("div", { class: "signals" }, signals.map((s) => el("div", { class: "signal " + s.effect },
el("b", { text: "⚠ " + (SIGNAL_TITLES[s.check] || s.check) + ": " }), s.message)));
}
function summarizeInput(v) {
if (v == null) return "";
if (typeof v !== "object") return String(v).slice(0, 140);
return Object.entries(v).slice(0, 4).map(([k, x]) => `${k}: ${typeof x === "object" ? JSON.stringify(x) : x}`).join(" · ").slice(0, 160);
}
function summarizeOutput(t) {
if (!t) return null;
const from = t.match(/"from"\s*:\s*"([^"]+)"/g), subject = t.match(/"subject"\s*:\s*"([^"]+)"/);
if (from && from.length === 1) {
const body = t.match(/"body"\s*:\s*"([^"]{0,220})/);
return `✉ from ${from[0].replace(/.*"from"\s*:\s*"/, "").replace(/"$/, "")}${subject ? ` — “${subject[1]}”` : ""}${body ? `: ${body[1]}…` : ""}`;
}
if (from && from.length > 1) return "Listed several messages";
return t.length > 180 ? t.slice(0, 180) + "…" : t;
}
function mergeChain(current, prior) {
const byId = new Map();
for (const s of prior || []) byId.set(s.id, s);
if (current && current.session_id) {
for (const r of rows) if (r.session_id === current.session_id) byId.set(r.id, { ...byId.get(r.id), ...r });
}
if (current) byId.set(current.id, { ...byId.get(current.id), ...current });
return [...byId.values()].sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.id < b.id ? -1 : 1);
}
async function loadStory(evOrId, box) {
const id = typeof evOrId === "string" ? evOrId : evOrId.id;
const current = typeof evOrId === "string" ? (rows.find((r) => r.id === id) || null) : evOrId;
const head = box.querySelector("h4, summary") || box.firstChild;
try {
const r = await api(`/v1/events/${encodeURIComponent(id)}/context`);
const steps = mergeChain(current, r.steps);
const parts = [head, steps.length > 1 ? railEl(steps, id) : el("div", { class: "muted", text: "Nothing else in this chain yet." })];
if (r.steps.length >= 15) parts.push(el("div", { class: "hint", text: "Showing the latest steps of this chain." }));
box.replaceChildren(...parts);
if (drawerEvent && drawerEvent.id === id && steps.length) {
const i = Math.max(0, steps.findIndex((s) => s.id === id));
$("#drSub").replaceChildren(statusEl(drawerEvent.status),
` · step ${i + 1} of ${steps.length} · ${agentOf(drawerEvent)} · ${new Date(drawerEvent.created_at).toLocaleString()}`);
}
} catch { box.replaceChildren(head, el("div", { class: "muted", text: "Couldn't load the chain." })); }
}
function storyDetails(evOrId) {
const d = el("details", { class: "story-d" }, el("summary", { text: "Chain" }), el("div", { class: "muted", text: "Loading…" }));
d.addEventListener("toggle", () => { if (d.open && !d.dataset.loaded) { d.dataset.loaded = "1"; loadStory(evOrId, d); } });
return d;
}
function sessionControl(session, compact) {
if (!session || !me) return null;
const stopped = (controlsState.sessions || {})[session];
if (stopped) return el("span", {}, compact ? "" : " ", el("span", { class: "tag", style: "border-color:var(--critical);color:var(--critical)", text: "stopped" }),
me.is_admin ? el("button", { class: "linkish", text: "resume", onclick: () => resume(null, session) }) : null);
return me.can_approve || me.is_admin
? el("span", {}, compact ? "" : " ", el("button", { class: "linkish", style: "color:var(--critical)", text: compact ? "Stop" : "⏹ stop this session", onclick: () => askStop(null, session) }),
" ", el("button", { class: "linkish", text: compact ? "Note" : "✉ send a note", onclick: () => askNote(null, session) }))
: null;
}
function renderDrawer(e) {
drawerEvent = e;
$("#drTitle").replaceChildren(avatarEl(agentOf(e), "sm"), el("span", { text: e.name }));
$("#drSub").replaceChildren(statusEl(e.status), ` · ${agentOf(e)} · ${new Date(e.created_at).toLocaleString()}`);
const body = $("#drBody");
const kids = [approvalBanner(e), feedbackEl(e)];
if (e.status === "denied" && !e.decided_by) kids.push(el("div", { class: "banner denied" }, el("b", { text: "Blocked: " }), e.reason || "", e.rule_id ? el("span", { class: "muted mono", text: ` (${e.rule_id})` }) : null));
kids.push(briefEl(e), snapshotEl(e), proofEl(e));
if (e.status === "awaiting_approval" && !e.brief && me && me.can_approve)
kids.push(el("button", { class: "linkish", text: "Ask AI for a brief", onclick: async () => {
try { await apiPost(`/v1/events/${encodeURIComponent(e.id)}/brief`); refreshDrawer(); } catch (err) { toast(err.message); }
} }));
kids.push(signalsEl(e.signals));
const story = el("div", { class: "blk" }, el("h4", { text: "Chain" }), el("div", { class: "muted", text: "Loading…" }));
kids.push(story);
loadStory(e.id, story);
if (e.status === "failed" && e.error) kids.push(el("div", { class: "banner failed" }, el("b", { text: "Error: " }), e.error));
const filt = (k, v) => v ? el("span", {}, el("span", { class: "mono", text: v }), " ",
el("button", { class: "linkish", text: "filter", onclick: () => { closeDrawer(); setFilter(k, v); } })) : el("span", { class: "muted", text: "—" });
const kv = [
["Event ID", el("span", {}, el("span", { class: "mono", text: e.id }), " ", el("button", { class: "linkish", text: "copy", onclick: (ev) => copy(e.id, ev.target) }))],
["Kind", e.kind], ["Decision", `${e.decision}${e.reason ? " — " + e.reason : ""}`], ["Rule", e.rule_id || "—"],
["Agent", filt("source", e.source)], ["Chain", el("span", {}, filt("session_id", e.session_id), sessionControl(e.session_id))],
["Client", filt("client", e.client)], ["Client IP", e.client_ip || "—"],
["Created", new Date(e.created_at).toLocaleString() + " (" + fmtAgo(e.created_at) + ")"],
["Completed", e.completed_at ? new Date(e.completed_at).toLocaleString() : "—"],
["Duration", fmtDur(e.duration_ms) || "—"],
];
kids.push(el("dl", { class: "kv" }, kv.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", {}, v)])));
const block = (title, val) => {
if (val == null || (typeof val === "object" && !Array.isArray(val) && !Object.keys(val).length)) return null;
const txt = typeof val === "string" ? val : JSON.stringify(val, null, 2);
return el("div", { class: "blk" },
el("h4", {}, title, el("button", { class: "linkish", text: "copy", onclick: (ev) => copy(txt, ev.target) })),
el("pre", { text: txt }));
};
kids.push(block("Input", e.input), block("Output", e.output), e.status !== "failed" ? block("Error", e.error) : null, block("Metadata", e.metadata));
body.replaceChildren(...kids.filter(Boolean));
tickClocks();
}
async function copy(text, btn) {
try { await navigator.clipboard.writeText(text); const o = btn.textContent; btn.textContent = "copied"; setTimeout(() => (btn.textContent = o), 1200); } catch {}
}
// ---------- key dialog
function openKeyDialog(wasWrong, why) {
const dlg = $("#keyDlg");
if (dlg.open) return;
$("#keyErr").textContent = why || (wasWrong ? "That key was rejected." : "");
$("#keyInput").value = "";
dlg.showModal();
$("#keyInput").focus();
}
$("#keyForm").addEventListener("submit", () => {
apiKey = $("#keyInput").value.trim();
store.set(apiKey);
me = null;
reload();
});
$("#keyCancel").addEventListener("click", () => $("#keyDlg").close());
fetch("/sso/status").then((r) => r.json()).then((s) => { $("#ssoBtn").hidden = !s.enabled; }).catch(() => {});
$("#keyBtn").addEventListener("click", () => openKeyDialog(false));
// ---------- wiring
document.querySelectorAll("#range button").forEach((b) => b.addEventListener("click", () => {
state.range = b.dataset.r; syncUrl(); renderFilters(); reload();
}));
let debounce;
["q", "kind", "source", "session_id"].forEach((k) => $("#" + k).addEventListener("input", (ev) => {
clearTimeout(debounce);
debounce = setTimeout(() => setFilter(k, ev.target.value.trim()), 300);
}));
$("#status").addEventListener("change", (ev) => setFilter("status", ev.target.value));
$("#refresh").addEventListener("click", reload);
$("#more").addEventListener("click", () => { const g = gen; run(g, () => loadEvents(true, g)); });
$("#drClose").addEventListener("click", closeDrawer);
$("#backdrop").addEventListener("click", closeDrawer);
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && selectedId && !$("#keyDlg").open) closeDrawer();
if (ev.key === "/" && document.activeElement === document.body) { ev.preventDefault(); $("#q").focus(); }
if (approveArm && !ev.repeat && (ev.key !== "a" || shortcutsBlocked(ev))) cancelApprove(); // any other key cancels a pending approval
if (ev.repeat || shortcutsBlocked(ev)) return; // never while typing, in a dialog, or with Ctrl/Cmd/Alt held
if (ev.key === "?") { ev.preventDefault(); $("#shortcutDlg").showModal(); return; }
if (view !== "activity" || !queue.length) return;
if (ev.key === "j") { ev.preventDefault(); moveQueueSelection(1); }
else if (ev.key === "k") { ev.preventDefault(); moveQueueSelection(-1); }
else if (ev.key === "a") { ev.preventDefault(); approveSelected(); }
else if (ev.key === "r") { ev.preventDefault(); focusRejectNote(); }
});
$("#shortcutDone").addEventListener("click", () => $("#shortcutDlg").close());
document.querySelectorAll("#evView button").forEach((b) => b.addEventListener("click", () => {
if (listMode === b.dataset.v) return;
listMode = b.dataset.v;
try { localStorage.setItem("gw.list", listMode); } catch {}
renderRows();
}));
applyListMode();
$("#tableToggle").addEventListener("click", (ev) => {
const t = $("#chartTable"), open = t.hidden;
t.hidden = !open;
ev.target.setAttribute("aria-expanded", String(open));
ev.target.textContent = open ? "Hide table" : "View as table";
});
$("#auto").addEventListener("change", (ev) => { try { localStorage.setItem("gw.auto", ev.target.checked ? "1" : "0"); } catch {} });
setInterval(() => {
if ($("#keyDlg").open) return;
if ($("#auto").checked && !document.hidden) live();
// The approval queue refreshes even when Live is off or the tab is in the background,
// so the "(n) Needs approval" tab title stays accurate.
else loadQueue().catch(() => {});
}, 5000);
setInterval(tickClocks, 1000);
let rz;
window.addEventListener("resize", () => { clearTimeout(rz); rz = setTimeout(() => lastStats && renderChart(lastStats), 120); });
// ---------- views (tabs)
$("#home").addEventListener("click", (ev) => { ev.preventDefault(); showView("activity"); });
function showView(name) {
if (!["activity", "team", "reports", "settings"].includes(name)) name = "activity";
view = name;
document.querySelectorAll(".view").forEach((v) => (v.hidden = v.id !== "view-" + name));
document.querySelectorAll("#tabs a").forEach((a) => { if (a.dataset.view === name) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); });
if (location.hash !== "#" + name) history.replaceState(null, "", location.pathname + location.search + (name === "activity" ? "" : "#" + name));
loadView().catch(() => {});
}
function loadView() {
if (!me) return Promise.resolve(); // reload() calls this again once we know who's signed in
if (view === "team") return loadTeam();
if (view === "reports") return loadReports();
if (view === "settings") return loadSettings();
return Promise.resolve();
}
window.addEventListener("hashchange", () => showView(location.hash.slice(1)));
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString([], { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
// ---------- emergency stop
let controlsState = { all: null, agents: {} };
async function loadControls() {
controlsState = await api("/v1/controls");
const b = $("#stopBanner"), s = controlsState.all;
const agentsStopped = Object.keys(controlsState.agents || {});
const sessionsStopped = Object.keys(controlsState.sessions || {});
const live = (x) => x && !(x.until && new Date(x.until) <= new Date());
const hhmm = (t) => new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (live(s)) {
b.hidden = false;
const long = Date.now() - new Date(s.at) > 30 * 60 * 1000;
b.replaceChildren(el("b", { text: `⏹ Emergency stop ON since ${hhmm(s.at)}` }),
el("span", { text: `By ${s.by}${s.reason ? " — " + s.reason : ""}${s.until ? `, until ${hhmm(s.until)}` : ""}. ` +
"Every action any agent tries is blocked, and the agent is told how to turn this off." +
(long ? " Still stopped: did you mean to leave this on?" : "") }),
me && me.is_admin ? el("button", { class: "btn", text: "Turn off", onclick: () => resume(null) }) : null);
} else if (agentsStopped.length || sessionsStopped.length) {
b.hidden = false;
const what = [...agentsStopped, ...sessionsStopped.map((x) => "session " + x)];
b.replaceChildren(el("b", { text: `⏹ Stopped: ${what.join(", ")}` }),
el("span", { text: "Everything else is running normally." }),
...(me && me.is_admin ? sessionsStopped.map((x) => el("button", { class: "btn", text: `Resume session ${x.slice(0, 18)}`, onclick: () => resume(null, x) })) : []));
} else b.hidden = true;
$("#stopBtn").textContent = live(s) ? "⏹ Agents stopped" : "⏹ Stop agents";
$("#stopBtn").disabled = !!live(s);
}
let stopTarget = null, stopSession = null;
function askStop(agent, session = null) {
stopTarget = agent; stopSession = session;
$("#stopWhat").textContent = session
? `This conversation (${session}) is stopped: its next action is blocked, Claude Code ends its turn, and anything it's waiting on is rejected. Other sessions keep working.`
: agent
? `Every action “${agent}” tries will be blocked until an admin resumes it. Other agents keep working.`
: "Every action any agent tries will be blocked immediately, until an admin resumes them. Actions already running aren't interrupted.";
$("#stopReason").value = "";
$("#stopDlg").showModal();
$("#stopReason").focus();
}
$("#stopBtn").addEventListener("click", () => askStop(null));
$("#modeBtn").addEventListener("click", async () => {
const observe = me.mode !== "shadow";
if (!observe && !confirm("Switch back to enforce? Risky actions will be blocked or wait for approval again.")) return;
try {
await apiSend("PUT", "/v1/policy/mode", { mode: observe ? "observe" : "enforce" });
toast(observe ? "Observe mode on: nothing is blocked or held, see Reports for what would have been."
: "Enforce mode on: rules block and hold again.");
await loadMe();
} catch (e) { toast(`Couldn't switch mode: ${e.message}`); }
});
$("#stopCancel").addEventListener("click", () => $("#stopDlg").close());
$("#stopForm").addEventListener("submit", async () => {
try {
await apiPost("/v1/controls/stop", { agent: stopTarget, session: stopSession, reason: $("#stopReason").value.trim() || null,
minutes: Number($("#stopFor").value) || null });
toast(stopSession ? "Session stopped" : stopTarget ? `Stopped ${stopTarget}` : "All agents stopped");
await loadControls(); if (view === "reports") loadReports();
if (drawerEvent && stopSession) renderDrawer(drawerEvent);
} catch (err) { toast(err.message); }
});
async function resume(agent, session = null) {
try {
await apiPost("/v1/controls/resume", { agent, session });
toast(session ? "Session resumed" : agent ? `Resumed ${agent}` : "All agents resumed");
await loadControls(); if (view === "reports") loadReports();
if (drawerEvent && session) renderDrawer(drawerEvent);
} catch (err) { toast(err.message); }
}
let noteTarget = null, noteSession = null;
function askNote(agent, session = null) {
noteTarget = agent; noteSession = session;
$("#noteWhat").textContent = `${session ? "This conversation (" + session + ")" : "“" + agent + "”"} gets it with its next ` +
"action (Claude Code and the MCP connectors pass it on), and it's kept in the audit trail. It steers; it can't approve anything.";
$("#noteText").value = "";
$("#noteDlg").showModal();
$("#noteText").focus();
}
$("#noteCancel").addEventListener("click", () => $("#noteDlg").close());
$("#noteForm").addEventListener("submit", async () => {
const text = $("#noteText").value.trim();
if (!text) return;
try {
await apiPost("/v1/notes", noteSession ? { session: noteSession, text } : { agent: noteTarget, text });
toast("Note sent: the agent gets it with its next action");
} catch (err) { toast(err.message); }
});
async function loadTeamAgents() {
try {
const r = await api("/v1/agents/inventory");
$("#teamAgents").hidden = !r.machines.length;
$("#teamAgentsSub").textContent = r.not_connected.length
? `${r.not_connected.length} agent${r.not_connected.length === 1 ? "" : "s"} not connected` : "every agent reported is connected";
cells("#teamAgentsRows", r.machines.map((m) => el("tr", {},
el("td", {}, el("b", { text: m.machine }), m.stale ? el("span", { class: "tag", style: "margin-left:6px", text: "old report" }) : null),
el("td", { class: "sec", text: m.agents.filter((a) => a.connected).map((a) => a.label).join(", ") || "none" }),
el("td", { style: "color:var(--critical)", text: m.agents.filter((a) => !a.connected).map((a) => a.label).join(", ") }),
el("td", { style: "color:var(--critical)", text: (m.mcp || []).filter((s) => !s.guarded).map((s) => `${s.name} (${s.agent})`).join(", ") }),
el("td", { class: "sec", text: fmtAgo(m.at) }))), "No computer has reported yet.");
} catch { $("#teamAgents").hidden = true; }
}
async function downloadEvidence() {
try {
const res = await fetch("/v1/audit/export.json", { headers: { "X-Gateway-Key": apiKey } });
if (!res.ok) throw new Error(`export failed (${res.status})`);
const name = (res.headers.get("content-disposition") || "").match(/filename="([^"]+)"/)?.[1] || "squidbrake-evidence.json";
const url = URL.createObjectURL(await res.blob());
el("a", { href: url, download: name }).click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (err) { toast(err.message); }
}
// ---------- team
async function loadTeam() {
const data = await api("/v1/team");
$("#teamSub").textContent = data.managed_by_env ? "managed by the GATEWAY_API_KEYS setting" : `${data.members.length} keys`;
const admin = me && me.is_admin && !data.managed_by_env;
$("#addMemberCard").hidden = !admin;
const LEVEL = { owner: "Owner", admin: "Admin", approver: "Approver", viewer: "Viewer (view only)" };
const access = (m) => m.kind === "agent" ? (m.owner ? `Asks permission · works for ${m.owner}` : "Asks permission")
: LEVEL[m.access] || (m.roles.includes("admin") ? "Admin" : m.approver ? "Approver" : "Viewer (view only)");
$("#ownerSel").replaceChildren(el("option", { value: "", text: "nobody in particular" }),
...data.members.filter((m) => m.kind === "person").map((m) => el("option", { value: m.name, text: m.name })));
$("#teamRows").replaceChildren(...data.members.map((m) => el("tr", {},
el("td", {}, m.kind === "agent" ? avatarEl(m.name, "sm") : null, m.kind === "agent" ? " " : null, el("b", { text: m.name }), m.name === me.client ? el("span", { class: "muted", text: " (you)" }) : null),
el("td", { class: "sec", text: m.kind === "agent" ? "🤖 AI agent" : "👤 Person" }),
el("td", { text: access(m) }),
el("td", {}, m.roles.filter((r) => r !== "admin" && r !== "owner").length ? m.roles.filter((r) => r !== "admin" && r !== "owner").map((r) => el("span", { class: "tag", text: r })) : el("span", { class: "muted", text: "—" })),
el("td", { class: "sec", text: fmtDate(m.created_at) }),
el("td", { class: "num" }, admin && m.name !== me.client ? [
m.kind === "person" ? el("button", { class: "linkish", text: "Edit", onclick: () => editMember(m) }) : null, " ",
el("button", { class: "linkish", style: "color:var(--critical)", text: "Remove", onclick: () => removeMember(m) }),
] : null),
)));
}
function editMember(m) {
const levels = ["viewer", "approver", "admin", "owner"];
const access = prompt(`Access for ${m.name}: viewer, approver, admin or owner\n(only owners make admins and owners)`, m.access || "approver");
if (access === null) return;
if (!levels.includes(access.trim().toLowerCase())) { toast("Use viewer, approver, admin or owner"); return; }
const teams = prompt(`Teams for ${m.name}, for rules that need a team to approve (comma separated, e.g. finance):`,
m.roles.filter((r) => r !== "admin" && r !== "owner").join(", "));
if (teams === null) return;
apiSend("PATCH", `/v1/team/${encodeURIComponent(m.name)}`, { access: access.trim().toLowerCase(),
roles: teams.split(",").map((r) => r.trim()).filter(Boolean) })
.then(() => { toast(`Updated ${m.name}`); loadTeam(); }).catch((err) => toast(err.message));
}
function removeMember(m) {
if (!confirm(`Remove ${m.name}? Their key stops working immediately.`)) return;
apiSend("DELETE", `/v1/team/${encodeURIComponent(m.name)}`).then(() => { toast(`Removed ${m.name}`); loadTeam(); }).catch((err) => toast(err.message));
}
const addForm = $("#addMember");
const syncKind = () => {
addForm.querySelectorAll(".person-only").forEach((n) => (n.hidden = addForm.kind.value === "agent"));
addForm.querySelectorAll(".agent-only").forEach((n) => (n.hidden = addForm.kind.value !== "agent"));
};
addForm.kind.addEventListener("change", syncKind);
addForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = addForm, person = f.kind.value === "person";
try {
const r = await apiPost("/v1/team", { name: f.name.value.trim(), kind: f.kind.value, access: person ? f.access.value : null,
roles: person ? f.roles.value.split(",").map((x) => x.trim()).filter(Boolean) : [],
owner: person ? null : f.owner.value || null });
const box = $("#newKey");
box.hidden = false;
box.replaceChildren(el("b", { text: `Key for ${r.name} — copy it now, it won't be shown again:` }), el("br"), el("code", { text: r.key }), " ",
el("button", { class: "linkish", text: "copy", onclick: (e) => copy(r.key, e.target) }),
el("div", { class: "muted", style: "margin-top:6px", text: person
? "Send it to them privately. They paste it into this dashboard, or use 📱 Phone after signing in."
: `For an agent: python connect.py wrap --agent ${r.name} --key <this key> … (see README)` }));
f.reset(); syncKind(); loadTeam();
} catch (err) { toast(err.message); }
});
// ---------- reports
let repDays = 7;
try { repDays = +localStorage.getItem("gw.repDays") || 7; } catch {}
document.querySelectorAll("#repRange button").forEach((b) => b.addEventListener("click", () => {
repDays = +b.dataset.d; try { localStorage.setItem("gw.repDays", repDays); } catch {} loadReports();
}));
const dur = (s) => s == null ? "—" : s < 60 ? `${Math.round(s)}s` : s < 3600 ? `${Math.round(s / 60)} min` : `${(s / 3600).toFixed(1)} h`;
const cells = (tbody, rows, empty) => $(tbody).replaceChildren(...(rows.length ? rows : [el("tr", {}, el("td", { colspan: "9", class: "muted", text: empty }))]));
let weeklyText = "";
async function loadCaught() {
let w;
try { w = await api("/v1/report/weekly", { days: repDays }); } catch { return; }
weeklyText = w.text;
$("#caughtSub").textContent = w.caught.length ? `last ${repDays === 1 ? "24 hours" : repDays + " days"}` : "nothing stopped in this period";
$("#caughtList").replaceChildren(...(w.caught.length ? w.caught.map((c) => el("tr", {},
el("td", {}, el("b", { text: c.outcome.charAt(0).toUpperCase() + c.outcome.slice(1) }),
...(c.by ? [el("div", { class: "sub", text: `by ${c.by}${c.note ? ': "' + c.note + '"' : ""}` })] : []),
...(c.share ? [el("button", { class: "linkish", type: "button", text: "Share", onclick: () => shareCaught(c) })] : [])),
el("td", {}, el("code", { text: c.what }), el("div", { class: "sub", text: c.agent || "" })),
el("td", { class: "sub", text: c.why }),
el("td", { class: "sub", text: c.effect || "—" })))
: [el("tr", {}, el("td", { colspan: 4, class: "sub", text: "Nothing was blocked, rejected, or (in shadow mode) would have been." }))]));
}
$("#copyWeekly").addEventListener("click", (e) => copy(weeklyText, e.target));
// Share cards on squidbrake.com, made from the link alone: words and counts, never a command's arguments
const SHARE = "https://squidbrake.com";
const SHARE_OUTCOME = { "blocked": "blocked", "rejected": "rejected", "would have been blocked": "would-block",
"would have waited for a person": "would-hold" };
function shareCaught(c) {
const s = c.share, q = new URLSearchParams({ what: s.what, outcome: SHARE_OUTCOME[c.outcome] });
if (s.agent) q.set("agent", s.agent);
if (s.rule) q.set("rule", s.rule);
if (s.decide_s != null) q.set("s", s.decide_s);
if (!confirm(`This opens a public page anyone can see:
${s.what}${s.agent ? " · " + s.agent : ""}${s.rule ? " · rule " + s.rule : ""} · ${c.outcome}
` +
"Only the program's name, never its arguments, files or who approved. Open it?")) return;
window.open(`${SHARE}/caught?${q}`, "_blank", "noopener");
}
let lastSummary = null;
$("#shareWeek").addEventListener("click", () => {
const r = lastSummary;
if (!r) return;
const rejected = r.approvals.rejected, q = new URLSearchParams({ e: r.total, h: r.approvals.held, r: rejected,
b: Math.max(0, (r.by_status.denied || 0) - rejected), d: repDays });
if (!confirm(`This opens a public page with your counts only (${r.total} actions, ${r.approvals.held} held). Open it?`)) return;
window.open(`${SHARE}/week?${q}`, "_blank", "noopener");
});
async function loadReports() {
loadCaught();
document.querySelectorAll("#repRange button").forEach((b) => b.setAttribute("aria-pressed", String(+b.dataset.d === repDays)));
const [r, log] = await Promise.all([api("/v1/reports/summary", { days: repDays }), api("/v1/audit/log", { limit: 40 })]);
lastSummary = r;
const s = r.by_status, a = r.approvals;
$("#printHead").replaceChildren(el("h1", { text: `AI agent activity report — last ${repDays === 1 ? "24 hours" : repDays + " days"}` }),
el("div", { class: "muted", text: `Generated ${fmtDate(r.generated_at)} by ${me.client} · audit trail ${r.audit.ok ? "verified" : "BROKEN"} · fingerprint ${r.audit.head_hash || "—"}` }));
// Status icon + this report's own wording (the tiles count different things than the status names).
const iconLabel = (status, text) => el("span", { class: "st " + status }, el("i", { "aria-hidden": "true", text: STATUS[status].icon }), text);
const tile = (label, value, sub, status) => el("div", { class: "card tile", style: "cursor:default" },
el("div", { class: "lbl" }, status ? iconLabel(status, label) : label), el("div", { class: "val", text: value }), el("div", { class: "pct", text: sub || "" }));
$("#repTiles").replaceChildren(
tile("Agent actions", nf.format(r.total), `${r.agents.length} agent${r.agents.length === 1 ? "" : "s"}`),
tile("Blocked", nf.format(s.denied || 0), "by rules, people or stop", "denied"),
tile("Held for a person", nf.format(a.held), `${a.waiting_now} waiting now`, "awaiting_approval"),
tile("Approved by a person", nf.format(a.approved), `${a.rejected} rejected · ${a.timed_out} timed out`, "completed"),
tile("Time to decide", dur(a.median_seconds_to_decide), "median"),
tile("Failed", nf.format(s.failed || 0), "errors from tools", "failed"),
...(r.shadow && (r.shadow.mode === "shadow" || r.shadow.agents.length || r.shadow.would_block || r.shadow.would_hold)
? [tile("Shadow mode: would block", nf.format(r.shadow.would_block), "let through, only recorded", "denied"),
tile("Shadow mode: would hold", nf.format(r.shadow.would_hold), "would have asked a person", "awaiting_approval")] : []),
...(r.week ? [
tile("Decision time", r.week.latency_ms.p50 == null ? "—" : `${r.week.latency_ms.p50} ms`,
r.week.latency_ms.p95 == null ? "p50" : `p50 · p95 ${r.week.latency_ms.p95} ms · p99 ${r.week.latency_ms.p99} ms`),
tile("False positives", r.week.false_positives.rate == null ? "—" : `${(r.week.false_positives.rate * 100).toFixed(1)}%`,
`${r.week.false_positives.legit} of ${r.week.false_positives.stopped} marked "this was legit"`)] : []));
const au = r.audit;
const ig = r.integrity && r.integrity.changes && r.integrity.changes.length ? r.integrity : null;
$("#auditCard").replaceChildren(el("div", { class: "card-head" }, el("h2", { text: "Tamper check" }),
el("button", { class: "linkish", text: "Re-check now", onclick: loadReports })),
el("div", { class: "audit-ok " + (au.ok ? "ok" : "bad") }, el("div", { class: "big", text: au.ok ? "✓" : "!" }),
el("div", {}, el("b", { text: au.ok ? "Audit trail intact" : au.events_changed ? "Recorded actions were edited afterwards"
: `Audit trail broken at entry #${au.first_bad_seq}` }),
el("div", { class: "sec", text: `${nf.format(au.entries)} entries. ${au.message}.` }),
el("div", { class: "muted", style: "margin-top:6px;font-size:12.5px", text: "Each entry is chained to the one before by a fingerprint, so any edit or deletion shows up here. Keep this fingerprint to prove later that today's history hasn't changed:" }),
el("div", { class: "fp", text: au.head_hash || "—" }),
ig ? el("div", { class: "sec", style: "margin-top:8px;color:var(--bad, #b91c1c)",
text: `Squidbrake's own files changed: ${ig.changes.slice(0, 5).map((c) => `${c.what} ${c.change}`).join("; ")}. ` +
`If you did this, run: squidbrake integrity accept.` + (ig.mode === "fail_closed" ? " Until then, risky actions are blocked." : "") }) : null,
au.checkpoints ? el("div", { class: "sec", style: "margin-top:8px", text: au.checkpoints.count
? `${au.checkpoints.ok ? "✓" : "✕"} ${au.checkpoints.message}. Signed every 50 entries with this gateway's ledger key (public key: GET /v1/audit/public-key), so an export can be proven unedited.`
: "Signed checkpoints start at entry 50." }) : null,
el("div", { style: "margin-top:10px" }, el("button", { class: "btn", text: "⬇ Download evidence", onclick: downloadEvidence }),
el("span", { class: "muted", style: "margin-left:8px;font-size:12.5px", text: "Anyone can check it offline: python verify.py <file>" })))));
const stoppedAgents = controlsState.agents || {};
cells("#repAgents", r.agents.map((g) => el("tr", {},
el("td", {}, avatarEl(g.agent, "sm"), " ", el("b", { text: g.agent }), stoppedAgents[g.agent] ? el("span", { class: "tag", style: "border-color:var(--critical);color:var(--critical);margin-left:6px", text: "stopped" }) : null),
...["total", "denied", "held", "approved", "rejected", "failed"].map((k) => el("td", { class: "num", text: nf.format(g[k] || 0) })),
el("td", { class: "sec", text: fmtAgo(g.last_seen) }),
el("td", { class: "num" }, me.is_admin && stoppedAgents[g.agent] ? el("button", { class: "linkish", text: "Resume", onclick: () => resume(g.agent) })
: (me.can_approve || me.is_admin) && !stoppedAgents[g.agent] ? el("span", {},
el("button", { class: "linkish", text: "Note", onclick: () => askNote(g.agent) }), " ",
el("button", { class: "linkish", style: "color:var(--critical)", text: "Stop", onclick: () => askStop(g.agent) })) : null),
)), "No agent activity in this period.");
api("/v1/budgets").then((b) => {
$("#spendCard").hidden = !b.budgets.length;
const pct = (s) => Math.min(100, Math.round(100 * (s.token_limit ? s.tokens / s.token_limit : s.usd / s.usd_limit)));
cells("#spendRows", b.spend.map((s) => el("tr", {},
el("td", { class: "mono", text: `${s.budget} (per ${s.period})` }),
el("td", {}, s.who === "together" ? "all its agents together" : el("b", { text: s.who })),
el("td", { text: s.token_limit ? `${nf.format(s.tokens)} / ${nf.format(s.token_limit)} tokens` : `$${s.usd.toFixed(2)} / $${s.usd_limit.toFixed(2)}` }),
el("td", {}, el("div", { class: "meter" + (s.paused ? " over" : "") }, el("i", { style: `width:${pct(s)}%` })),
s.paused ? el("span", { class: "tag", style: "border-color:var(--critical);color:var(--critical)", text: "paused" }) : null),
el("td", { class: "sec", text: fmtDate(s.resets_at) }))), "No spend reported this period.");
}).catch(() => { $("#spendCard").hidden = true; });
cells("#repApprovers", a.by_approver.map((p) => el("tr", {}, el("td", { text: p.approver }),
el("td", { class: "num", text: p.approved }), el("td", { class: "num", text: p.rejected }))), "No decisions yet.");
cells("#repBlocked", r.blocked_by_rule.map((b) => el("tr", {}, el("td", { class: "mono", text: b.rule_id || "—" }),
el("td", { class: "wrap sec", text: b.reason || "" }), el("td", { class: "num", text: b.count }))), "Nothing blocked.");
cells("#repTools", r.top_tools.map((t) => el("tr", {}, el("td", { class: "mono", text: t.name }), el("td", { class: "num", text: nf.format(t.count) }))), "No calls.");
const describe = (e) => {
const d = e.detail || {};
const map = {
"event.created": () => `${d.source || "?"} asked to run ${d.name} → ${d.status}${d.rule_id ? " (" + d.rule_id + ")" : ""}`,
"event.approved": () => `approved${d.via === "link" ? " from a notification link" : ""}${d.decided_with ? " (" + d.decided_with + ", signed)" : ""}${d.note ? ": “" + d.note + "”" : ""}`,
"event.rejected": () => `rejected${d.via === "link" ? " from a notification link" : ""}${d.note ? ": “" + d.note + "”" : ""}`,
"event.expired": () => `nobody decided in time → ${d.outcome}`,
"event.completed": () => `finished${d.duration_ms != null ? " in " + fmtDur(d.duration_ms) : ""}`,
"event.failed": () => `failed: ${d.error || ""}`,
"controls.stopped": () => `${e.target === "*" ? "stopped ALL agents" : "stopped " + e.target.replace("session:", "session ")}${d.reason ? ": " + d.reason : ""}`,
"controls.resumed": () => e.target === "*" ? "resumed all agents" : `resumed ${e.target}`,
"team.added": () => `added ${e.target} (${d.kind}${d.approver ? ", can approve" : ""}${d.roles && d.roles.length ? ", " + d.roles.join("/") : ""})`,
"team.updated": () => `changed ${e.target}: ${d.approver ? "can approve" : "view only"}${d.roles && d.roles.length ? ", " + d.roles.join("/") : ""}`,
"team.removed": () => `removed ${e.target}`,
"settings.updated": () => `changed settings: ${(d.changed || []).join(", ")}`,
"audit.exported": () => `downloaded the audit CSV (${d.days} days)`,
"phone.link_shown": () => "opened the phone link QR code",
"event.snapshot": () => d.skipped ? `no undo copy: ${d.skipped}` : `kept an undo copy (${d.kind}${d.ref ? " " + d.ref : ""})`,
"event.proof": () => `${d.status === "confirmed" ? "checked: it really happened" : d.status === "mismatch" ? "NOT actually done" : "couldn't check"}: ${d.message || ""}`,
"device.added": () => `added a Face ID / fingerprint device: ${e.target}`,
"device.removed": () => `removed the device ${e.target}`,
};
return (map[e.action] || (() => e.action))();
};
cells("#repAudit", log.entries.map((e) => el("tr", {},
el("td", { class: "sec", title: e.at, text: fmtDate(e.at) }),
el("td", { text: e.actor || "system" }),
el("td", { class: "mono sec", text: e.action }),
el("td", { class: "wrap", text: describe(e) }))), "Nothing recorded yet.");
}
$("#printBtn").addEventListener("click", () => window.print());
$("#csvBtn").addEventListener("click", async () => {
try {
const res = await fetch(`/v1/audit/export.csv?days=${repDays}`, { headers: { "X-Gateway-Key": apiKey } });
if (!res.ok) throw new Error(`export failed (${res.status})`);
const url = URL.createObjectURL(await res.blob());
el("a", { href: url, download: `squidbrake-audit-${new Date().toISOString().slice(0, 10)}.csv` }).click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
loadReports();
} catch (err) { toast(err.message); }
});
$("#jsonlBtn").addEventListener("click", async () => {
try {
const res = await fetch(`/v1/audit/export.jsonl?days=${repDays}`, { headers: { "X-Gateway-Key": apiKey } });
if (!res.ok) throw new Error(`export failed (${res.status})`);
const url = URL.createObjectURL(await res.blob());
el("a", { href: url, download: `squidbrake-audit-${new Date().toISOString().slice(0, 10)}.jsonl` }).click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
loadReports();
} catch (err) { toast(err.message); }
});
$("#evidenceBtn").addEventListener("click", async () => {
try {
const res = await fetch(`/v1/audit/evidence-pack?days=${Math.max(repDays, 30)}`, { headers: { "X-Gateway-Key": apiKey } });
if (!res.ok) throw new Error(`evidence pack failed (${res.status})`);
const url = URL.createObjectURL(await res.blob());
el("a", { href: url, download: `squidbrake-evidence-pack-${new Date().toISOString().slice(0, 10)}.html` }).click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
loadReports();
} catch (err) { toast(err.message); }
});
// ---------- settings
const sForm = $("#settingsForm");
// one switch per agent on this computer (admins, and only when the gateway runs on this computer)
function renderAgentsHere(list) {
const box = $("#agentsList");
box.replaceChildren(...list.map((a) => {
const sw = el("input", { type: "checkbox", "aria-label": `Send ${a.label} through Squidbrake` });
sw.checked = a.connected;
sw.disabled = !a.present && !a.connected;
sw.onchange = async () => {
sw.disabled = true;
try {
const r = await apiPost(`/v1/agents/local/${encodeURIComponent(a.name)}`, { on: sw.checked });
renderAgentsHere(r.agents);
toast(`${a.label}: ${sw.checked ? "connected" : "taken out"}. Quit and reopen ${a.label} to apply.`);
} catch (err) { toast(String(err.message || err)); sw.checked = !sw.checked; sw.disabled = false; }
};
return el("label", { class: "toggle-row", style: "display:flex;align-items:center;gap:10px;padding:6px 0" }, sw,
el("b", { text: a.label }),
el("span", { class: "hint", text: a.connected ? "goes through Squidbrake" : a.present ? "not connected" : "not installed here",
title: a.connected && a.checks ? `Checks: ${a.checks}. Not checked: ${a.not_checked}` : "" }),
a.connected && a.not_checked && a.not_checked !== "nothing known"
? el("span", { class: "hint", text: `not checked: ${a.not_checked}` }) : null);
}));
}
async function loadAgentsHere() {
if (!(me && me.is_admin)) return;
try { const r = await api("/v1/agents/local"); $("#agentsHere").hidden = false; renderAgentsHere(r.agents); }
catch { $("#agentsHere").hidden = true; } // a gateway on a server: nothing to switch here
}
async function loadSettings() {
loadAgentsHere();
loadTeamAgents();
const [s, team] = await Promise.all([api("/v1/settings"), api("/v1/team")]);
for (const k of ["public_url", "ntfy_topic", "slack_webhook", "slack_signing_secret", "teams_webhook", "email_to",
"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from"]) sForm[k].value = s[k] || "";
if (s.smtp_host) $("#emailSetup").open = true;
for (const k of ["siem_url", "siem_token"]) $("#siemForm")[k].value = s[k] || "";
$("#siemForm").siem_format.value = s.siem_format || "json";
loadRuleHelp();
if (s.slack_signing_secret) $("#slackSetup").open = true;
api("/v1/slack/manifest").then((m) => { $("#slackManifest").textContent = JSON.stringify(m.manifest, null, 2); }).catch(() => {});
sForm.weekly_digest.checked = !!s.weekly_digest;
sForm.second_person.checked = !!s.second_person;
const approvers = team.members.filter((m) => m.approver).map((m) => m.name);
$("#notifyAs").replaceChildren(...approvers.map((n) => el("option", { value: n, text: n, selected: n === s.notify_as ? "" : null })));
$("#custForm").customers.value = (s.customers || []).join("\n");
$("#custCount").textContent = s.customers && s.customers.length ? `${s.customers.length} on the list` : "";
const ai = $("#aiForm");
ai.ai_briefs.checked = !!s.ai_briefs; ai.ai_model.value = s.ai_model || ""; ai.ai_key.value = "";
$("#aiKeyHint").textContent = s.ai_key_set ? `A key ending ${s.ai_key_hint || "…"} is set. Paste a new one to replace it.` : "Kept on this gateway and never shown again.";
$("#devRequired").checked = !!s.require_device_signature;
renderDevices();
loadMcp();
}
async function renderDevices() {
const r = await loadDevices();
if (!r) return;
$("#devSub").textContent = r.required ? "required for approvals" : "";
cells("#devRows", r.devices.map((d) => el("tr", {}, el("td", {}, el("b", { text: d.name || "device" })), el("td", { text: d.person }),
el("td", { class: "sec", text: fmtDate(d.created_at) }), el("td", { class: "sec", text: d.last_used_at ? fmtAgo(d.last_used_at) : "never" }),
el("td", { class: "num" }, el("button", { class: "linkish", style: "color:var(--critical)", text: "Remove", onclick: async () => {
if (!confirm(`Remove ${d.name}? Approvals from it stop working.`)) return;
try { await apiSend("DELETE", `/v1/devices/${encodeURIComponent(d.credential_id)}`); renderDevices(); } catch (err) { toast(err.message); }
} })))), "No devices yet.");
}
$("#devAdd").addEventListener("click", async () => {
if (!passkeysWork()) { $("#devMsg").textContent = "This browser can't use passkeys here (it needs https, or localhost)."; return; }
const name = prompt("Name this device (e.g. Pat's iPhone):", /iPhone|iPad|Android|Mac|Windows/.exec(navigator.userAgent)?.[0] || "My device");
if (!name) return;
try { await registerDevice(name.trim()); $("#devMsg").textContent = "Added ✓ Your approvals from this device are now signed."; renderDevices(); }
catch (err) { $("#devMsg").textContent = err.name === "NotAllowedError" ? "Cancelled." : err.message; }
});
$("#devRequired").addEventListener("change", async (ev) => {
try { await apiSend("PUT", "/v1/settings", { require_device_signature: ev.target.checked }); renderDevices(); toast(ev.target.checked ? "Approvals now need Face ID or fingerprint" : "Plain approvals allowed again"); }
catch (err) { ev.target.checked = !ev.target.checked; toast(err.message); }
});
$("#aiForm").addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = ev.target, body = { ai_briefs: f.ai_briefs.checked, ai_model: f.ai_model.value.trim() || null };
if (f.ai_key.value.trim()) body.ai_key = f.ai_key.value.trim();
try { await apiSend("PUT", "/v1/settings", body); $("#aiMsg").textContent = "Saved ✓"; loadSettings(); }
catch (err) { $("#aiMsg").textContent = err.message; }
});
$("#aiForget").addEventListener("click", async () => {
try { await apiSend("PUT", "/v1/settings", { ai_key: "", ai_briefs: false }); $("#aiMsg").textContent = "Key removed"; loadSettings(); }
catch (err) { $("#aiMsg").textContent = err.message; }
});
$("#custForm").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const s = await apiSend("PUT", "/v1/settings", { customers: ev.target.customers.value.split("\n") });
ev.target.customers.value = s.customers.join("\n");
$("#custCount").textContent = s.customers.length ? `${s.customers.length} on the list` : "";
$("#custMsg").textContent = "Saved ✓"; setTimeout(() => ($("#custMsg").textContent = ""), 3000);
} catch (err) { $("#custMsg").textContent = err.message; }
});
// ---------- MCP servers by URL
let mcpCatalog = [];
function showAuth(kind) {
$("#mcpForm").auth.value = kind;
document.querySelectorAll("#mcpForm [data-auth]").forEach((n) => { n.hidden = n.dataset.auth !== kind; });
}
$("#mcpAuth").addEventListener("change", (e) => showAuth(e.target.value));
async function loadMcpCatalog() {
if (mcpCatalog.length) return;
let r;
try { r = await api("/v1/mcp-catalog"); } catch { return; }
mcpCatalog = r.apps;
$("#mcpRedirect").textContent = r.redirect_uri.includes("<") ? location.origin + "/v1/mcp-oauth/callback" : r.redirect_uri;
const groups = {};
for (const a of mcpCatalog) (groups[a.category] = groups[a.category] || []).push(a);
$("#mcpApp").append(...Object.keys(groups).sort().map((cat) => el("optgroup", { label: cat },
...groups[cat].map((a) => el("option", { value: a.id, text: a.name })))));
}
$("#mcpApp").addEventListener("change", () => {
const f = $("#mcpForm"), a = mcpCatalog.find((x) => x.id === f.app.value);
if (!a) { $("#mcpAppHint").textContent = "Pick an app and only its token is left to paste."; return; }
f.name.value = a.id; f.url.value = a.url;
const docs = el("a", { href: a.source, target: "_blank", rel: "noopener", text: "Docs" });
if (a.auth === "oauth") {
showAuth("oauth");
$("#mcpAppHint").replaceChildren("Add it, then click Connect and sign in to ", a.name, ". ",
...(a.note ? [a.note + " "] : []), docs);
(a.own_app ? f.client_id : f.querySelector("button[type=submit]")).focus();
return;
}
showAuth("header");
f.hname.value = a.header; f.hvalue.value = ""; f.hvalue.placeholder = a.value.replace("<TOKEN>", "your token");
$("#mcpAppHint").replaceChildren(`Token: ${a.token}. Paste it as `, el("code", { text: a.value.replace("<TOKEN>", "…") }), ". ",
...(a.note ? [a.note + " "] : []), docs);
f.hvalue.focus();
});
async function connectMcp(name, btn) {
btn.disabled = true; btn.textContent = "Starting…";
try {
const r = await apiSend("POST", `/v1/mcp-servers/${encodeURIComponent(name)}/connect`);
if (r.authorize_url) window.open(r.authorize_url, "_blank", "noopener");
btn.textContent = "Waiting for sign-in…";
for (let i = 0; i < 180; i++) { // up to 6 minutes for the person to sign in
await new Promise((res) => setTimeout(res, 2000));
const m = (await api("/v1/mcp-servers")).servers.find((s) => s.name === name);
if (m && (m.connected && String(m.sign_in || "").startsWith("connected") || String(m.sign_in || "").startsWith("sign-in failed"))) break;
}
} catch (err) { toast(err.message); }
loadMcp();
}
async function loadMcp() {
loadMcpCatalog();
let r;
try { r = await api("/v1/mcp-servers"); } catch { return; }
$("#mcpList").replaceChildren(...(r.servers.length ? r.servers.map((m) => {
const copyBtn = el("button", { class: "btn", type: "button", text: "Copy", onclick: (e) => copy(m.endpoint, e.target) });
const del = el("button", { class: "btn", type: "button", text: "Remove", onclick: async () => {
if (!confirm(`Remove ${m.name}? Agents using its URL stop reaching the app.`)) return;
try { await apiSend("DELETE", `/v1/mcp-servers/${encodeURIComponent(m.name)}`); loadMcp(); } catch (err) { toast(err.message); }
} });
const status = m.auth === "oauth"
? el("td", {}, el("span", { text: m.connected ? "signed in" : "not signed in yet" }),
...(m.sign_in && !String(m.sign_in).startsWith("connected") ? [el("div", { class: "sub", text: m.sign_in })] : []), " ",
el("button", { class: "btn", type: "button", text: m.connected ? "Reconnect" : "Connect",
onclick: (e) => connectMcp(m.name, e.target) }))
: el("td", { text: m.running ? "running" : "starts on first use" });
return el("tr", {}, el("td", {}, el("b", { text: m.name })),
el("td", {}, el("code", { text: m.endpoint }), " ", copyBtn),
el("td", { class: "sub", text: (m.url || m.command || "") + (m.headers.length ? ` (+ ${m.headers.join(", ")})` : "")
+ (m.auth === "oauth" ? ` (OAuth${m.own_app ? ", your app" : ""})` : "") }),
status, el("td", {}, del));
}) : [el("tr", {}, el("td", { colspan: 5, class: "sub", text: "None yet. Add one below." }))]));
}
$("#mcpForm").addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = ev.target, headers = {}, oauth = f.auth.value === "oauth";
if (!oauth && f.hname.value.trim() && f.hvalue.value) headers[f.hname.value.trim()] = f.hvalue.value;
const body = oauth ? { url: f.url.value.trim(), auth: "oauth", client_id: f.client_id.value.trim() || null,
client_secret: f.client_secret.value || null, scope: f.scope.value.trim() || null }
: { url: f.url.value.trim(), headers };
try {
const m = await apiSend("PUT", `/v1/mcp-servers/${encodeURIComponent(f.name.value.trim())}`, body);
$("#mcpMsg").textContent = oauth ? `Added. Now click Connect next to ${m.name} and sign in.`
: `Added. Give your agent ${m.endpoint}`;
f.reset(); showAuth("header"); loadMcp();
} catch (err) { $("#mcpMsg").textContent = err.message; }
});
$("#useOrigin").addEventListener("click", () => { sForm.public_url.value = location.origin; });
$("#copyManifest").addEventListener("click", (e) => copy($("#slackManifest").textContent, e.target));
$("#siemForm").addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = ev.target;
try {
await apiSend("PUT", "/v1/settings", { siem_url: f.siem_url.value.trim(), siem_token: f.siem_token.value,
siem_format: f.siem_format.value });
$("#siemMsg").textContent = f.siem_url.value.trim() ? "Saved ✓ Decisions now go there." : "Saved ✓ (off)";
} catch (err) { $("#siemMsg").textContent = err.message; }
});
$("#siemTest").addEventListener("click", async () => {
try { const r = await apiPost("/v1/settings/siem-test"); $("#siemMsg").textContent = r.ok ? "Delivered ✓" : r.error; }
catch (err) { $("#siemMsg").textContent = err.message; }
});
async function loadRuleHistory() {
let v;
try { v = await api("/v1/policy/versions"); } catch { return; }
$("#ruleHistory").replaceChildren(...v.versions.slice(0, 15).map((x) => el("div", { class: "suggest" },
el("div", { class: "grow" }, el("code", { text: x.fingerprint }), x.current ? el("span", { class: "tag", style: "margin-left:6px", text: "in use" }) : null,
el("div", { class: "sub", text: `first used ${fmtAgo(x.first_seen)}` })),
x.current ? null : el("button", { class: "btn", type: "button", text: "Changes", onclick: async () => {
try { const d = await api(`/v1/policy/versions/${x.fingerprint}`); $("#ruleDiff").hidden = false; $("#ruleDiff").textContent = d.diff_to_current || "(no differences)"; }
catch (err) { toast(err.message); }
} }),
x.current || !(me && me.is_admin) ? null : el("button", { class: "btn", type: "button", text: "Put back", onclick: async () => {
if (!confirm(`Put rules version ${x.fingerprint} back? Today's rules.yaml is kept as a backup.`)) return;
try { const r = await apiPost(`/v1/policy/versions/${x.fingerprint}/restore`); toast(`Rules put back (backup: ${r.backup})`); loadRuleHistory(); }
catch (err) { toast(err.message); }
} }))));
}
async function loadRuleHelp() {
loadRuleHistory();
let s, p;
try { [s, p] = await Promise.all([api("/v1/policy/suggestions"), api("/v1/policy/packs")]); } catch { return; }
$("#ruleSuggest").replaceChildren(...(s.suggestions.length ? [el("h3", { class: "sub-h", text: "Suggested: stop asking about these" }),
...s.suggestions.map((x) => el("div", { class: "suggest" },
el("div", { class: "grow" }, el("code", { text: x.what }),
el("div", { class: "sub", text: `approved or marked legit ${x.approved} times in 30 days, never rejected${x.held_by ? " · held by " + x.held_by : ""}` })),
el("button", { class: "btn", type: "button", text: "Allow it", title: x.yaml, onclick: async (e) => {
if (!confirm(`Add this rule to rules.yaml?\n\n${x.yaml}`)) return;
try { await apiPost(`/v1/policy/suggestions/${encodeURIComponent(x.id)}/apply`); toast("Rule added"); loadRuleHelp(); }
catch (err) { toast(err.message); }
} })))] : []));
$("#rulePacks").replaceChildren(...p.packs.map((x) => el("div", { class: "pack" }, el("b", { text: x.title }),
el("p", { text: x.about }),
x.added ? el("span", { class: "sub", text: "Added ✓" }) : el("button", { class: "btn", type: "button", text: "Add pack",
title: x.yaml, onclick: async () => {
if (!confirm(`Add the ${x.title} rules to rules.yaml?\n\n${x.yaml}`)) return;
try { await apiPost(`/v1/policy/packs/${x.id}/apply`); toast(`${x.title} rules added`); loadRuleHelp(); }
catch (err) { toast(err.message); }
} }))));
}
$("#genTopic").addEventListener("click", () => {
const b = new Uint8Array(9); crypto.getRandomValues(b);
sForm.ntfy_topic.value = "gw-" + Array.from(b, (x) => x.toString(36).padStart(2, "0")).join("").slice(0, 16);
});
sForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await apiSend("PUT", "/v1/settings", { public_url: sForm.public_url.value.trim(), ntfy_topic: sForm.ntfy_topic.value.trim(),
slack_webhook: sForm.slack_webhook.value.trim(), slack_signing_secret: sForm.slack_signing_secret.value.trim(),
teams_webhook: sForm.teams_webhook.value.trim(), email_to: sForm.email_to.value.trim(),
smtp_host: sForm.smtp_host.value.trim(), smtp_port: sForm.smtp_port.value ? +sForm.smtp_port.value : null,
smtp_user: sForm.smtp_user.value.trim(), smtp_password: sForm.smtp_password.value,
smtp_from: sForm.smtp_from.value.trim(),
notify_as: sForm.notify_as.value || null, weekly_digest: sForm.weekly_digest.checked,
second_person: sForm.second_person.checked });
$("#settingsMsg").textContent = "Saved ✓"; setTimeout(() => ($("#settingsMsg").textContent = ""), 3000);
} catch (err) { $("#settingsMsg").textContent = err.message; }
});
$("#testNotify").addEventListener("click", async () => {
try {
const r = await apiPost("/v1/settings/test");
$("#settingsMsg").textContent = (r.sent.length ? `Sent to ${r.sent.join(" and ")} ✓ ` : "") + r.errors.join(" · ");
} catch (err) { $("#settingsMsg").textContent = err.message; }
});
$("#ruleTest").addEventListener("submit", async (ev) => {
ev.preventDefault();
const f = ev.target;
let input = f.input.value.trim();
try { input = input ? JSON.parse(input) : null; } catch { /* plain text input is fine */ }
try {
const r = await apiPost("/v1/policy/check", { name: f.name.value.trim(), source: f.source.value.trim() || null, input });
const what = { allow: "✓ Allowed", deny: "✕ Blocked", review: "? Held for a person" }[r.decision];
$("#ruleResult").textContent = `${what} — ${r.reason}${r.rule_id ? " (rule " + r.rule_id + ")" : ""}`
+ (r.decision === "review" ? `; if nobody decides within ${Math.round(r.timeout_seconds / 60)} min it's ${r.on_timeout === "allow" ? "allowed" : "blocked"}${r.approvers ? "; who can approve: " + r.approvers.join(", ") : ""}` : "");
} catch (err) { $("#ruleResult").textContent = err.message; }
});
// ---------- phone
$("#phoneBtn").addEventListener("click", async () => {
try {
const r = await api("/v1/me/phone-link");
$("#phoneAs").textContent = r.as;
$("#phoneQr").src = r.qr;
$("#phoneWarn").hidden = r.public;
$("#phoneDlg").showModal();
} catch (err) { toast(err instanceof AuthError ? "Sign in first" : err.message); }
});
renderFilters();
showView(location.hash.slice(1));
reload().then(() => { if (linkedEvent) openDrawer(linkedEvent); });
})();
</script>
</body>
</html>