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,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`;
|
||||
}
|
||||
Reference in New Issue
Block a user