Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js
T
RichardandClaude Opus 5 5adf4c1ca6 Prompt History und Verbrauchsauswertung (#660), englische Hauptsprache (#661)
Prompt history: every run records its transcript (task, each model step with
tokens and cost, tool calls and results, engine follow-ups) - bounded in size,
scrubbed of recognisable secrets, system prompts stored once per text.
Retention in days per instance (default 14) and per agent (0 = never, -1 =
forever); an hourly janitor deletes what is past it, "never" also purges.

Usage analysis over run receipts: filter by period, agent, model, event, status,
cost and free text; sort by every column; totals over the whole filter; hourly
or daily series grouped by agent, model or event; markers for budget denials and
model changes; run detail with receipt, transcript, tool calls and approvals.

WebUI: Analysis > Token usage > Runs / Timeline, run detail, Prompt History with
retention settings. New code, comments and texts in English; texts come from
language packs (i18n/en.json, de.json) via modules/i18n.js. CLAUDE.md records
English as the main language.

Version 0.1.12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:33:09 +02:00

235 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}