Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-runs.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

188 lines
7.4 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 › 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`;
}