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>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// ClawdDotNet — Analysis › Prompt History (#660)
|
||||
//
|
||||
// Recorded prompts and responses of every run, searchable over the full transcript, plus
|
||||
// how long they are kept: an instance default (14 days unless changed) and an override per
|
||||
// agent - "never" for an agent that handles secrets, "forever" or many days to debug one.
|
||||
//
|
||||
// Transcripts are foreign text and only ever inserted via textContent.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { erstelleElement, erstelleKarte, formatiereDatumZeit, formatiereTokens, zeigeMeldung } from "../modules/dom.js";
|
||||
import { navigiere } from "../modules/router.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
import { readFilters, applyFilters, rangeOf, loadFacets, buildFilterBar } from "../modules/usage-filters.js";
|
||||
import { statusBadge, sourceLabel } from "./usage-runs.js";
|
||||
|
||||
const PATH = "/prompt-history";
|
||||
const PAGE_SIZE = 25;
|
||||
const NEVER = 0;
|
||||
const FOREVER = -1;
|
||||
|
||||
export async function renderPromptHistory() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
|
||||
const filters = readFilters();
|
||||
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
|
||||
layout.append(
|
||||
erstelleElement("h1", { text: t("history.title") }),
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("history.intro") }));
|
||||
|
||||
const facets = await loadFacets(filters);
|
||||
layout.append(buildFilterBar(filters, facets, (next) => applyFilters(PATH, next), ["range", "agent", "q"]));
|
||||
|
||||
const list = erstelleElement("div", {}, erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
const settings = erstelleElement("div", {});
|
||||
layout.append(list, settings);
|
||||
container.replaceChildren(layout);
|
||||
|
||||
await Promise.all([loadList(list, filters), loadSettings(settings)]);
|
||||
}
|
||||
|
||||
async function loadList(target, filters) {
|
||||
const { from, to } = rangeOf(filters);
|
||||
const query = new URLSearchParams({ from, to, offset: String(filters.offset || 0), limit: String(PAGE_SIZE) });
|
||||
if (filters.agent) query.set("agent", filters.agent);
|
||||
if (filters.q) query.set("q", filters.q);
|
||||
|
||||
let page;
|
||||
try {
|
||||
page = await api(`/prompt-history?${query}`);
|
||||
} catch (err) {
|
||||
target.replaceChildren(erstelleKarte(t("history.unavailable"), [erstelleElement("p", { klasse: "gedaempft", text: err.message })]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (page.items.length === 0) {
|
||||
target.replaceChildren(erstelleKarte(t("history.empty.title"), [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: filters.q ? t("history.empty.search") : t("history.empty.text") })
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = page.items.map((item) => {
|
||||
const open = () => navigiere(`#${PATH}/${encodeURIComponent(item.runId)}`);
|
||||
const card = erstelleElement("div", { klasse: "verlauf-eintrag klickbar", tabindex: "0", onclick: open });
|
||||
card.addEventListener("keydown", (event) => { if (event.key === "Enter") open(); });
|
||||
|
||||
card.append(
|
||||
erstelleElement("div", { klasse: "verlauf-kopf" },
|
||||
erstelleElement("strong", { text: item.agentId }),
|
||||
erstelleElement("span", { klasse: "gedaempft klein", text: `${formatiereDatumZeit(item.startedAt)} · ${sourceLabel(item.source)} · ${item.model}` }),
|
||||
statusBadge(item.status)),
|
||||
erstelleElement("div", { klasse: "verlauf-zeile" },
|
||||
erstelleElement("span", { klasse: "verlauf-marke", text: t("history.prompt") }),
|
||||
erstelleElement("span", { klasse: "verlauf-text", text: item.prompt || "—" })),
|
||||
erstelleElement("div", { klasse: "verlauf-zeile" },
|
||||
erstelleElement("span", { klasse: "verlauf-marke", text: t("history.response") }),
|
||||
erstelleElement("span", { klasse: "verlauf-text", text: item.response || "—" })));
|
||||
|
||||
if (item.truncated) {
|
||||
card.append(erstelleElement("div", { klasse: "gedaempft klein", text: t("history.truncated", { chars: formatiereTokens(item.sizeChars) }) }));
|
||||
}
|
||||
return card;
|
||||
});
|
||||
|
||||
const offset = filters.offset || 0;
|
||||
const pager = erstelleElement("div", { klasse: "blaettern" },
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach klein", text: t("common.previous"), disabled: offset <= 0,
|
||||
onclick: () => applyFilters(PATH, { ...filters, offset: Math.max(0, offset - PAGE_SIZE) })
|
||||
}),
|
||||
erstelleElement("span", { klasse: "gedaempft", text: t("runs.page", { from: offset + 1, to: offset + page.items.length, total: page.total }) }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach klein", text: t("common.next"), disabled: offset + PAGE_SIZE >= page.total,
|
||||
onclick: () => applyFilters(PATH, { ...filters, offset: offset + PAGE_SIZE })
|
||||
}));
|
||||
|
||||
target.replaceChildren(erstelleKarte(t("history.list", { n: page.total }), [...items, pager]));
|
||||
}
|
||||
|
||||
// ─── Retention settings ───
|
||||
|
||||
function modeOf(days) {
|
||||
if (days === null || days === undefined) return "inherit";
|
||||
if (days === NEVER) return "never";
|
||||
if (days === FOREVER) return "forever";
|
||||
return "days";
|
||||
}
|
||||
|
||||
/** A mode select plus a day count that is only shown for "days". */
|
||||
function retentionInput(days, allowInherit) {
|
||||
const modes = allowInherit ? ["inherit", "never", "days", "forever"] : ["never", "days", "forever"];
|
||||
const mode = erstelleElement("select", { "aria-label": t("history.settings.mode") });
|
||||
for (const m of modes) mode.append(erstelleElement("option", { value: m, text: t(`history.settings.mode.${m}`) }));
|
||||
mode.value = modeOf(days);
|
||||
|
||||
const count = erstelleElement("input", {
|
||||
type: "number", min: "1", step: "1", value: days > 0 ? String(days) : "14", "aria-label": t("history.settings.days")
|
||||
});
|
||||
const unit = erstelleElement("span", { klasse: "gedaempft klein", text: t("history.settings.daysUnit") });
|
||||
const dayBox = erstelleElement("span", { klasse: "gruppe-horizontal" }, count, unit);
|
||||
const sync = () => { dayBox.hidden = mode.value !== "days"; };
|
||||
mode.addEventListener("change", sync);
|
||||
sync();
|
||||
|
||||
return {
|
||||
element: erstelleElement("span", { klasse: "gruppe-horizontal aufbewahrung" }, mode, dayBox),
|
||||
value() {
|
||||
switch (mode.value) {
|
||||
case "inherit": return null;
|
||||
case "never": return NEVER;
|
||||
case "forever": return FOREVER;
|
||||
default: return Math.max(1, Math.round(Number(count.value) || 14));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function describe(days) {
|
||||
if (days === NEVER) return t("history.settings.effective.never");
|
||||
if (days === FOREVER) return t("history.settings.effective.forever");
|
||||
return t("history.settings.effective.days", { n: days });
|
||||
}
|
||||
|
||||
async function loadSettings(target) {
|
||||
let settings;
|
||||
try {
|
||||
settings = await api("/prompt-history/settings");
|
||||
} catch (err) {
|
||||
target.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = retentionInput(settings.defaultRetentionDays, false);
|
||||
const rows = [];
|
||||
|
||||
const table = erstelleElement("table", { klasse: "daten-tabelle" });
|
||||
const head = erstelleElement("tr", {});
|
||||
for (const label of ["history.settings.agent", "history.settings.retention", "history.settings.effective"]) {
|
||||
head.append(erstelleElement("th", { text: t(label) }));
|
||||
}
|
||||
table.append(head);
|
||||
|
||||
for (const agent of settings.agents) {
|
||||
const input = retentionInput(agent.retentionDays, true);
|
||||
rows.push({ agentId: agent.agentId, input, before: agent.retentionDays ?? null });
|
||||
table.append(erstelleElement("tr", {},
|
||||
erstelleElement("td", { text: agent.displayName }),
|
||||
erstelleElement("td", {}, input.element),
|
||||
erstelleElement("td", { klasse: "gedaempft", text: describe(agent.effectiveRetentionDays) })));
|
||||
}
|
||||
|
||||
const save = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche primaer klein",
|
||||
text: t("history.settings.save"),
|
||||
onclick: async () => {
|
||||
const body = { defaultRetentionDays: instance.value(), agents: [] };
|
||||
for (const row of rows) {
|
||||
const value = row.input.value();
|
||||
if (value !== row.before) body.agents.push({ agentId: row.agentId, retentionDays: value });
|
||||
}
|
||||
save.disabled = true;
|
||||
try {
|
||||
const result = await api("/prompt-history/settings", { method: "PUT", body });
|
||||
zeigeMeldung(result?.message ?? t("history.settings.saved"), "erfolg");
|
||||
await loadSettings(target);
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("history.settings.failed", { error: err.message }), "gefahr");
|
||||
save.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
target.replaceChildren(erstelleKarte(t("history.settings.title"), [
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("history.settings.intro") }),
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleElement("strong", { text: t("history.settings.default") }), instance.element),
|
||||
erstelleElement("div", { klasse: "tabelle-scroll" }, table),
|
||||
save
|
||||
]));
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// ClawdDotNet — run detail: prompt, response and every step in between (#660)
|
||||
//
|
||||
// Reached from Token usage › Runs and from Prompt History. Shows the receipt, the recorded
|
||||
// transcript (task, each model step with its tokens and cost, each tool call and result),
|
||||
// the system prompt, and what the run proposed for approval.
|
||||
//
|
||||
// Everything in a transcript is foreign text - model output, web pages, database rows.
|
||||
// It is only ever inserted via textContent.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
erstellePlakette,
|
||||
formatiereDatumZeit,
|
||||
formatiereTokens,
|
||||
formatiereKosten
|
||||
} from "../modules/dom.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
import { statusBadge, sourceLabel, formatDuration } from "./usage-runs.js";
|
||||
|
||||
export async function renderRunDetail(runId, backPath) {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
|
||||
const back = erstelleElement("a", { href: `#${backPath}`, klasse: "zurueck-link", text: t("detail.back") });
|
||||
// Back to the filtered list if we came from one.
|
||||
back.addEventListener("click", (event) => {
|
||||
if (window.history.length > 1) {
|
||||
event.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht lauf-detail" }, back,
|
||||
erstelleElement("h1", { text: t("detail.title") }),
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
container.replaceChildren(layout);
|
||||
|
||||
let run;
|
||||
try {
|
||||
run = await api(`/runs/${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
layout.lastChild.replaceWith(erstelleKarte(t("detail.notFound"), [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: err.message })
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [buildReceipt(run)];
|
||||
parts.push(run.transcript ? buildTranscript(run) : buildTranscriptState(run));
|
||||
if (!run.transcript && run.toolCalls.length > 0) parts.push(buildToolCalls(run.toolCalls));
|
||||
if (run.approvals.length > 0) parts.push(buildApprovals(run.approvals));
|
||||
|
||||
layout.lastChild.replaceWith(...parts);
|
||||
}
|
||||
|
||||
function buildReceipt(run) {
|
||||
const r = run.receipt;
|
||||
const list = erstelleElement("dl", { klasse: "datenliste" });
|
||||
const add = (label, value) => list.append(
|
||||
erstelleElement("dt", { text: t(label) }),
|
||||
value instanceof Node ? erstelleElement("dd", {}, value) : erstelleElement("dd", { text: value ?? "—" }));
|
||||
|
||||
add("detail.runId", run.runId);
|
||||
if (r) {
|
||||
add("runs.col.time", formatiereDatumZeit(r.occurredAt));
|
||||
add("runs.col.agent", r.agentId);
|
||||
add("runs.col.model", r.model);
|
||||
add("runs.col.event", r.taskTitle ? `${sourceLabel(r.source)} · ${r.taskTitle}` : sourceLabel(r.source));
|
||||
add("runs.col.status", statusBadge(r.status));
|
||||
add("runs.col.steps", String(r.stepCount));
|
||||
add("detail.tokens", t("detail.tokensValue", {
|
||||
prompt: formatiereTokens(r.promptTokens),
|
||||
completion: formatiereTokens(r.completionTokens),
|
||||
cached: formatiereTokens(r.cachedTokens)
|
||||
}));
|
||||
add("runs.col.cost", r.costIsKnown || r.costUsd > 0 ? formatiereKosten(r.costUsd) : "—");
|
||||
add("runs.col.duration", formatDuration(r.durationMs));
|
||||
if (r.result) add("detail.result", r.result);
|
||||
}
|
||||
return erstelleKarte(t("detail.receipt"), [list]);
|
||||
}
|
||||
|
||||
function buildTranscriptState(run) {
|
||||
const text = {
|
||||
disabled: t("detail.transcript.disabled"),
|
||||
unavailable: t("detail.transcript.unavailable"),
|
||||
missing: t("detail.transcript.missing")
|
||||
}[run.transcriptState] ?? t("detail.transcript.missing");
|
||||
return erstelleKarte(t("detail.transcript"), [erstelleElement("p", { klasse: "gedaempft", text })]);
|
||||
}
|
||||
|
||||
function pre(text) {
|
||||
return erstelleElement("pre", { klasse: "code-block transkript-text", text: text ?? "" });
|
||||
}
|
||||
|
||||
function prettyJson(text) {
|
||||
if (!text) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTranscript(run) {
|
||||
const transcript = run.transcript;
|
||||
const items = [];
|
||||
|
||||
if (run.systemPrompt) {
|
||||
const details = erstelleElement("details", { klasse: "transkript-system" },
|
||||
erstelleElement("summary", { text: t("detail.systemPrompt", { n: run.systemPrompt.length }) }),
|
||||
pre(run.systemPrompt));
|
||||
items.push(details);
|
||||
}
|
||||
|
||||
if (transcript.truncated) {
|
||||
items.push(erstelleElement("div", { klasse: "hinweis-box warnung", text: t("detail.transcript.truncated") }));
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
for (const entry of transcript.entries) {
|
||||
const kind = entry.kind;
|
||||
const block = erstelleElement("div", { klasse: `transkript-eintrag art-${kind}` });
|
||||
const head = erstelleElement("div", { klasse: "transkript-kopf" });
|
||||
|
||||
if (kind === "assistant") {
|
||||
step++;
|
||||
head.append(erstelleElement("strong", { text: t("detail.entry.assistant", { n: step }) }));
|
||||
if (entry.promptTokens !== undefined && entry.promptTokens !== null) {
|
||||
head.append(erstellePlakette(t("detail.entry.usage", {
|
||||
prompt: formatiereTokens(entry.promptTokens),
|
||||
completion: formatiereTokens(entry.completionTokens ?? 0)
|
||||
}), "neutral"));
|
||||
}
|
||||
if (entry.costUsd) head.append(erstellePlakette(formatiereKosten(entry.costUsd), "neutral"));
|
||||
if (entry.finishReason && entry.finishReason !== "stop" && entry.finishReason !== "tool_calls") {
|
||||
head.append(erstellePlakette(entry.finishReason, "warnung"));
|
||||
}
|
||||
} else {
|
||||
head.append(erstelleElement("strong", {
|
||||
text: kind === "tool_result"
|
||||
? t("detail.entry.tool_result", { tool: entry.toolName ?? "?" })
|
||||
: t(`detail.entry.${kind}`)
|
||||
}));
|
||||
}
|
||||
head.append(erstelleElement("span", { klasse: "gedaempft klein", text: formatiereDatumZeit(entry.at) }));
|
||||
if (entry.clipped) head.append(erstellePlakette(t("detail.entry.clipped"), "warnung"));
|
||||
block.append(head);
|
||||
|
||||
if (entry.text) block.append(pre(entry.text));
|
||||
|
||||
for (const call of entry.toolCalls ?? []) {
|
||||
block.append(erstelleElement("div", { klasse: "transkript-aufruf" },
|
||||
erstelleElement("span", { klasse: "klein", text: t("detail.entry.call", { tool: call.name }) }),
|
||||
pre(prettyJson(call.arguments))));
|
||||
}
|
||||
|
||||
items.push(block);
|
||||
}
|
||||
|
||||
const summary = t("detail.transcript.size", { n: transcript.entries.length, chars: formatiereTokens(transcript.sizeChars) });
|
||||
return erstelleKarte(t("detail.transcript"), [erstelleElement("p", { klasse: "hinweis", text: summary }), ...items]);
|
||||
}
|
||||
|
||||
function buildToolCalls(calls) {
|
||||
const table = erstelleElement("table", { klasse: "daten-tabelle" });
|
||||
const head = erstelleElement("tr", {});
|
||||
for (const label of ["runs.col.time", "detail.tool", "runs.col.status", "detail.arguments"]) {
|
||||
head.append(erstelleElement("th", { text: t(label) }));
|
||||
}
|
||||
table.append(head);
|
||||
for (const call of calls) {
|
||||
table.append(erstelleElement("tr", {},
|
||||
erstelleElement("td", { text: formatiereDatumZeit(call.at) }),
|
||||
erstelleElement("td", { text: call.tool }),
|
||||
erstelleElement("td", { text: call.status }),
|
||||
erstelleElement("td", {}, pre(prettyJson(call.arguments)))));
|
||||
}
|
||||
return erstelleKarte(t("detail.toolCalls"), [
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("detail.toolCalls.hint") }),
|
||||
erstelleElement("div", { klasse: "tabelle-scroll" }, table)
|
||||
]);
|
||||
}
|
||||
|
||||
function buildApprovals(approvals) {
|
||||
const list = erstelleElement("ul", { klasse: "schlichte-liste" });
|
||||
for (const a of approvals) {
|
||||
list.append(erstelleElement("li", {},
|
||||
erstelleElement("strong", { text: `#${a.id} ${a.tool}.${a.action ?? "?"} ` }),
|
||||
erstellePlakette(a.status, a.status === "Executed" ? "erfolg" : a.status === "Pending" ? "warnung" : "neutral")));
|
||||
}
|
||||
return erstelleKarte(t("detail.approvals"), [list]);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// ClawdDotNet — Token usage › Runs (#660)
|
||||
//
|
||||
// Every run of the selected range: filterable by agent, model, event (source), status,
|
||||
// cost and free text; sortable by every column; totals over the whole filter, not only
|
||||
// the visible page. A row opens the run detail with prompt and response.
|
||||
//
|
||||
// Strictly DOM API, no innerHTML: results and task titles are foreign text.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
erstellePlakette,
|
||||
formatiereDatumZeit,
|
||||
formatiereTokens,
|
||||
formatiereKosten
|
||||
} from "../modules/dom.js";
|
||||
import { navigiere } from "../modules/router.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
import { readFilters, applyFilters, toApiQuery, loadFacets, buildFilterBar } from "../modules/usage-filters.js";
|
||||
|
||||
const PATH = "/verbrauch/runs";
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "time", label: "runs.col.time" },
|
||||
{ key: "agent", label: "runs.col.agent" },
|
||||
{ key: "model", label: "runs.col.model" },
|
||||
{ key: "source", label: "runs.col.event" },
|
||||
{ key: "status", label: "runs.col.status" },
|
||||
{ key: "steps", label: "runs.col.steps", numeric: true },
|
||||
{ key: "prompt", label: "runs.col.prompt", numeric: true },
|
||||
{ key: "completion", label: "runs.col.completion", numeric: true },
|
||||
{ key: "cost", label: "runs.col.cost", numeric: true },
|
||||
{ key: "duration", label: "runs.col.duration", numeric: true }
|
||||
];
|
||||
|
||||
export function statusBadge(status) {
|
||||
const kind = {
|
||||
Completed: "erfolg",
|
||||
BudgetExceeded: "warnung",
|
||||
Truncated: "warnung",
|
||||
LoopLimitExceeded: "gefahr",
|
||||
RepeatedFailure: "gefahr",
|
||||
Failed: "gefahr",
|
||||
Cancelled: "neutral"
|
||||
}[status] ?? "neutral";
|
||||
return erstellePlakette(status, kind);
|
||||
}
|
||||
|
||||
export function sourceLabel(source) {
|
||||
const key = `source.${source}`;
|
||||
const text = t(key);
|
||||
return text === key ? source : text;
|
||||
}
|
||||
|
||||
export async function renderUsageRuns() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
|
||||
const filters = readFilters();
|
||||
filters.sort ??= "time";
|
||||
filters.dir ??= "desc";
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: t("runs.title") }));
|
||||
|
||||
const facets = await loadFacets(filters);
|
||||
layout.append(buildFilterBar(filters, facets, (next) => applyFilters(PATH, next)));
|
||||
|
||||
const content = erstelleElement("div", {}, erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
layout.append(content);
|
||||
container.replaceChildren(layout);
|
||||
|
||||
let page;
|
||||
try {
|
||||
page = await api(`/usage/runs?${toApiQuery(filters, { offset: filters.offset, limit: PAGE_SIZE })}`);
|
||||
} catch (err) {
|
||||
content.replaceChildren(erstelleKarte(t("runs.unavailable"), [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: err.message })
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
content.replaceChildren(buildTotals(page.totals), buildTable(page, filters));
|
||||
}
|
||||
|
||||
function buildTotals(totals) {
|
||||
const tiles = [
|
||||
[t("runs.total.runs"), String(totals.runs)],
|
||||
[t("runs.total.prompt"), formatiereTokens(totals.promptTokens)],
|
||||
[t("runs.total.completion"), formatiereTokens(totals.completionTokens)],
|
||||
[t("runs.total.cached"), formatiereTokens(totals.cachedTokens)],
|
||||
[t("runs.total.cost"), formatiereKosten(totals.costUsd)],
|
||||
[t("runs.total.notRun"), String(totals.notRun)]
|
||||
];
|
||||
|
||||
const grid = erstelleElement("div", { klasse: "kennzahlen-raster" });
|
||||
for (const [label, value] of tiles) {
|
||||
grid.append(erstelleElement("div", { klasse: "kennzahl" },
|
||||
erstelleElement("div", { klasse: "kennzahl-wert", text: value }),
|
||||
erstelleElement("div", { klasse: "kennzahl-titel", text: label })));
|
||||
}
|
||||
|
||||
const parts = [grid];
|
||||
if (totals.runsWithUnknownCost > 0) {
|
||||
parts.push(erstelleElement("p", { klasse: "hinweis", text: t("runs.unknownCost", { n: totals.runsWithUnknownCost }) }));
|
||||
}
|
||||
return erstelleKarte(t("runs.totals"), parts);
|
||||
}
|
||||
|
||||
function buildTable(page, filters) {
|
||||
if (page.items.length === 0) {
|
||||
return erstelleKarte(t("runs.empty.title"), [erstelleElement("p", { klasse: "gedaempft", text: t("runs.empty.text") })]);
|
||||
}
|
||||
|
||||
const table = erstelleElement("table", { klasse: "daten-tabelle lauf-tabelle" });
|
||||
const head = erstelleElement("tr", {});
|
||||
for (const column of COLUMNS) {
|
||||
const active = filters.sort === column.key;
|
||||
const arrow = active ? (filters.dir === "asc" ? " ▲" : " ▼") : "";
|
||||
const button = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "sortier-knopf",
|
||||
text: t(column.label) + arrow,
|
||||
onclick: () => applyFilters(PATH, {
|
||||
...filters,
|
||||
offset: 0,
|
||||
sort: column.key,
|
||||
dir: active && filters.dir === "desc" ? "asc" : "desc"
|
||||
})
|
||||
});
|
||||
head.append(erstelleElement("th", { klasse: column.numeric ? "zahl" : "", "aria-sort": active ? (filters.dir === "asc" ? "ascending" : "descending") : "none" }, button));
|
||||
}
|
||||
table.append(erstelleElement("thead", {}, head));
|
||||
|
||||
const body = erstelleElement("tbody", {});
|
||||
for (const run of page.items) {
|
||||
const open = () => navigiere(`#${PATH}/${encodeURIComponent(run.runId)}`);
|
||||
const row = erstelleElement("tr", { klasse: "klickbar", tabindex: "0", onclick: open });
|
||||
row.addEventListener("keydown", (event) => { if (event.key === "Enter") open(); });
|
||||
|
||||
const event = erstelleElement("td", {}, erstelleElement("div", { text: sourceLabel(run.source) }));
|
||||
if (run.taskTitle || run.taskId) {
|
||||
event.append(erstelleElement("div", { klasse: "gedaempft klein", text: run.taskTitle || run.taskId }));
|
||||
}
|
||||
|
||||
row.append(
|
||||
erstelleElement("td", { text: formatiereDatumZeit(run.occurredAt) }),
|
||||
erstelleElement("td", { text: run.agentId }),
|
||||
erstelleElement("td", { klasse: "klein", text: run.model || "—" }),
|
||||
event,
|
||||
erstelleElement("td", {}, statusBadge(run.status)),
|
||||
erstelleElement("td", { klasse: "zahl", text: String(run.stepCount) }),
|
||||
erstelleElement("td", { klasse: "zahl", text: formatiereTokens(run.promptTokens) }),
|
||||
erstelleElement("td", { klasse: "zahl", text: formatiereTokens(run.completionTokens) }),
|
||||
erstelleElement("td", { klasse: "zahl", text: run.costIsKnown || run.costUsd > 0 ? formatiereKosten(run.costUsd) : "—" }),
|
||||
erstelleElement("td", { klasse: "zahl", text: formatDuration(run.durationMs) })
|
||||
);
|
||||
body.append(row);
|
||||
}
|
||||
table.append(body);
|
||||
|
||||
const offset = filters.offset || 0;
|
||||
const pager = erstelleElement("div", { klasse: "blaettern" },
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach klein", text: t("common.previous"), disabled: offset <= 0,
|
||||
onclick: () => applyFilters(PATH, { ...filters, offset: Math.max(0, offset - PAGE_SIZE) })
|
||||
}),
|
||||
erstelleElement("span", {
|
||||
klasse: "gedaempft",
|
||||
text: t("runs.page", { from: offset + 1, to: offset + page.items.length, total: page.total })
|
||||
}),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach klein", text: t("common.next"), disabled: offset + PAGE_SIZE >= page.total,
|
||||
onclick: () => applyFilters(PATH, { ...filters, offset: offset + PAGE_SIZE })
|
||||
}));
|
||||
|
||||
return erstelleKarte(t("runs.list"), [erstelleElement("div", { klasse: "tabelle-scroll" }, table), pager]);
|
||||
}
|
||||
|
||||
export function formatDuration(ms) {
|
||||
if (!ms) return "—";
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
const seconds = ms / 1000;
|
||||
return seconds < 60 ? `${seconds.toFixed(1)} s` : `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")} min`;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user