The Insights tab ignored the time-range picker in the upper right: clicking '15 minutes' still showed findings from the analyzer's fixed background window (WindowDays, e.g. 7 days), because /api/insights always returned the cached background result regardless of what range the UI requested. - internal/insights/analyzer.go: split Refresh()'s logic into a shared analyze(from, to) core, and add a public AnalyzeRange(from, to) that runs the detectors on demand for an arbitrary window without disturbing the cached background result used by Latest() and the Stop-hook/surfacing path. - internal/web/insights.go: handleInsights now honors optional from/to query params, calling AnalyzeRange for that window instead of Latest() when present. - internal/web/static/app.js: loadInsights() sends the current state.rangeMicros as from/to, and the window label now renders minutes/hours/days appropriately (windowLabel()) instead of always rounding up to whole days, so a 15-minute selection reads as '15 minutes' rather than '1 day'.
1715 lines
61 KiB
JavaScript
1715 lines
61 KiB
JavaScript
/* Fathom UI.
|
||
*
|
||
* Vanilla JS, no framework, no build step — the whole app ships inside the Go
|
||
* binary. It is small enough that a framework would be more code than the code.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
// ---------------------------------------------------------------- state
|
||
|
||
const state = {
|
||
rangeMicros: 86400000000, // 24h, matching the pre-selected button
|
||
search: '',
|
||
status: '',
|
||
traces: [],
|
||
trace: null, // the loaded detail payload
|
||
selected: null, // selected span id
|
||
onlyContent: false,
|
||
merge: true, // fold emitter wrapper spans by default
|
||
|
||
// Live updates. `pending` holds traces that arrived while the reader was
|
||
// scrolled away from the top, so nothing shifts under them unannounced.
|
||
live: 'connecting', // connecting | live | offline
|
||
rows: new Map(), // trace_id -> <tr>, so refreshes patch instead of rebuild
|
||
pending: 0,
|
||
listLoaded: false, // first paint is not an 'arrival'
|
||
};
|
||
|
||
const KIND = {
|
||
llm_call: { label: 'model', color: 'var(--kind-llm)' },
|
||
tool_call: { label: 'tool', color: 'var(--kind-tool)' },
|
||
agent_step: { label: 'agent', color: 'var(--kind-agent)' },
|
||
retrieval: { label: 'retrieval', color: 'var(--kind-retrieval)' },
|
||
mcp: { label: 'mcp', color: 'var(--kind-mcp)' },
|
||
hook: { label: 'hook', color: 'var(--kind-hook)' },
|
||
other: { label: 'other', color: 'var(--kind-other)' },
|
||
};
|
||
|
||
const kindColor = k => (KIND[k] || KIND.other).color;
|
||
|
||
// ---------------------------------------------------------------- utilities
|
||
|
||
const $ = sel => document.querySelector(sel);
|
||
const $$ = sel => Array.from(document.querySelectorAll(sel));
|
||
|
||
function el(tag, attrs = {}, ...kids) {
|
||
const n = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (v === null || v === undefined || 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 if (k === 'style') n.setAttribute('style', v);
|
||
else n.setAttribute(k, v);
|
||
}
|
||
for (const kid of kids.flat()) {
|
||
if (kid === null || kid === undefined || kid === false) continue;
|
||
n.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/* Durations are formatted to three significant figures with a unit that keeps
|
||
* the number small. A waterfall full of "1403000µs" is unreadable; the same
|
||
* data as "1.40s" is not. */
|
||
function fmtDur(micros) {
|
||
if (micros === null || micros === undefined) return '—';
|
||
const ms = micros / 1000;
|
||
if (ms < 1) return `${micros}µs`;
|
||
if (ms < 1000) return `${ms < 10 ? ms.toFixed(1) : Math.round(ms)}ms`;
|
||
const s = ms / 1000;
|
||
if (s < 60) return `${s.toFixed(s < 10 ? 2 : 1)}s`;
|
||
const m = Math.floor(s / 60);
|
||
return `${m}m ${Math.round(s - m * 60)}s`;
|
||
}
|
||
|
||
function fmtNum(n) {
|
||
if (!n) return '0';
|
||
if (n < 1000) return String(n);
|
||
if (n < 1e6) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
|
||
return `${(n / 1e6).toFixed(2)}M`;
|
||
}
|
||
|
||
function fmtCost(usd) {
|
||
if (!usd) return '—';
|
||
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
||
return `$${usd.toFixed(2)}`;
|
||
}
|
||
|
||
function fmtAgo(micros) {
|
||
const secs = (Date.now() - micros / 1000) / 1000;
|
||
if (secs < 60) return `${Math.max(0, Math.round(secs))}s ago`;
|
||
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
|
||
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
|
||
return `${Math.round(secs / 86400)}d ago`;
|
||
}
|
||
|
||
function fmtClock(micros) {
|
||
const d = new Date(micros / 1000);
|
||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
function toast(msg) {
|
||
const t = $('#toast');
|
||
t.textContent = msg;
|
||
t.hidden = false;
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => { t.hidden = true; }, 1600);
|
||
}
|
||
|
||
async function api(path, opts) {
|
||
const res = await fetch(path, opts);
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`);
|
||
return body;
|
||
}
|
||
|
||
function nowMicros() { return Date.now() * 1000; }
|
||
|
||
// ---------------------------------------------------------------- trace list
|
||
|
||
async function loadTraces() {
|
||
const to = nowMicros();
|
||
const from = to - state.rangeMicros;
|
||
const params = new URLSearchParams({ from, to, limit: 300 });
|
||
if (state.status) params.set('status', state.status);
|
||
if (state.search) params.set('q', state.search);
|
||
|
||
try {
|
||
const data = await api(`/api/traces?${params}`);
|
||
state.traces = data.traces || [];
|
||
renderTraces(data);
|
||
} catch (e) {
|
||
$('#list-scan').textContent = '';
|
||
showListEmpty(`Could not load traces: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function showListEmpty(message) {
|
||
$('#trace-rows').replaceChildren();
|
||
const box = $('#list-empty');
|
||
box.replaceChildren(el('p', { text: message }));
|
||
box.hidden = false;
|
||
}
|
||
|
||
function renderTraces(data) {
|
||
const tbody = $('#trace-rows');
|
||
const s = data.stats || {};
|
||
$('#list-scan').textContent =
|
||
`${data.total} traces · scanned ${fmtNum(s.rows_scanned || 0)} spans across ${s.shards_scanned || 0} shard${s.shards_scanned === 1 ? '' : 's'}`;
|
||
|
||
if (!state.traces.length) {
|
||
tbody.replaceChildren();
|
||
state.rows.clear();
|
||
const box = $('#list-empty');
|
||
box.replaceChildren(...[
|
||
el('p', { text: state.search || state.status
|
||
? 'No traces match those filters in this time range.'
|
||
: 'No traces in this time range yet.' }),
|
||
!state.search && !state.status
|
||
? el('p', {}, 'Point an agent at Fathom, or run ',
|
||
el('code', { text: 'fathom gen' }), ' for sample data.')
|
||
: null,
|
||
].filter(Boolean));
|
||
box.hidden = false;
|
||
return;
|
||
}
|
||
$('#list-empty').hidden = true;
|
||
|
||
/* Patch the table rather than rebuild it.
|
||
*
|
||
* Replacing the whole tbody on every refresh throws away hover state, any
|
||
* in-progress text selection, and the identity of every row — which makes a
|
||
* "new trace arrived" highlight impossible to express, because every row is
|
||
* new. Reusing the element per trace_id means an update touches only the
|
||
* cells whose values actually changed. */
|
||
const scroller = $('#view-traces .table-wrap');
|
||
const atTop = scroller.scrollTop < 8;
|
||
const seen = new Set();
|
||
let appeared = 0;
|
||
|
||
const frag = document.createDocumentFragment();
|
||
for (const t of state.traces) {
|
||
seen.add(t.trace_id);
|
||
let row = state.rows.get(t.trace_id);
|
||
if (row) {
|
||
updateTraceRow(row, t);
|
||
} else {
|
||
row = traceRow(t);
|
||
state.rows.set(t.trace_id, row);
|
||
// On first paint every row is new; highlighting all of them is noise.
|
||
if (state.listLoaded) {
|
||
appeared++;
|
||
row.classList.add('is-new');
|
||
setTimeout(() => row.classList.remove('is-new'), 2200);
|
||
}
|
||
}
|
||
frag.append(row);
|
||
}
|
||
|
||
for (const [id, row] of state.rows) {
|
||
if (!seen.has(id)) {
|
||
row.remove();
|
||
state.rows.delete(id);
|
||
}
|
||
}
|
||
|
||
// Appending existing nodes moves them into the new order without recreating
|
||
// them, so the scroll container is untouched.
|
||
tbody.append(frag);
|
||
|
||
state.listLoaded = true;
|
||
|
||
if (appeared && !atTop) {
|
||
// The reader is scrolled into the list. Inserting above them would shift
|
||
// everything down mid-read, so announce it instead and let them choose.
|
||
state.pending += appeared;
|
||
showNewPill();
|
||
} else if (atTop) {
|
||
state.pending = 0;
|
||
hideNewPill();
|
||
}
|
||
}
|
||
|
||
function showNewPill() {
|
||
const pill = $('#new-pill');
|
||
pill.textContent = `${state.pending} new trace${state.pending === 1 ? '' : 's'} ↑`;
|
||
pill.hidden = false;
|
||
}
|
||
|
||
function hideNewPill() {
|
||
$('#new-pill').hidden = true;
|
||
}
|
||
|
||
function traceRow(t) {
|
||
const row = el('tr', { onclick: () => navigate(`#/trace/${t.trace_id}`) },
|
||
// The relative-time text node is created up front so updates can patch it
|
||
// in place rather than rebuild the cell.
|
||
el('td', { class: 'cell-when' }, '', el('span', { class: 'abs' })),
|
||
// The prompt leads, not the span name. Every Claude Code trace is named
|
||
// "claude_code.interaction", so a name column repeats one string down the
|
||
// whole page while the thing that distinguishes runs sits in a subtitle.
|
||
el('td', {},
|
||
el('div', { class: 'trace-name' },
|
||
el('span', { class: 'status-dot' }),
|
||
el('span', { class: 'trace-headline' }),
|
||
),
|
||
el('div', { class: 'trace-id' }),
|
||
),
|
||
el('td', { class: 'num cell-dur' }),
|
||
el('td', {}, el('div', { class: 'shape' })),
|
||
el('td', { class: 'num cell-tok' }),
|
||
el('td', { class: 'num cell-cost' }),
|
||
);
|
||
updateTraceRow(row, t);
|
||
return row;
|
||
}
|
||
|
||
/* updateTraceRow writes only the cells whose value changed.
|
||
*
|
||
* A trace still in flight grows on every flush, so these cells are rewritten
|
||
* repeatedly. Assigning textContent unconditionally would collapse any text
|
||
* selection the reader had made inside the row, which is a strange thing to
|
||
* have happen while you are trying to copy a trace id. */
|
||
function updateTraceRow(row, t) {
|
||
const [whenTd, whatTd, durTd, shapeTd, tokTd, costTd] = row.children;
|
||
|
||
setText(whenTd.firstChild, fmtAgo(t.start_ts));
|
||
setText(whenTd.querySelector('.abs'), fmtClock(t.start_ts));
|
||
|
||
const dot = whatTd.querySelector('.status-dot');
|
||
const wantDot = `status-dot ${t.status}`;
|
||
if (dot.className !== wantDot) {
|
||
dot.className = wantDot;
|
||
dot.title = t.status;
|
||
}
|
||
setText(whatTd.querySelector('.trace-headline'), t.preview || t.name || '(unnamed)');
|
||
|
||
const meta = whatTd.querySelector('.trace-id');
|
||
const metaText = `${t.trace_id.slice(0, 12)} · ${t.span_count} spans` +
|
||
(t.model ? ` · ${t.model}` : '');
|
||
if (meta.dataset.text !== metaText || Boolean(t.partial) !== (meta.dataset.partial === '1')) {
|
||
meta.dataset.text = metaText;
|
||
meta.dataset.partial = t.partial ? '1' : '0';
|
||
meta.replaceChildren(...[
|
||
metaText,
|
||
// A partial trace has no root span yet, so any name here would be an
|
||
// arbitrary child's. Say what is actually true instead.
|
||
t.partial
|
||
? el('span', { class: 'chip-live', text: 'in progress' })
|
||
: (t.preview && t.name ? ` · ${t.name}` : ''),
|
||
].filter(Boolean).map(x => (x.nodeType ? x : document.createTextNode(String(x)))));
|
||
}
|
||
|
||
setText(durTd, fmtDur(t.duration_micros));
|
||
|
||
const shapeText = `${t.llm_calls}|${t.tool_calls}|${t.error_count}`;
|
||
const shape = shapeTd.firstElementChild;
|
||
if (shape.dataset.shape !== shapeText) {
|
||
shape.dataset.shape = shapeText;
|
||
shape.replaceChildren();
|
||
// One pip group per kind present, so the composition of the run is legible
|
||
// without opening it.
|
||
for (const [kind, n, label] of [
|
||
['llm_call', t.llm_calls, 'model call'],
|
||
['tool_call', t.tool_calls, 'tool call'],
|
||
]) {
|
||
if (!n) continue;
|
||
shape.append(el('span', { class: 'shape-group', title: `${n} ${label}${n === 1 ? '' : 's'}` },
|
||
el('span', { class: 'pip', style: `background:${kindColor(kind)}` }),
|
||
String(n),
|
||
));
|
||
}
|
||
if (t.error_count) {
|
||
shape.append(el('span', { class: 'badge-err', title: `${t.error_count} span(s) reported an error` },
|
||
`${t.error_count} err`));
|
||
}
|
||
}
|
||
|
||
const tokText = t.tokens ? fmtNum(t.tokens) : '—';
|
||
const cachedText = t.cached_tokens ? `${fmtNum(t.cached_tokens)} cached` : '';
|
||
if (tokTd.dataset.tok !== tokText + cachedText) {
|
||
tokTd.dataset.tok = tokText + cachedText;
|
||
tokTd.title = t.cached_tokens
|
||
? `${(t.tokens || 0).toLocaleString()} new tokens, ${t.cached_tokens.toLocaleString()} read from cache`
|
||
: `${(t.tokens || 0).toLocaleString()} tokens`;
|
||
tokTd.replaceChildren(...[
|
||
document.createTextNode(tokText),
|
||
cachedText ? el('span', { class: 'sub-num', text: cachedText }) : null,
|
||
].filter(Boolean));
|
||
}
|
||
|
||
setText(costTd, fmtCost(t.cost_usd));
|
||
}
|
||
|
||
// setText assigns only when the value differs, so an unchanged cell is left
|
||
// completely alone.
|
||
function setText(node, value) {
|
||
if (!node) return;
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
if (node.nodeValue !== value) node.nodeValue = value;
|
||
return;
|
||
}
|
||
if (node.textContent !== value) node.textContent = value;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- trace detail
|
||
|
||
async function loadTrace(id) {
|
||
$('#detail-name').textContent = 'Loading…';
|
||
$('#detail-meta').replaceChildren();
|
||
$('#detail-stats').replaceChildren();
|
||
$('#waterfall').replaceChildren();
|
||
$('#inspector').replaceChildren(el('div', { class: 'inspector-empty', text: 'Loading…' }));
|
||
|
||
try {
|
||
// The detail lookup defaults to a wider window than the list, so a link to
|
||
// an older trace still resolves after the list has scrolled past it.
|
||
const data = await api(`/api/traces/${encodeURIComponent(id)}`);
|
||
state.trace = data;
|
||
state.selected = null;
|
||
renderTrace(data);
|
||
} catch (e) {
|
||
$('#detail-name').textContent = 'Not found';
|
||
$('#inspector').replaceChildren(el('div', { class: 'inspector-empty', text: e.message }));
|
||
}
|
||
}
|
||
|
||
function renderTrace(data, opts = {}) {
|
||
const tr = data.trace;
|
||
const flat = data.flat || [];
|
||
const root = flat[0];
|
||
|
||
// Every Claude Code trace is named "claude_code.interaction", so the root
|
||
// name alone identifies nothing. The prompt is what the reader recognises.
|
||
const prompt = tracePrompt(flat);
|
||
$('#detail-name').textContent = prompt || (root ? root.span.name : tr.trace_id);
|
||
// replaceChildren stringifies null rather than skipping it, which is how a
|
||
// literal "null" ended up rendered in the header.
|
||
$('#detail-meta').replaceChildren(...[
|
||
el('span', { text: tr.trace_id }),
|
||
el('span', { text: new Date(tr.start_ts / 1000).toLocaleString() }),
|
||
tr.orphan_count ? el('span', { text: `${tr.orphan_count} orphaned`, title:
|
||
'These spans name a parent that was never received — usually the exporter was still batching when the process exited.' }) : null,
|
||
].filter(Boolean));
|
||
|
||
// Header stats, chosen to answer "was this slow, expensive, or broken?".
|
||
const totals = spanTotals(flat);
|
||
$('#detail-stats').replaceChildren(...[
|
||
stat(fmtDur(tr.end_ts - tr.start_ts), 'duration'),
|
||
stat(String(tr.span_count), 'spans'),
|
||
stat(fmtNum(totals.tokens), 'tokens'),
|
||
totals.cached ? stat(fmtNum(totals.cached), 'cached') : null,
|
||
stat(fmtCost(totals.cost), 'cost'),
|
||
stat(String(totals.errors), 'errors'),
|
||
].filter(Boolean));
|
||
|
||
renderLegend(flat);
|
||
renderWaterfall(tr, flat);
|
||
|
||
const restore = opts.restore;
|
||
if (restore) {
|
||
// A live refresh: put the reader back exactly where they were. If the span
|
||
// they had selected has since been folded or replaced, fall back rather
|
||
// than leaving nothing selected.
|
||
const still = flat.some(n => spanID(n) === restore.keepSelected);
|
||
selectSpan(still ? restore.keepSelected : (flat[0] && spanID(flat[0])), { silent: true });
|
||
$('#waterfall').scrollTop = restore.keepWaterfall;
|
||
$('#inspector').scrollTop = restore.keepInspector;
|
||
return;
|
||
}
|
||
|
||
// Opening on the first span that actually carries content puts the reader at
|
||
// the prompt rather than at an empty root.
|
||
const first = flat.find(n => nodeContent(n).length) || flat[0];
|
||
if (first) selectSpan(spanID(first));
|
||
}
|
||
|
||
// tracePrompt digs out the user's prompt, which is the only human-meaningful
|
||
// name a trace has.
|
||
function tracePrompt(flat) {
|
||
for (const n of flat) {
|
||
const p = attrStr(spanAttrs(n), 'user_prompt');
|
||
if (p) return p;
|
||
for (const l of n.logs || []) {
|
||
const lp = logAttr(l, 'prompt');
|
||
if (lp) return lp;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function stat(val, label) {
|
||
return el('div', { class: 'stat' },
|
||
el('div', { class: 'stat-val', text: val }),
|
||
el('div', { class: 'stat-label', text: label }),
|
||
);
|
||
}
|
||
|
||
function spanTotals(flat) {
|
||
let tokens = 0, cached = 0, cost = 0, errors = 0;
|
||
const tokenKeys = ['input_tokens', 'output_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens'];
|
||
const cacheKeys = ['cache_read_tokens', 'cache_creation_tokens', 'gen_ai.usage.cache_read_input_tokens'];
|
||
for (const n of flat) {
|
||
const a = spanAttrs(n);
|
||
for (const k of tokenKeys) tokens += Number(attrStr(a, k)) || 0;
|
||
for (const k of cacheKeys) cached += Number(attrStr(a, k)) || 0;
|
||
if (spanStatus(n) === 'error') errors++;
|
||
for (const l of n.logs || []) cost += Number(logAttr(l, 'cost_usd')) || 0;
|
||
}
|
||
return { tokens, cached, cost, errors };
|
||
}
|
||
|
||
function renderLegend(flat) {
|
||
const present = new Set(flat.map(n => spanKind(n)));
|
||
$('#legend').replaceChildren(...Array.from(present).map(k =>
|
||
el('span', { class: 'legend-item' },
|
||
el('span', { class: 'pip', style: `background:${kindColor(k)}` }),
|
||
(KIND[k] || KIND.other).label,
|
||
)));
|
||
}
|
||
|
||
// Kind and Status arrive as names, not integers, so there is no enum table to
|
||
// keep in sync with Go's.
|
||
const spanKind = n => n.span.kind || 'other';
|
||
const spanAttrs = n => n.span.attrs || {};
|
||
const spanStatus = n => n.span.status || 'unset';
|
||
const spanID = n => n.span.span_id;
|
||
const attrStr = (attrs, key) => String(attrs[key]?.str ?? '');
|
||
const logAttr = (l, key) => String((l.attrs || {})[key]?.str ?? '');
|
||
|
||
function renderWaterfall(tr, flat) {
|
||
const wf = $('#waterfall');
|
||
wf.replaceChildren();
|
||
for (const n of flat) { n._folded = 0; }
|
||
|
||
const total = Math.max(1, tr.end_ts - tr.start_ts);
|
||
let rows = state.merge ? foldWrappers(flat) : flat;
|
||
if (state.onlyContent) rows = rows.filter(n => nodeContent(n).length);
|
||
|
||
renderAxis(total);
|
||
|
||
for (const n of rows) {
|
||
const kind = spanKind(n);
|
||
const left = (n.offset_micros / total) * 100;
|
||
const width = Math.max(0.35, (n.duration_micros / total) * 100);
|
||
const content = nodeContent(n);
|
||
|
||
// The tree glyph is drawn rather than nested with indentation so that every
|
||
// row shares one grid and the bars all start from the same x origin.
|
||
const indent = state.onlyContent ? 0 : n.depth;
|
||
const glyph = indent ? '│ '.repeat(Math.max(0, indent - 1)) + '├─' : '';
|
||
|
||
const row = el('div', {
|
||
class: 'span-row',
|
||
'data-span': spanID(n),
|
||
onclick: () => selectSpan(spanID(n)),
|
||
},
|
||
el('div', { class: 'span-label', title: n.span.name },
|
||
glyph ? el('span', { class: 'span-tree', text: glyph }) : null,
|
||
el('span', { class: 'span-kind-dot', style: `background:${kindColor(kind)}` }),
|
||
el('span', { class: 'span-name' }, spanLabel(n)),
|
||
n._folded ? el('span', { class: 'fold-count', title:
|
||
`${n._folded} nested wrapper span(s) folded into this row` , text: `+${n._folded}` }) : null,
|
||
content.length ? el('span', { class: 'content-dot', title: `${content.length} content record(s)` }) : null,
|
||
),
|
||
el('div', { class: 'span-track' },
|
||
el('div', {
|
||
class: `span-bar${spanStatus(n) === 'error' ? ' is-error' : ''}`,
|
||
style: `left:${left}%;width:${width}%;background:${kindColor(kind)}`,
|
||
}),
|
||
),
|
||
el('div', { class: 'span-dur', text: fmtDur(n.duration_micros) }),
|
||
);
|
||
wf.append(row);
|
||
}
|
||
}
|
||
|
||
/* foldWrappers hides spans that restate their parent rather than adding to it.
|
||
*
|
||
* Claude Code emits every tool call as three spans: `claude_code.tool`, and
|
||
* beneath it `tool.execution` and `tool.blocked_on_user`, all sharing one
|
||
* tool_use_id. Rendered literally that triples the height of the tree and says
|
||
* the same thing three times — in a 33-span trace, 20 rows were pure restatement.
|
||
*
|
||
* A child is folded when it is a dotted extension of its parent's name, shares
|
||
* the parent's tool call id, and carries no content of its own. Anything that
|
||
* fails those tests — a child that took materially longer, that errored on its
|
||
* own, that holds a payload — is left visible, because then it is telling the
|
||
* reader something the parent does not. The count of folded children is shown on
|
||
* the parent so nothing disappears silently.
|
||
*/
|
||
function foldWrappers(flat) {
|
||
const byId = new Map(flat.map(n => [spanID(n), n]));
|
||
const folded = new Set();
|
||
|
||
for (const n of flat) {
|
||
const parent = byId.get(n.span.parent_span_id);
|
||
if (!parent) continue;
|
||
|
||
const isExtension = n.span.name.startsWith(parent.span.name + '.');
|
||
// Contained in the parent's window: a child that outlasts its parent is
|
||
// describing something the parent's bar does not already show.
|
||
const contained = n.duration_micros <= parent.duration_micros;
|
||
const hasOwnContent = nodeContent(n).length > 0;
|
||
const ownError = spanStatus(n) === 'error' && spanStatus(parent) !== 'error';
|
||
const hasChildren = flat.some(c => c.span.parent_span_id === spanID(n));
|
||
|
||
// Matching tool_use_id was the original test, but it is too strict: Claude
|
||
// Code stamps it on `tool` and `tool.execution` and omits it on
|
||
// `tool.blocked_on_user`, so permission-wait rows survived folding and the
|
||
// tree still carried a row per tool that said nothing.
|
||
if (isExtension && contained && !hasOwnContent && !ownError && !hasChildren) {
|
||
folded.add(spanID(n));
|
||
parent._folded = (parent._folded || 0) + 1;
|
||
}
|
||
}
|
||
for (const n of flat) if (!folded.has(spanID(n))) n._foldedShown = true;
|
||
return flat.filter(n => !folded.has(spanID(n)));
|
||
}
|
||
|
||
/* Span names from real emitters are long and repetitive
|
||
* ("claude_code.tool.execution"). Showing the distinguishing tail in full and
|
||
* dimming the shared prefix keeps the column scannable. A tool span is labelled
|
||
* by the tool it ran, which is what the reader is actually looking for. */
|
||
function spanLabel(n) {
|
||
const attrs = spanAttrs(n);
|
||
const name = n.span.name || '';
|
||
const tool = attrStr(attrs, 'tool_name');
|
||
const model = attrStr(attrs, 'gen_ai.request.model') || attrStr(attrs, 'model');
|
||
|
||
const parts = name.split('.');
|
||
const tail = parts.length > 1 ? parts.slice(1).join('.') : name;
|
||
const frag = document.createDocumentFragment();
|
||
frag.append(el('span', { text: tail }));
|
||
if (tool) frag.append(el('span', { class: 'qualifier', text: ` ${tool}` }));
|
||
else if (model) frag.append(el('span', { class: 'qualifier', text: ` ${model}` }));
|
||
return frag;
|
||
}
|
||
|
||
function renderAxis(totalMicros) {
|
||
const axis = $('#timeaxis');
|
||
axis.replaceChildren();
|
||
const steps = 5;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const pct = (i / steps) * 100;
|
||
if (i === steps) continue; // the last label would overflow the container
|
||
axis.append(el('div', {
|
||
class: 'tick',
|
||
style: `left:${pct}%`,
|
||
text: i === 0 ? '0' : fmtDur((totalMicros * i) / steps),
|
||
}));
|
||
}
|
||
}
|
||
|
||
function selectSpan(spanId, opts = {}) {
|
||
if (!spanId) return;
|
||
state.selected = spanId;
|
||
for (const row of $$('.span-row')) {
|
||
row.classList.toggle('is-selected', row.dataset.span === spanId);
|
||
}
|
||
const node = (state.trace?.flat || []).find(n => (spanID(n)) === spanId);
|
||
if (node) renderInspector(node);
|
||
// A live refresh restores scroll itself; scrolling here would fight it.
|
||
if (!opts.silent) {
|
||
const sel = $(`.span-row[data-span="${CSS.escape(spanId)}"]`);
|
||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
// Keep the URL pointing at exactly what is on screen, so a link shares the
|
||
// span, not just the trace.
|
||
const traceId = state.trace?.trace?.trace_id;
|
||
if (traceId) history.replaceState(null, '', `#/trace/${traceId}/${spanId}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- inspector
|
||
|
||
/* nodeContent turns a span's correlated log records into ordered transcript
|
||
* entries. This is where the correlation done on the server pays off: each
|
||
* span carries the prompt, tool payload, or response that belongs to it. */
|
||
function nodeContent(n) {
|
||
const out = [];
|
||
for (const l of n.logs || []) {
|
||
const a = l.attrs || {};
|
||
const ev = l.event_name || '';
|
||
const ts = l.ts || 0;
|
||
const get = k => String(a[k]?.str ?? '');
|
||
|
||
if (ev === 'user_prompt' && get('prompt')) {
|
||
out.push({ kind: 'prompt', title: 'User prompt', ts, body: get('prompt') });
|
||
} else if (ev === 'tool_result') {
|
||
const ok = get('success') !== 'false';
|
||
out.push({
|
||
kind: 'tool', failed: !ok, ts,
|
||
title: `${get('tool_name') || 'Tool'} ${ok ? '' : '— failed'}`.trim(),
|
||
input: get('tool_input') || get('tool_parameters'),
|
||
body: l.body || '',
|
||
meta: {
|
||
duration: get('duration_ms') ? `${get('duration_ms')}ms` : '',
|
||
bytes: get('tool_result_size_bytes'),
|
||
},
|
||
});
|
||
} else if (ev === 'assistant_response' && get('response')) {
|
||
out.push({ kind: 'response', title: 'Assistant', ts, body: get('response'),
|
||
meta: { model: get('model') } });
|
||
} else if (ev === 'api_request') {
|
||
out.push({ kind: 'api', title: 'Model call', ts, body: '', meta: {
|
||
model: get('model'),
|
||
in: get('input_tokens'), out: get('output_tokens'),
|
||
cacheRead: get('cache_read_tokens'),
|
||
cost: get('cost_usd'), duration: get('duration_ms') ? `${get('duration_ms')}ms` : '',
|
||
}});
|
||
}
|
||
}
|
||
// Span input/output, for emitters that put payloads on the span itself
|
||
// rather than in log records.
|
||
const inText = n.span.input || '';
|
||
const outText = n.span.output || '';
|
||
if (inText) out.push({ kind: 'prompt', title: 'Input', ts: 0, body: inText });
|
||
if (outText) out.push({ kind: 'response', title: 'Output', ts: 0, body: outText });
|
||
|
||
out.sort((a, b) => a.ts - b.ts);
|
||
return out;
|
||
}
|
||
|
||
function renderInspector(n) {
|
||
const insp = $('#inspector');
|
||
const attrs = spanAttrs(n);
|
||
const kind = spanKind(n);
|
||
const status = spanStatus(n);
|
||
const content = nodeContent(n);
|
||
|
||
const head = el('div', { class: 'insp-head' },
|
||
el('div', { class: 'insp-title' },
|
||
el('span', { class: 'span-kind-dot', style: `background:${kindColor(kind)}` }),
|
||
el('span', { text: n.span.name }),
|
||
status === 'error' ? el('span', { class: 'badge-err', text: 'error' }) : null,
|
||
),
|
||
el('div', { class: 'insp-sub' },
|
||
el('span', { text: fmtDur(n.duration_micros) }),
|
||
el('span', { text: `+${fmtDur(n.offset_micros)} into trace` }),
|
||
el('span', { text: `depth ${n.depth}` }),
|
||
el('span', { text: (spanID(n) ?? '').slice(0, 12) }),
|
||
),
|
||
);
|
||
|
||
const sections = [];
|
||
|
||
if (content.length) {
|
||
sections.push(section('Transcript', content.length, true,
|
||
el('div', { class: 'transcript' }, content.map(renderMessage))));
|
||
}
|
||
|
||
const attrKeys = Object.keys(attrs).sort();
|
||
if (attrKeys.length) {
|
||
const grid = el('div', { class: 'attrs' });
|
||
for (const k of attrKeys) {
|
||
const v = attrs[k];
|
||
const type = v.type ?? 0;
|
||
const str = String(v.str ?? '');
|
||
grid.append(
|
||
el('div', { class: 'attr-k', text: k }),
|
||
el('div', { class: `attr-v${type === 1 || type === 2 ? ' is-num' : type === 3 ? ' is-bool' : ''}`, text: str }),
|
||
);
|
||
}
|
||
sections.push(section('Attributes', attrKeys.length, content.length < 2, grid));
|
||
}
|
||
|
||
if (!content.length && !attrKeys.length) {
|
||
sections.push(el('div', { class: 'inspector-empty', text: 'This span carries no attributes or content.' }));
|
||
}
|
||
|
||
insp.replaceChildren(head, ...sections);
|
||
insp.scrollTop = 0;
|
||
}
|
||
|
||
function section(title, count, open, body) {
|
||
return el('details', { class: 'insp-section', open: open ? 'open' : null },
|
||
el('summary', {}, title, el('span', { class: 'insp-count', text: String(count) })),
|
||
el('div', { class: 'insp-body' }, body),
|
||
);
|
||
}
|
||
|
||
function renderMessage(m) {
|
||
const chips = el('div', { class: 'kv-inline' });
|
||
const meta = m.meta || {};
|
||
const chip = (label, val) => val
|
||
? chips.append(el('span', { class: 'chip' }, `${label} `, el('b', { text: String(val) })))
|
||
: null;
|
||
chip('model', meta.model);
|
||
chip('in', meta.in && fmtNum(Number(meta.in)));
|
||
chip('out', meta.out && fmtNum(Number(meta.out)));
|
||
chip('cached', meta.cacheRead && Number(meta.cacheRead) ? fmtNum(Number(meta.cacheRead)) : '');
|
||
chip('cost', meta.cost && fmtCost(Number(meta.cost)));
|
||
chip('took', meta.duration);
|
||
chip('bytes', meta.bytes);
|
||
|
||
const bodyParts = [];
|
||
if (chips.childNodes.length) bodyParts.push(chips);
|
||
if (m.input) {
|
||
bodyParts.push(el('pre', { class: 'payload', text: prettyJSON(m.input) }));
|
||
}
|
||
if (m.body) {
|
||
if (m.input) bodyParts.push(el('div', { style: 'height:8px' }));
|
||
bodyParts.push(el('pre', { class: 'payload', text: m.body }));
|
||
}
|
||
|
||
return el('div', { class: `msg ${m.kind}${m.failed ? ' failed' : ''}` },
|
||
el('div', { class: 'msg-head' },
|
||
el('span', { text: m.title }),
|
||
m.ts ? el('span', { class: 'msg-when', text: fmtClock(m.ts) }) : null,
|
||
el('button', {
|
||
class: 'copy-btn', text: 'copy',
|
||
onclick: e => {
|
||
e.stopPropagation();
|
||
navigator.clipboard.writeText(m.input ? `${m.input}\n\n${m.body}` : m.body).then(
|
||
() => toast('Copied'), () => toast('Copy failed'));
|
||
},
|
||
}),
|
||
),
|
||
el('div', { class: 'msg-body' }, bodyParts.length ? bodyParts : el('span', { class: 'attr-k', text: '(no payload)' })),
|
||
);
|
||
}
|
||
|
||
/* Tool inputs arrive as JSON strings. Pretty-printing them is the difference
|
||
* between a wall of escapes and something readable, but a non-JSON payload must
|
||
* pass through untouched rather than being mangled. */
|
||
function prettyJSON(s) {
|
||
try {
|
||
return JSON.stringify(JSON.parse(s), null, 2);
|
||
} catch {
|
||
return s;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- explore
|
||
|
||
const EXAMPLES = [
|
||
['Slowest tools', `SELECT tool_name, count(*), p95(duration), max(duration)
|
||
FROM spans
|
||
WHERE time >= {from} AND time <= {to} AND kind = tool_call
|
||
GROUP BY tool_name
|
||
ORDER BY p95(duration) DESC
|
||
LIMIT 20`],
|
||
['Tokens by model', `SELECT model, count(*), sum(input_tokens), sum(output_tokens)
|
||
FROM spans
|
||
WHERE time >= {from} AND time <= {to} AND kind = llm_call
|
||
GROUP BY model
|
||
ORDER BY sum(input_tokens) DESC`],
|
||
['Spans by kind', `SELECT kind, count(*), avg(duration)
|
||
FROM spans
|
||
WHERE time >= {from} AND time <= {to}
|
||
GROUP BY kind
|
||
ORDER BY count(*) DESC`],
|
||
['Failures', `SELECT name, tool_name, count(*)
|
||
FROM spans
|
||
WHERE time >= {from} AND time <= {to} AND status = error
|
||
GROUP BY name, tool_name
|
||
ORDER BY count(*) DESC
|
||
LIMIT 20`],
|
||
['Event volume', `SELECT event_name, count(*)
|
||
FROM logs
|
||
WHERE time >= {from} AND time <= {to}
|
||
GROUP BY event_name
|
||
ORDER BY count(*) DESC`],
|
||
];
|
||
|
||
function fillRange(sql) {
|
||
const to = nowMicros();
|
||
return sql.replace('{from}', String(to - state.rangeMicros)).replace('{to}', String(to));
|
||
}
|
||
|
||
function renderExamples() {
|
||
$('#examples').replaceChildren(...EXAMPLES.map(([label, sql]) =>
|
||
el('button', {
|
||
class: 'example', text: label,
|
||
onclick: () => {
|
||
$('#query-input').value = fillRange(sql);
|
||
state.autosize?.();
|
||
runQuery();
|
||
},
|
||
})));
|
||
}
|
||
|
||
async function runQuery() {
|
||
const sql = $('#query-input').value;
|
||
$('#query-error').hidden = true;
|
||
try {
|
||
const res = await api('/api/query', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query: sql }),
|
||
});
|
||
renderResults(res);
|
||
} catch (e) {
|
||
$('#query-error').textContent = e.message;
|
||
$('#query-error').hidden = false;
|
||
$('#result-head').replaceChildren();
|
||
$('#result-rows').replaceChildren();
|
||
$('#chart-wrap').hidden = true;
|
||
$('#query-scan').textContent = '';
|
||
}
|
||
}
|
||
|
||
function renderResults(res) {
|
||
const s = res.stats || {};
|
||
$('#query-scan').textContent =
|
||
`${res.rows.length} rows · scanned ${fmtNum(s.rows_scanned || 0)} across ${s.shards_scanned || 0} shards in ${fmtDur(s.elapsed_micros || 0)}`;
|
||
|
||
$('#result-head').replaceChildren(
|
||
el('tr', {}, res.columns.map((c, i) =>
|
||
el('th', { class: isNumericColumn(res, i) ? 'col-num' : '', text: c }))));
|
||
|
||
const tbody = $('#result-rows');
|
||
tbody.replaceChildren();
|
||
for (const row of res.rows) {
|
||
tbody.append(el('tr', {}, row.map((v, i) => {
|
||
const numeric = typeof v === 'number';
|
||
const isTrace = res.columns[i] === 'trace_id' && typeof v === 'string' && v;
|
||
return el('td', {
|
||
class: `${numeric ? 'is-num' : ''}${isTrace ? ' is-link' : ''}`,
|
||
text: formatCell(v, res.columns[i]),
|
||
onclick: isTrace ? () => navigate(`#/trace/${v}`) : null,
|
||
});
|
||
})));
|
||
}
|
||
|
||
drawChart(res);
|
||
}
|
||
|
||
function isNumericColumn(res, i) {
|
||
return res.rows.some(r => typeof r[i] === 'number');
|
||
}
|
||
|
||
function formatCell(v, col) {
|
||
if (v === null || v === undefined) return '—';
|
||
if (typeof v === 'object') return JSON.stringify(v);
|
||
if (typeof v === 'number') {
|
||
// Duration columns are micros; rendering them raw is unreadable.
|
||
if (/duration|_ts$|^ts$/.test(col)) return fmtDur(v);
|
||
if (Number.isInteger(v)) return v.toLocaleString();
|
||
return v.toFixed(3);
|
||
}
|
||
return String(v);
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------- chart
|
||
|
||
/* The chart answers one question: how do these categories compare on one
|
||
* measure. That makes it a horizontal bar chart with a single series, so every
|
||
* bar takes the same hue — colouring bars by their own value would double-encode
|
||
* length as lightness and spend the only free channel on information the bar
|
||
* already shows. Category labels sit outside the plot so they are never clipped
|
||
* by a short bar, and the results table below is the table-view twin, so no
|
||
* value is reachable only by hovering.
|
||
*/
|
||
|
||
const CHART_MAX_BARS = 15;
|
||
const BAR_H = 15;
|
||
const ROW_H = 26;
|
||
|
||
function drawChart(res) {
|
||
const wrap = $('#chart-wrap');
|
||
const svg = $('#chart');
|
||
svg.replaceChildren();
|
||
|
||
const plan = chartPlan(res);
|
||
if (!plan) { wrap.hidden = true; return; }
|
||
wrap.hidden = false;
|
||
|
||
const { labelIdx, valueIdx, rows } = plan;
|
||
const max = Math.max(...rows.map(r => Number(r[valueIdx]) || 0), 0);
|
||
if (max <= 0) { wrap.hidden = true; return; }
|
||
|
||
// Reserve space from the actual label widths rather than a guess, so a long
|
||
// model name neither overflows nor squeezes the plot to nothing.
|
||
const labelW = Math.min(240, Math.max(90,
|
||
...rows.map(r => String(formatCell(r[labelIdx], res.columns[labelIdx]) || '—').length * 6.6 + 12)));
|
||
const valueW = 76;
|
||
const padT = 6, padB = 20;
|
||
const width = svg.clientWidth || wrap.clientWidth || 800;
|
||
const plotW = Math.max(40, width - labelW - valueW);
|
||
const height = padT + rows.length * ROW_H + padB;
|
||
|
||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||
svg.style.height = `${height}px`;
|
||
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const mk = (tag, attrs) => {
|
||
const n = document.createElementNS(ns, tag);
|
||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||
return n;
|
||
};
|
||
|
||
// Recessive gridlines: solid hairlines one shade off the surface, never dashed.
|
||
const ticks = niceTicks(max, 4);
|
||
for (const t of ticks) {
|
||
const x = labelW + (t / max) * plotW;
|
||
svg.append(mk('line', {
|
||
x1: x, x2: x, y1: padT, y2: padT + rows.length * ROW_H,
|
||
stroke: 'var(--border)', 'stroke-width': 1, 'shape-rendering': 'crispEdges',
|
||
}));
|
||
const lab = mk('text', {
|
||
x, y: height - 6, 'text-anchor': t === 0 ? 'start' : 'middle',
|
||
fill: 'var(--text-faint)', 'font-size': 10, 'font-family': 'var(--mono)',
|
||
});
|
||
lab.textContent = compactNumber(t, res.columns[valueIdx]);
|
||
svg.append(lab);
|
||
}
|
||
|
||
rows.forEach((row, i) => {
|
||
const y = padT + i * ROW_H;
|
||
const value = Number(row[valueIdx]) || 0;
|
||
// The 2px inset is the surface gap that separates adjacent bars without
|
||
// drawing a border around them.
|
||
const barW = Math.max(1, (value / max) * plotW);
|
||
const label = formatCell(row[labelIdx], res.columns[labelIdx]) || '—';
|
||
|
||
const g = mk('g', { class: 'bar-g', tabindex: 0, role: 'listitem' });
|
||
|
||
// A full-row hit area, so the hover target is the row rather than a 15px bar.
|
||
g.append(mk('rect', {
|
||
x: 0, y, width, height: ROW_H, fill: 'transparent',
|
||
}));
|
||
|
||
const cat = mk('text', {
|
||
x: labelW - 10, y: y + ROW_H / 2 + 4, 'text-anchor': 'end',
|
||
fill: 'var(--text-dim)', 'font-size': 11.5,
|
||
});
|
||
cat.textContent = label.length > 34 ? label.slice(0, 33) + '\u2026' : label;
|
||
g.append(cat);
|
||
|
||
g.append(mk('rect', {
|
||
x: labelW, y: y + (ROW_H - BAR_H) / 2,
|
||
width: barW, height: BAR_H,
|
||
rx: 4, ry: 4,
|
||
fill: 'var(--accent)',
|
||
}));
|
||
|
||
const val = mk('text', {
|
||
x: labelW + barW + 8, y: y + ROW_H / 2 + 4,
|
||
fill: 'var(--text)', 'font-size': 11, 'font-family': 'var(--mono)',
|
||
});
|
||
val.textContent = formatCell(row[valueIdx], res.columns[valueIdx]);
|
||
g.append(val);
|
||
|
||
const title = mk('title', {});
|
||
title.textContent = `${label}\n${res.columns[valueIdx]}: ${formatCell(row[valueIdx], res.columns[valueIdx])}`;
|
||
g.append(title);
|
||
|
||
svg.append(g);
|
||
});
|
||
|
||
const notes = [];
|
||
if (plan.truncated) notes.push(`top ${CHART_MAX_BARS} of ${plan.total}`);
|
||
if (plan.dropped) notes.push(`${plan.dropped} unnamed row${plan.dropped === 1 ? '' : 's'} charted only in the table`);
|
||
if (notes.length) {
|
||
// Right-aligned: left-aligned it sat on top of the zero tick label.
|
||
const note = mk('text', {
|
||
x: width - 2, y: height - 6, fill: 'var(--text-faint)', 'font-size': 10,
|
||
'text-anchor': 'end',
|
||
});
|
||
note.textContent = notes.join(' · ');
|
||
svg.append(note);
|
||
}
|
||
}
|
||
|
||
/* chartPlan decides whether these results are chartable at all. A chart of a raw
|
||
* row listing, or of a result with no label column, is noise — the table already
|
||
* says it better. */
|
||
function chartPlan(res) {
|
||
if (!res.rows.length || res.columns.length < 2) return null;
|
||
|
||
let labelIdx = -1, valueIdx = -1;
|
||
for (let i = 0; i < res.columns.length; i++) {
|
||
const anyNum = res.rows.some(r => typeof r[i] === 'number');
|
||
if (!anyNum && labelIdx < 0) labelIdx = i;
|
||
if (anyNum && valueIdx < 0) valueIdx = i;
|
||
}
|
||
// The measure the query sorted by is the measure the query is about. Falling
|
||
// back to the first numeric column is what made a "slowest tools" query
|
||
// render a chart of call counts.
|
||
if (res.order_by) {
|
||
const i = res.columns.findIndex(c => c.toLowerCase() === res.order_by.toLowerCase());
|
||
if (i >= 0 && res.rows.some(r => typeof r[i] === 'number')) valueIdx = i;
|
||
}
|
||
if (labelIdx < 0 || valueIdx < 0) return null;
|
||
// More than a screenful of categories is a table, not a bar chart.
|
||
if (res.rows.length > 60) return null;
|
||
|
||
// A category with no name cannot be interpreted, and an unlabelled bucket is
|
||
// often the largest one — it crushes every named bar into the margin. The row
|
||
// stays in the table below, which is where an unnamed total belongs.
|
||
const named = res.rows.filter(r => String(r[labelIdx] ?? '').trim() !== '');
|
||
const dropped = res.rows.length - named.length;
|
||
if (!named.length) return null;
|
||
|
||
const sorted = [...named].sort((a, b) => (Number(b[valueIdx]) || 0) - (Number(a[valueIdx]) || 0));
|
||
return {
|
||
labelIdx, valueIdx, dropped,
|
||
rows: sorted.slice(0, CHART_MAX_BARS),
|
||
truncated: sorted.length > CHART_MAX_BARS,
|
||
total: sorted.length,
|
||
};
|
||
}
|
||
|
||
// niceTicks picks round axis values rather than dividing the max evenly, so the
|
||
// gridlines land on numbers a reader recognises.
|
||
function niceTicks(max, count) {
|
||
const raw = max / count;
|
||
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||
const norm = raw / mag;
|
||
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
|
||
const out = [];
|
||
for (let v = 0; v <= max * 1.0001; v += step) out.push(v);
|
||
return out;
|
||
}
|
||
|
||
function compactNumber(v, col) {
|
||
if (v === 0) return '0';
|
||
if (/duration|_ts$|^ts$/.test(col || '')) return fmtDur(v);
|
||
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
|
||
if (v >= 1000) return `${(v / 1000).toFixed(v < 10000 ? 1 : 0)}k`;
|
||
return Number.isInteger(v) ? String(v) : v.toFixed(1);
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------- live updates
|
||
|
||
/* The server pushes a one-line event whenever a flush makes new rows
|
||
* queryable; the client decides what, if anything, to refetch.
|
||
*
|
||
* Two rules keep this from being annoying. Events are coalesced, because a busy
|
||
* agent flushes every second and a refetch per flush is pointless churn. And a
|
||
* refresh never moves what the reader is looking at: the list patches rows in
|
||
* place and holds new arrivals behind a pill when scrolled, and the detail view
|
||
* restores the selected span and both scroll positions.
|
||
*/
|
||
|
||
let liveSource = null;
|
||
let liveTimer = null;
|
||
|
||
function setLiveState(next) {
|
||
if (state.live === next) return;
|
||
state.live = next;
|
||
const dot = $('#live-dot');
|
||
dot.className = `live-dot is-${next}`;
|
||
dot.title = {
|
||
live: 'Connected — traces appear as they arrive',
|
||
connecting: 'Connecting to the trace stream…',
|
||
offline: 'Disconnected from the trace stream; retrying',
|
||
}[next];
|
||
}
|
||
|
||
function connectLive() {
|
||
if (liveSource) liveSource.close();
|
||
setLiveState('connecting');
|
||
|
||
liveSource = new EventSource('/api/events');
|
||
|
||
liveSource.onopen = () => setLiveState('live');
|
||
|
||
liveSource.onerror = () => {
|
||
// EventSource reconnects on its own; this only reflects that in the UI.
|
||
setLiveState(liveSource.readyState === EventSource.CLOSED ? 'offline' : 'connecting');
|
||
};
|
||
|
||
liveSource.onmessage = (e) => {
|
||
let ev;
|
||
try {
|
||
ev = JSON.parse(e.data);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (ev.type === 'hello') {
|
||
setLiveState('live');
|
||
return;
|
||
}
|
||
if (ev.type !== 'flush') return;
|
||
scheduleRefresh(ev);
|
||
};
|
||
}
|
||
|
||
// scheduleRefresh coalesces a burst of flushes into one refetch.
|
||
let pendingTraceIDs = new Set();
|
||
function scheduleRefresh(ev) {
|
||
for (const id of ev.trace_ids || []) pendingTraceIDs.add(id);
|
||
if (liveTimer) return;
|
||
liveTimer = setTimeout(() => {
|
||
liveTimer = null;
|
||
const ids = pendingTraceIDs;
|
||
pendingTraceIDs = new Set();
|
||
applyRefresh(ids);
|
||
}, 400);
|
||
}
|
||
|
||
function applyRefresh(traceIDs) {
|
||
if (!$('#view-insights').hidden) {
|
||
loadInsights();
|
||
return;
|
||
}
|
||
// Keep the tab badge current even when the tab is closed, so a new finding is
|
||
// discoverable without going to look for one.
|
||
refreshInsightBadge();
|
||
|
||
if (!$('#view-traces').hidden) {
|
||
loadTraces();
|
||
return;
|
||
}
|
||
if (!$('#view-trace').hidden && state.trace) {
|
||
const open = state.trace.trace?.trace_id;
|
||
// Only refetch the trace actually on screen, and only when this flush
|
||
// touched it. Reloading an unrelated trace would be pure cost.
|
||
if (open && traceIDs.has(open)) refreshOpenTrace();
|
||
}
|
||
}
|
||
|
||
/* refreshOpenTrace re-renders a trace that is still being written without
|
||
* disturbing the reader: the selected span stays selected and both panes keep
|
||
* their scroll position. Watching a run unfold is the point; having the view
|
||
* jump every second would make it unusable. */
|
||
async function refreshOpenTrace() {
|
||
const id = state.trace?.trace?.trace_id;
|
||
if (!id) return;
|
||
|
||
const waterfall = $('#waterfall');
|
||
const inspector = $('#inspector');
|
||
const keepSelected = state.selected;
|
||
const keepWaterfall = waterfall.scrollTop;
|
||
const keepInspector = inspector.scrollTop;
|
||
|
||
try {
|
||
const data = await api(`/api/traces/${encodeURIComponent(id)}`);
|
||
if (state.trace?.trace?.trace_id !== id) return; // navigated away mid-flight
|
||
state.trace = data;
|
||
renderTrace(data, { restore: { keepSelected, keepWaterfall, keepInspector } });
|
||
} catch {
|
||
// A refresh that fails leaves the last good render on screen, which is
|
||
// better than blanking a trace someone is reading.
|
||
}
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------- insights
|
||
|
||
/* The insights tab reads what the background analyzer last produced. Opening it
|
||
* never triggers analysis, so it cannot make the server do work proportional to
|
||
* the store.
|
||
*
|
||
* Every finding leads with its cost and ends with the traces it came from. That
|
||
* ordering is deliberate: the number is the argument for caring, and the traces
|
||
* are what make the claim checkable rather than something to take on faith. */
|
||
|
||
/* normalizeResult fills in anything the server left out.
|
||
*
|
||
* The server is the same binary as this page, so in principle its shape is
|
||
* guaranteed — but a nil Go slice marshals to JSON null, and relying on every
|
||
* field being present is what put a raw JavaScript error on the first page a
|
||
* new install opens. One normalisation point is cheaper than a guard at every
|
||
* use, and it means a partial or older response degrades instead of crashing.
|
||
*/
|
||
function normalizeResult(res) {
|
||
const r = res || {};
|
||
return {
|
||
...r,
|
||
themes: Array.isArray(r.themes) ? r.themes : [],
|
||
window: r.window || { from: 0, to: 0 },
|
||
trace_count: r.trace_count || 0,
|
||
total_cost_usd: r.total_cost_usd || 0,
|
||
addressable_cost_usd: r.addressable_cost_usd || 0,
|
||
min_occurrences: r.min_occurrences || 3,
|
||
dismissed: r.dismissed || 0,
|
||
local: r.local || { available: false },
|
||
};
|
||
}
|
||
|
||
function countInsights(res) {
|
||
return res.themes.reduce((n, t) => n + (t.insights ? t.insights.length : 0), 0);
|
||
}
|
||
|
||
async function refreshInsightBadge() {
|
||
try {
|
||
const data = await api('/api/insights');
|
||
if (!data.ready || !data.result) return;
|
||
const total = countInsights(normalizeResult(data.result));
|
||
const badge = $('#insight-count');
|
||
badge.textContent = String(total);
|
||
badge.hidden = total === 0;
|
||
} catch {
|
||
// The badge is decoration; a failure here must not surface as an error.
|
||
}
|
||
}
|
||
|
||
async function loadInsights() {
|
||
const body = $('#insights-body');
|
||
try {
|
||
const to = nowMicros();
|
||
const from = to - state.rangeMicros;
|
||
const data = await api(`/api/insights?from=${from}&to=${to}`);
|
||
if (data.disabled) {
|
||
showInsightsState({
|
||
title: 'Insights are turned off',
|
||
lines: ['Set enabled = true under [insights] in fathom.ini to switch them on.'],
|
||
});
|
||
return;
|
||
}
|
||
if (!data.ready) {
|
||
// "Not looked yet" and "nothing found" are different claims, and an empty
|
||
// page would assert the second when the first is true.
|
||
showInsightsState({ title: 'Looking through recent traces…', quiet: true });
|
||
return;
|
||
}
|
||
renderInsights(data.result);
|
||
} catch (e) {
|
||
showInsightsState({
|
||
title: 'Could not load insights',
|
||
lines: [e.message],
|
||
});
|
||
}
|
||
}
|
||
|
||
/* showInsightsState renders the page when there is nothing to list.
|
||
*
|
||
* Empty is not one situation, and collapsing them into a single message throws
|
||
* away the most useful thing the page could say. A store with no traces needs
|
||
* to be told how to get some; a store with three traces is not yet able to show
|
||
* a pattern and should say so rather than imply everything is fine; a busy store
|
||
* with nothing flagged is genuinely good news and should read that way. */
|
||
function showInsightsState({ title, lines = [], hint, action, quiet = false }) {
|
||
$('#insights-summary').replaceChildren();
|
||
$('#local-status').hidden = true;
|
||
$('#restore-insights').hidden = true;
|
||
$('#insight-count').hidden = true;
|
||
|
||
$('#insights-body').replaceChildren(
|
||
el('div', { class: `insights-state${quiet ? ' is-quiet' : ''}` },
|
||
el('div', { class: 'state-mark' }),
|
||
el('h2', { class: 'state-title', text: title }),
|
||
...lines.map(t => el('p', { class: 'state-line', text: t })),
|
||
hint ? el('pre', { class: 'state-hint', text: hint }) : null,
|
||
action || null,
|
||
),
|
||
);
|
||
}
|
||
|
||
function renderInsights(raw) {
|
||
const res = normalizeResult(raw);
|
||
const themes = res.themes;
|
||
const total = countInsights(res);
|
||
|
||
if (!total) {
|
||
renderInsightsEmpty(res);
|
||
return;
|
||
}
|
||
|
||
const badge = $('#insight-count');
|
||
badge.textContent = String(total);
|
||
badge.hidden = false;
|
||
|
||
const label = windowLabel(res);
|
||
$('#insights-summary').replaceChildren(
|
||
document.createTextNode(`Last ${label}: `),
|
||
el('b', { text: String(res.trace_count) }),
|
||
document.createTextNode(` ${res.trace_count === 1 ? 'trace' : 'traces'}, `),
|
||
el('b', { text: fmtCost(res.total_cost_usd) }),
|
||
document.createTextNode(' total. These findings cover '),
|
||
el('b', { text: fmtCost(res.addressable_cost_usd) }),
|
||
document.createTextNode(' of it.'),
|
||
);
|
||
$('#restore-insights').hidden = !res.dismissed;
|
||
renderLocalStatus(res.local);
|
||
|
||
$('#insights-body').replaceChildren(...themes.map(themeSection));
|
||
}
|
||
|
||
// windowLabel describes the analysis window the way the range picker's own
|
||
// buttons do ("15 minutes", "24 hours") rather than always in days, since a
|
||
// request can now cover any range the picker offers.
|
||
function windowLabel(res) {
|
||
const w = res.window || {};
|
||
const micros = Math.max(0, (w.to || 0) - (w.from || 0));
|
||
const mins = micros / 60e6;
|
||
if (mins < 60) return `${Math.max(1, Math.round(mins))} minute${Math.round(mins) === 1 ? '' : 's'}`;
|
||
const hours = mins / 60;
|
||
if (hours < 48) return `${Math.round(hours)} hour${Math.round(hours) === 1 ? '' : 's'}`;
|
||
const days = Math.round(hours / 24);
|
||
return `${days} day${days === 1 ? '' : 's'}`;
|
||
}
|
||
|
||
// renderInsightsEmpty picks the message that is actually true.
|
||
function renderInsightsEmpty(res) {
|
||
const label = windowLabel(res);
|
||
const min = res.min_occurrences || 3;
|
||
|
||
// Everything was found and then hidden. Offering the restore is the only
|
||
// useful thing to say.
|
||
if (res.dismissed > 0) {
|
||
showInsightsState({
|
||
title: `${res.dismissed} finding${res.dismissed === 1 ? '' : 's'} hidden`,
|
||
lines: [`Nothing else stood out in the last ${label}.`],
|
||
action: el('button', {
|
||
class: 'primary', text: 'Show them again',
|
||
onclick: restoreInsights,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
|
||
// A brand new store. The useful thing is how to get traces into it.
|
||
if (res.trace_count === 0) {
|
||
showInsightsState({
|
||
title: 'No traces yet',
|
||
lines: [
|
||
'Insights come from traces, so there is nothing to look at until an agent sends some.',
|
||
'Point Claude Code at this server, or write a batch of sample traces to try the page out.',
|
||
],
|
||
hint: 'fathom gen -traces 40',
|
||
action: el('a', { class: 'primary', href: '#/traces', text: 'Go to traces' }),
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Some traces, but not enough for anything to have repeated yet. This is not
|
||
// the same as "nothing is wrong" and must not be dressed up as it.
|
||
if (res.trace_count < min) {
|
||
showInsightsState({
|
||
title: 'Not enough to go on yet',
|
||
lines: [
|
||
`${res.trace_count} ${res.trace_count === 1 ? 'trace' : 'traces'} in the last ${label}. ` +
|
||
`A pattern has to repeat at least ${min} times before it is worth reporting, so there is not yet enough here to tell.`,
|
||
'Keep working and check back — this fills in on its own.',
|
||
],
|
||
});
|
||
return;
|
||
}
|
||
|
||
// A real body of work with nothing flagged. That is a good result, and the
|
||
// page should say so rather than look broken.
|
||
showInsightsState({
|
||
title: 'Nothing worth flagging',
|
||
lines: [
|
||
`${res.trace_count} traces and ${fmtCost(res.total_cost_usd)} over the last ${label}, ` +
|
||
`with no repeated sequence, duplicated context, or recurring failure above the ${min}-time threshold.`,
|
||
'Findings appear here on their own when a habit starts to form.',
|
||
],
|
||
quiet: true,
|
||
});
|
||
}
|
||
|
||
/* renderLocalStatus says whether a model on this machine was used, and for
|
||
* what. "Available" and "used" are different claims — a model can be present
|
||
* and still have failed — so the last run's actual usage is reported rather
|
||
* than the configuration. */
|
||
function renderLocalStatus(local) {
|
||
const box = $('#local-status');
|
||
if (!local || !local.available) {
|
||
box.hidden = true;
|
||
return;
|
||
}
|
||
box.hidden = false;
|
||
|
||
const used = local.used_for || [];
|
||
const detail = !local.enabled ? 'off'
|
||
: used.length ? `used for ${used.join(' and ')}`
|
||
: 'available, not needed this run';
|
||
|
||
box.replaceChildren(
|
||
el('span', {}, 'Local model: ', el('code', { text: local.chat_model || local.embed_model || local.kind })),
|
||
el('span', { class: 'ev-when', text: detail, title: `${local.kind} at ${local.endpoint}` }),
|
||
el('label', { class: 'local-switch', title:
|
||
'Use the model running on this machine to group prompts by meaning and name the groups. ' +
|
||
'Findings and every number stay heuristic either way.' },
|
||
el('input', {
|
||
type: 'checkbox',
|
||
...(local.enabled ? { checked: 'checked' } : {}),
|
||
onchange: e => setLocalEnabled(e.target.checked),
|
||
}),
|
||
el('span', { text: 'use it' }),
|
||
),
|
||
);
|
||
}
|
||
|
||
async function setLocalEnabled(on) {
|
||
try {
|
||
const data = await api('/api/insights/local', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: on }),
|
||
});
|
||
if (data.result) renderInsights(data.result);
|
||
toast(on ? 'Using the local model' : 'Local model off');
|
||
} catch (e) {
|
||
toast(`Could not change that: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function themeSection(group) {
|
||
return el('section', { class: 'theme' },
|
||
el('div', { class: 'theme-title', text: group.title }),
|
||
el('p', { class: 'theme-sub', text: group.subtitle }),
|
||
...group.insights.map(insightCard),
|
||
);
|
||
}
|
||
|
||
function insightCard(ins) {
|
||
const bodyEl = el('div', { class: 'insight-body', hidden: true },
|
||
el('p', { text: ins.body }),
|
||
ins.suggestion ? el('div', { class: 'suggestion', text: ins.suggestion }) : null,
|
||
ins.steps && ins.steps.length > 1 ? stepsRow(ins.steps) : null,
|
||
ins.evidence && ins.evidence.length
|
||
? el('div', {},
|
||
// Evidence is capped, so say so rather than appear to contradict the
|
||
// occurrence count in the title.
|
||
el('div', { class: 'evidence-title', text: ins.occurrences > ins.evidence.length
|
||
? `${ins.evidence.length} of ${ins.occurrences} traces`
|
||
: `Seen in ${ins.evidence.length} ${ins.evidence.length === 1 ? 'trace' : 'traces'}` }),
|
||
el('div', { class: 'evidence' }, ins.evidence.map(evidenceRow)))
|
||
: null,
|
||
el('div', { class: 'insight-actions' },
|
||
el('button', {
|
||
class: 'dismiss', text: 'Not useful',
|
||
title: 'Hide this until the pattern recurs after today',
|
||
onclick: e => { e.stopPropagation(); dismissInsight(ins.id); },
|
||
})),
|
||
);
|
||
|
||
const title = el('div', { class: 'insight-title' }, ins.title);
|
||
if (ins.method) {
|
||
// Semantic grouping can find what lexical grouping cannot, and can also
|
||
// group things a reader would have kept apart. Marking which was used lets
|
||
// the finding be judged on the right terms.
|
||
title.append(el('span', {
|
||
class: `method-tag ${ins.method === 'semantic' ? 'is-semantic' : ''}`,
|
||
title: ins.method === 'semantic'
|
||
? 'Grouped by meaning, using the local model'
|
||
: 'Grouped by shared wording',
|
||
text: ins.method,
|
||
}));
|
||
}
|
||
|
||
const head = el('div', { class: 'insight-head', onclick: () => { bodyEl.hidden = !bodyEl.hidden; } },
|
||
title,
|
||
el('div', { class: 'insight-cost' },
|
||
fmtCost(ins.cost_usd),
|
||
el('span', { class: 'sub', text: `${ins.occurrences}×${ins.wall_micros ? ' · ' + fmtDur(ins.wall_micros) : ''}` }),
|
||
),
|
||
);
|
||
|
||
return el('div', { class: `insight sev-${ins.severity}` }, head, bodyEl);
|
||
}
|
||
|
||
function stepsRow(steps) {
|
||
const row = el('div', { class: 'steps' });
|
||
steps.forEach((st, i) => {
|
||
if (i) row.append(el('span', { class: 'step-arrow', text: '→' }));
|
||
row.append(el('span', { class: 'step', text: st }));
|
||
});
|
||
return row;
|
||
}
|
||
|
||
function evidenceRow(ev) {
|
||
return el('div', {
|
||
class: 'ev-row',
|
||
title: 'Open this trace',
|
||
onclick: () => navigate(`#/trace/${ev.trace_id}`),
|
||
},
|
||
el('span', { class: 'ev-when', text: fmtAgo(ev.when) }),
|
||
el('span', { class: 'ev-prompt', text: ev.prompt || ev.trace_id.slice(0, 16) }),
|
||
el('span', { class: 'ev-detail', text: ev.detail || '' }),
|
||
el('span', { class: 'ev-cost', text: fmtCost(ev.cost_usd) }),
|
||
);
|
||
}
|
||
|
||
async function dismissInsight(id) {
|
||
try {
|
||
const data = await api('/api/insights/dismiss', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
if (data.result) renderInsights(data.result);
|
||
toast('Hidden until it recurs');
|
||
} catch (e) {
|
||
toast(`Could not dismiss: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function restoreInsights() {
|
||
try {
|
||
const data = await api('/api/insights/restore', { method: 'POST' });
|
||
if (data.result) renderInsights(data.result);
|
||
toast('Dismissed findings restored');
|
||
} catch (e) {
|
||
toast(`Could not restore: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- routing
|
||
|
||
function navigate(hash) {
|
||
if (location.hash === hash) route();
|
||
else location.hash = hash;
|
||
}
|
||
|
||
function route() {
|
||
const hash = location.hash || '#/traces';
|
||
const parts = hash.slice(2).split('/');
|
||
|
||
const show = id => {
|
||
for (const v of $$('.view')) v.hidden = (v.id !== id);
|
||
};
|
||
const tab = parts[0] === 'explore' ? 'explore'
|
||
: parts[0] === 'insights' ? 'insights'
|
||
: 'traces';
|
||
for (const a of $$('.tabs a')) {
|
||
a.classList.toggle('is-active', a.dataset.tab === tab);
|
||
}
|
||
|
||
if (parts[0] === 'trace' && parts[1]) {
|
||
show('view-trace');
|
||
const wanted = parts[2];
|
||
if (state.trace?.trace?.trace_id === parts[1]) {
|
||
if (wanted) selectSpan(wanted);
|
||
} else {
|
||
loadTrace(parts[1]).then(() => { if (wanted) selectSpan(wanted); });
|
||
}
|
||
} else if (parts[0] === 'insights') {
|
||
show('view-insights');
|
||
loadInsights();
|
||
} else if (parts[0] === 'explore') {
|
||
show('view-explore');
|
||
if (!$('#query-input').value.trim()) {
|
||
$('#query-input').value = fillRange(EXAMPLES[0][1]);
|
||
}
|
||
state.autosize?.();
|
||
} else {
|
||
show('view-traces');
|
||
loadTraces();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- wiring
|
||
|
||
function initTheme() {
|
||
const saved = localStorage.getItem('fathom-theme');
|
||
if (saved) document.documentElement.dataset.theme = saved;
|
||
else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||
document.documentElement.dataset.theme = 'light';
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
initTheme();
|
||
renderExamples();
|
||
|
||
$('#theme-toggle').addEventListener('click', () => {
|
||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.dataset.theme = next;
|
||
localStorage.setItem('fathom-theme', next);
|
||
});
|
||
|
||
$('#range-picker').addEventListener('click', e => {
|
||
const btn = e.target.closest('.range-btn');
|
||
if (!btn) return;
|
||
state.rangeMicros = Number(btn.dataset.range);
|
||
for (const b of $$('.range-btn')) b.classList.toggle('is-active', b === btn);
|
||
if (!location.hash.startsWith('#/trace/')) route();
|
||
});
|
||
|
||
let searchTimer;
|
||
$('#search').addEventListener('input', e => {
|
||
state.search = e.target.value.trim();
|
||
state.listLoaded = false;
|
||
state.pending = 0;
|
||
hideNewPill();
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(loadTraces, 180);
|
||
});
|
||
|
||
$('#status-filter').addEventListener('click', e => {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
// Changing a filter swaps the whole visible set; those rows are not
|
||
// arrivals and must not be announced as such.
|
||
state.listLoaded = false;
|
||
state.pending = 0;
|
||
hideNewPill();
|
||
state.status = btn.dataset.status;
|
||
for (const b of $('#status-filter').children) b.classList.toggle('is-active', b === btn);
|
||
loadTraces();
|
||
});
|
||
|
||
$('#back-btn').addEventListener('click', () => navigate('#/traces'));
|
||
|
||
const rerenderTree = () => {
|
||
if (state.trace) renderWaterfall(state.trace.trace, state.trace.flat);
|
||
if (state.selected) selectSpan(state.selected);
|
||
};
|
||
$('#only-content').addEventListener('change', e => {
|
||
state.onlyContent = e.target.checked;
|
||
rerenderTree();
|
||
});
|
||
$('#merge-wrappers').addEventListener('change', e => {
|
||
state.merge = e.target.checked;
|
||
rerenderTree();
|
||
});
|
||
|
||
const autosize = () => {
|
||
const ta = $('#query-input');
|
||
ta.style.height = 'auto';
|
||
ta.style.height = `${Math.min(300, ta.scrollHeight + 2)}px`;
|
||
};
|
||
$('#query-input').addEventListener('input', autosize);
|
||
state.autosize = autosize;
|
||
|
||
$('#restore-insights').addEventListener('click', restoreInsights);
|
||
refreshInsightBadge();
|
||
|
||
$('#run-query').addEventListener('click', runQuery);
|
||
$('#query-input').addEventListener('keydown', e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); }
|
||
});
|
||
|
||
// Keyboard navigation through the waterfall. An engineer reading a trace
|
||
// moves span to span constantly; reaching for the mouse each time is the
|
||
// difference between skimming a run and slogging through it.
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.matches('input, textarea')) return;
|
||
if (!state.trace || $('#view-trace').hidden) return;
|
||
|
||
const rows = $$('.span-row');
|
||
const idx = rows.findIndex(r => r.dataset.span === state.selected);
|
||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (idx < rows.length - 1) selectSpan(rows[idx + 1].dataset.span);
|
||
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (idx > 0) selectSpan(rows[idx - 1].dataset.span);
|
||
} else if (e.key === 'Escape') {
|
||
navigate('#/traces');
|
||
}
|
||
});
|
||
|
||
window.addEventListener('hashchange', route);
|
||
route();
|
||
|
||
connectLive();
|
||
|
||
// Relative timestamps ("2m ago") go stale on their own, with no event to
|
||
// prompt a redraw. This only rewrites those cells; it does not refetch.
|
||
setInterval(() => {
|
||
if ($('#view-traces').hidden) return;
|
||
for (const [id, row] of state.rows) {
|
||
const t = state.traces.find(x => x.trace_id === id);
|
||
if (t) setText(row.children[0].firstChild, fmtAgo(t.start_ts));
|
||
}
|
||
}, 15000);
|
||
|
||
$('#new-pill').addEventListener('click', () => {
|
||
$('#view-traces .table-wrap').scrollTo({ top: 0, behavior: 'smooth' });
|
||
state.pending = 0;
|
||
hideNewPill();
|
||
});
|
||
|
||
// Scrolling back to the top clears the backlog notice on its own.
|
||
$('#view-traces .table-wrap').addEventListener('scroll', () => {
|
||
if ($('#view-traces .table-wrap').scrollTop < 8 && state.pending) {
|
||
state.pending = 0;
|
||
hideNewPill();
|
||
}
|
||
}, { passive: true });
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|