// ClawdDotNet — Token usage › Timeline (#660) // // Stacked bars per hour or day, grouped by agent, model or event, measuring cost, tokens or // runs. Budget denials and model changes are drawn as markers on the axis - the usual // explanation for a jump. Clicking a bar opens the runs of exactly that bucket and group. // // Inline SVG built with the DOM API: the manager runs offline, no chart library. import { api } from "../modules/api.js"; import { erstelleElement, erstelleKarte, formatiereTokens, formatiereKosten } from "../modules/dom.js"; import { t } from "../modules/i18n.js"; import { readFilters, applyFilters, toApiQuery, loadFacets, buildFilterBar, rangeOf } from "../modules/usage-filters.js"; import { sourceLabel } from "./usage-runs.js"; const PATH = "/verbrauch/timeline"; const SVG = "http://www.w3.org/2000/svg"; // Distinguishable in light and dark theme; repeats after ten groups. const PALETTE = ["#4e79a7", "#f28e2b", "#59a14f", "#e15759", "#76b7b2", "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac"]; function svg(tag, attributes = {}, ...children) { const element = document.createElementNS(SVG, tag); for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value)); for (const child of children) element.append(child); return element; } function title(text) { const element = document.createElementNS(SVG, "title"); element.textContent = text; return element; } export async function renderUsageTimeline() { const container = document.getElementById("ansicht-inhalt"); if (!container) return; const filters = readFilters(); const bucket = filters.bucket ?? (["24h", "7d"].includes(filters.range) ? "hour" : "day"); const groupBy = filters.groupBy ?? "agent"; const metric = filters.metric ?? "cost"; const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" }); layout.append(erstelleElement("h1", { text: t("timeline.title") })); const facets = await loadFacets(filters); layout.append(buildFilterBar(filters, facets, (next) => applyFilters(PATH, { ...next, bucket, groupBy, metric }))); layout.append(buildControls(filters, bucket, groupBy, metric)); const content = erstelleElement("div", {}, erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") })); layout.append(content); container.replaceChildren(layout); let points; let events; try { const tzOffset = -new Date().getTimezoneOffset(); [points, events] = await Promise.all([ api(`/usage/series?${toApiQuery(filters, { bucket, groupBy, tzOffset })}`), api(`/usage/events?${toApiQuery(filters)}`) ]); } catch (err) { content.replaceChildren(erstelleKarte(t("runs.unavailable"), [erstelleElement("p", { klasse: "gedaempft", text: err.message })])); return; } if (points.length === 0) { content.replaceChildren(erstelleKarte(t("runs.empty.title"), [erstelleElement("p", { klasse: "gedaempft", text: t("runs.empty.text") })])); return; } content.replaceChildren(buildChart(points, events, filters, bucket, groupBy, metric)); } function buildControls(filters, bucket, groupBy, metric) { const bar = erstelleElement("div", { klasse: "reiter-leiste diagramm-steuerung" }); const group = (name, options, current) => { const wrapper = erstelleElement("div", { klasse: "gruppe-horizontal" }, erstelleElement("span", { klasse: "gedaempft klein", text: t(`timeline.${name}`) })); for (const option of options) { wrapper.append(erstelleElement("button", { type: "button", klasse: `reiter-schaltflaeche${option === current ? " aktiv" : ""}`, text: t(`timeline.${name}.${option}`), onclick: () => applyFilters(PATH, { ...readFilters(), bucket, groupBy, metric, [name]: option }) })); } return wrapper; }; bar.append( group("metric", ["cost", "tokens", "runs"], metric), group("groupBy", ["agent", "model", "source"], groupBy), group("bucket", ["hour", "day"], bucket)); return bar; } function bucketsBetween(from, to, bucket) { const step = bucket === "day" ? 864e5 : 36e5; const start = new Date(from); if (bucket === "day") start.setHours(0, 0, 0, 0); else start.setMinutes(0, 0, 0); const list = []; for (let time = start.getTime(); time < new Date(to).getTime() && list.length < 2000; time += step) { list.push(time); } return list; } function formatBucket(time, bucket) { const date = new Date(time); return bucket === "day" ? date.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit" }) : date.toLocaleString(undefined, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); } function formatValue(value, metric) { return metric === "cost" ? formatiereKosten(value) : metric === "tokens" ? formatiereTokens(value) : String(value); } function buildChart(points, events, filters, bucket, groupBy, metric) { const valueOf = (p) => (metric === "cost" ? Number(p.costUsd) : metric === "tokens" ? p.tokens : p.runs); const { from, to } = rangeOf(filters); // The API only returns buckets that have runs; the axis shows the whole range. const firstPoint = Math.min(...points.map((p) => new Date(p.bucketStart).getTime())); const buckets = bucketsBetween(Math.min(new Date(from).getTime(), firstPoint), to, bucket); const step = bucket === "day" ? 864e5 : 36e5; const indexOf = (time) => Math.floor((time - buckets[0]) / step); const groups = [...new Set(points.map((p) => p.group))].sort(); const color = (group) => PALETTE[groups.indexOf(group) % PALETTE.length]; const stacks = buckets.map(() => []); for (const p of points) { const i = indexOf(new Date(p.bucketStart).getTime()); if (i >= 0 && i < stacks.length) stacks[i].push(p); } const max = Math.max(...stacks.map((s) => s.reduce((sum, p) => sum + valueOf(p), 0)), 0) || 1; const barWidth = Math.max(4, Math.min(28, Math.floor(1100 / buckets.length))); const left = 70; const top = 16; const height = 260; const width = left + buckets.length * barWidth + 20; const chart = svg("svg", { class: "zeitverlauf", viewBox: `0 0 ${width} ${height + top + 50}`, width, height: height + top + 50, role: "img", "aria-label": t("timeline.title") }); // Axis and grid: four lines with the value. for (let i = 0; i <= 4; i++) { const y = top + height - (height * i) / 4; chart.append(svg("line", { x1: left, x2: width - 10, y1: y, y2: y, class: "raster" })); const label = svg("text", { x: left - 6, y: y + 4, "text-anchor": "end", class: "achse" }); label.textContent = formatValue((max * i) / 4, metric); chart.append(label); } const labelEvery = Math.ceil(buckets.length / 12); stacks.forEach((stack, i) => { const x = left + i * barWidth; let y = top + height; for (const p of stack) { const h = (valueOf(p) / max) * height; if (h <= 0) continue; y -= h; const rect = svg("rect", { x: x + 1, y, width: barWidth - 2, height: h, fill: color(p.group), class: "balken", tabindex: 0 }, title(`${formatBucket(buckets[i], bucket)} · ${groupBy === "source" ? sourceLabel(p.group) : p.group}: ` + `${formatValue(valueOf(p), metric)} (${t("timeline.tooltipRuns", { n: p.runs })})`)); rect.addEventListener("click", () => openBucket(filters, buckets[i], step, groupBy, p.group)); chart.append(rect); } if (i % labelEvery === 0) { const label = svg("text", { x: x + barWidth / 2, y: top + height + 16, "text-anchor": "middle", class: "achse" }); label.textContent = formatBucket(buckets[i], bucket); chart.append(label); } }); for (const event of events) { const time = new Date(event.at).getTime(); const i = (time - buckets[0]) / step; if (i < 0 || i > buckets.length) continue; const x = left + i * barWidth; const kind = event.kind === "budget_exceeded" ? "ereignis-budget" : "ereignis-modell"; chart.append(svg("g", { class: `ereignis ${kind}` }, svg("line", { x1: x, x2: x, y1: top, y2: top + height }), svg("path", { d: `M${x - 5},${top + height + 22} L${x + 5},${top + height + 22} L${x},${top + height + 30} Z` }), title(`${new Date(event.at).toLocaleString()} · ${event.agentId}: ${t(`timeline.event.${event.kind}`)} – ${event.text}`))); } const legend = erstelleElement("div", { klasse: "legende" }); for (const group of groups) { const total = points.filter((p) => p.group === group).reduce((sum, p) => sum + valueOf(p), 0); legend.append(erstelleElement("span", { klasse: "legende-eintrag" }, erstelleElement("span", { klasse: "legende-farbe", stil: { background: color(group) } }), erstelleElement("span", { text: `${groupBy === "source" ? sourceLabel(group) : group}: ${formatValue(total, metric)}` }))); } if (events.length > 0) { legend.append( erstelleElement("span", { klasse: "legende-eintrag" }, erstelleElement("span", { klasse: "legende-marke ereignis-budget" }), erstelleElement("span", { text: t("timeline.event.budget_exceeded") })), erstelleElement("span", { klasse: "legende-eintrag" }, erstelleElement("span", { klasse: "legende-marke ereignis-modell" }), erstelleElement("span", { text: t("timeline.event.model_changed") }))); } return erstelleKarte(t("timeline.chart"), [ erstelleElement("div", { klasse: "diagramm-scroll" }, chart), legend, erstelleElement("p", { klasse: "hinweis", text: t("timeline.hint") }) ]); } function openBucket(filters, start, step, groupBy, group) { const day = (time) => { const d = new Date(time); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }; const next = { ...filters, range: "custom", from: day(start), to: day(start + step - 1), offset: 0 }; delete next.bucket; delete next.groupBy; delete next.metric; next[groupBy === "source" ? "source" : groupBy] = group; applyFilters("/verbrauch/runs", next); }