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>
This commit is contained in:
Richard
2026-09-19 15:33:09 +02:00
co-authored by Claude Opus 5
parent 2f0361278b
commit 5adf4c1ca6
40 changed files with 3521 additions and 10 deletions
+306
View File
@@ -1415,3 +1415,309 @@ button.klein {
margin-top: 10px;
font-size: 14px;
}
/* ─── Token usage analysis and prompt history (#660) ─── */
.nav-unterlink {
padding-left: 42px;
font-size: 13px;
}
.seitenleiste.kollabiert .nav-unterlink {
display: none;
}
.filter-leiste {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px 14px;
margin: 12px 0 16px;
}
.filter-feld {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--gedaempft);
}
.filter-feld select,
.filter-feld input {
min-width: 140px;
}
.filter-suche input {
min-width: 220px;
}
.filter-eigen {
display: flex;
align-items: center;
gap: 6px;
}
.filter-aktionen {
display: flex;
gap: 6px;
}
.kennzahlen-raster {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.kennzahl {
border: 1px solid var(--divider);
padding: 10px 12px;
}
.kennzahl-wert {
font-size: 20px;
font-weight: 600;
}
.kennzahl-titel {
font-size: 12px;
color: var(--gedaempft);
}
.tabelle-scroll,
.diagramm-scroll {
overflow-x: auto;
}
.daten-tabelle td.zahl,
.daten-tabelle th.zahl {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sortier-knopf {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-transform: inherit;
letter-spacing: inherit;
cursor: pointer;
}
.klickbar {
cursor: pointer;
}
.klickbar:focus-visible {
outline: 2px solid var(--accent);
}
.blaettern {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
}
.zurueck-link {
display: inline-block;
margin-bottom: 8px;
}
.diagramm-steuerung {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
margin-bottom: 12px;
}
.zeitverlauf .raster {
stroke: var(--divider);
stroke-width: 1;
}
.zeitverlauf .achse {
fill: var(--gedaempft);
font-size: 11px;
}
.zeitverlauf .balken {
cursor: pointer;
}
.zeitverlauf .balken:hover,
.zeitverlauf .balken:focus {
opacity: 0.8;
outline: none;
}
.zeitverlauf .ereignis line {
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
.zeitverlauf .ereignis-budget line,
.zeitverlauf .ereignis-budget path {
stroke: var(--gefahr);
fill: var(--gefahr);
}
.zeitverlauf .ereignis-modell line,
.zeitverlauf .ereignis-modell path {
stroke: var(--accent);
fill: var(--accent);
}
.legende {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 10px;
font-size: 13px;
}
.legende-eintrag {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legende-farbe {
width: 12px;
height: 12px;
display: inline-block;
}
.legende-marke {
width: 0;
height: 12px;
display: inline-block;
border-left: 2px dashed;
}
.legende-marke.ereignis-budget {
border-color: var(--gefahr);
}
.legende-marke.ereignis-modell {
border-color: var(--accent);
}
.transkript-system {
margin-bottom: 12px;
}
.transkript-eintrag {
border-left: 3px solid var(--divider);
padding: 6px 0 6px 12px;
margin: 10px 0;
}
.transkript-eintrag.art-user { border-left-color: var(--accent); }
.transkript-eintrag.art-assistant { border-left-color: var(--erfolg); }
.transkript-eintrag.art-tool_result { border-left-color: var(--warnung); }
.transkript-eintrag.art-engine,
.transkript-eintrag.art-event { border-left-color: var(--gedaempft); }
.transkript-kopf {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.transkript-text {
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow-y: auto;
margin: 4px 0;
}
.transkript-aufruf {
margin-top: 6px;
}
.schlichte-liste {
list-style: none;
padding: 0;
margin: 0;
}
.schlichte-liste li {
padding: 4px 0;
}
.verlauf-eintrag {
border: 1px solid var(--divider);
padding: 10px 12px;
margin-bottom: 10px;
}
.verlauf-eintrag:hover {
background: var(--surface-hover);
}
.verlauf-kopf {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.verlauf-zeile {
display: flex;
gap: 10px;
font-size: 14px;
margin: 2px 0;
}
.verlauf-marke {
flex: 0 0 80px;
color: var(--gedaempft);
font-size: 12px;
text-transform: uppercase;
}
.verlauf-text {
flex: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.aufbewahrung input[type="number"] {
width: 80px;
}
@media (max-width: 768px) {
.filter-feld select,
.filter-feld input,
.filter-suche input {
min-width: 0;
width: 100%;
}
.filter-feld {
flex: 1 1 140px;
}
.verlauf-zeile {
flex-direction: column;
gap: 2px;
}
}
span.klein,
div.klein,
td.klein {
font-size: 12px;
}
@@ -30,6 +30,11 @@ import { rendereInstanzen, stoppePolling as stoppeInstanzenPolling } from "./vie
import { rendereEinstellungen } from "./views/settings.js";
import { rendereSicherung } from "./views/backup.js";
import { rendereAgentenChats, trenneAgentenChatsStream } from "./views/agent-chats.js";
import { loadLanguage, translateDocument } from "./modules/i18n.js";
import { renderUsageRuns } from "./views/usage-runs.js";
import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
// ─── 1. Routen registrieren ───
@@ -48,6 +53,12 @@ registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
// #660: token usage sub-views and the prompt history
registriereRoute("/verbrauch/runs", () => { verlasseAnsicht(); return renderUsageRuns(); });
registriereRoute("/verbrauch/runs/:runId", (p) => { verlasseAnsicht(); return renderRunDetail(p.runId, "/verbrauch/runs"); });
registriereRoute("/verbrauch/timeline", () => { verlasseAnsicht(); return renderUsageTimeline(); });
registriereRoute("/prompt-history", () => { verlasseAnsicht(); return renderPromptHistory(); });
registriereRoute("/prompt-history/:runId", (p) => { verlasseAnsicht(); return renderRunDetail(p.runId, "/prompt-history"); });
registriereRoute("/agenten-chats", () => { verlasseAnsicht(); return rendereAgentenChats(); });
registriereRoute("/protokoll", () => { verlasseAnsicht(); return rendereProtokoll(); });
registriereRoute("/sicherung", () => { verlasseAnsicht(); return rendereSicherung(); });
@@ -352,6 +363,10 @@ async function starteHauptAnwendung(instanzen) {
}
});
// #661: language packs before the first render
await loadLanguage();
translateDocument();
// Router initialisieren
initialisiereRouter();
}
@@ -0,0 +1,141 @@
{
"nav.usage.runs": "Läufe",
"nav.usage.timeline": "Zeitverlauf",
"nav.promptHistory": "Prompt-Verlauf",
"common.loading": "Lade…",
"common.previous": "Zurück",
"common.next": "Weiter",
"filter.range": "Zeitraum",
"filter.range.24h": "Letzte 24 Stunden",
"filter.range.7d": "Letzte 7 Tage",
"filter.range.30d": "Letzte 30 Tage",
"filter.range.90d": "Letzte 90 Tage",
"filter.range.custom": "Eigener…",
"filter.from": "Von",
"filter.to": "Bis",
"filter.agent": "Agent",
"filter.model": "Modell",
"filter.event": "Ereignis",
"filter.status": "Status",
"filter.all": "Alle",
"filter.minCost": "Kosten ab ($)",
"filter.search": "Suche",
"filter.search.placeholder": "Aufgabe, Ergebnis, Lauf-ID…",
"filter.apply": "Anwenden",
"filter.reset": "Zurücksetzen",
"source.task": "Aufgabe",
"source.direct": "Direkter Lauf",
"source.approval": "Freigabe",
"source.webview": "Chat (Fenster)",
"source.webui": "Chat (Web)",
"source.telegram": "Telegram",
"source.agentcomm": "Delegation",
"source.job": "Job",
"source.unknown": "Unbekannt",
"runs.title": "Tokennutzung › Läufe",
"runs.unavailable": "Keine Verbrauchsdaten",
"runs.totals": "Summen für diesen Filter",
"runs.total.runs": "Läufe",
"runs.total.prompt": "Prompt-Tokens",
"runs.total.completion": "Antwort-Tokens",
"runs.total.cached": "Aus dem Cache",
"runs.total.cost": "Kosten",
"runs.total.notRun": "Abgewiesen (Budget, Herunterfahren)",
"runs.unknownCost": "{n} Lauf/Läufe ohne bekannten Preis sind mit 0 $ gezählt.",
"runs.empty.title": "Keine Läufe",
"runs.empty.text": "Kein Lauf passt zu diesem Filter.",
"runs.list": "Läufe",
"runs.page": "{from}–{to} von {total}",
"runs.col.time": "Zeit",
"runs.col.agent": "Agent",
"runs.col.model": "Modell",
"runs.col.event": "Ereignis",
"runs.col.status": "Status",
"runs.col.steps": "Schritte",
"runs.col.prompt": "Prompt",
"runs.col.completion": "Antwort",
"runs.col.cost": "Kosten",
"runs.col.duration": "Dauer",
"detail.back": "← Zurück",
"detail.title": "Lauf",
"detail.notFound": "Lauf nicht gefunden",
"detail.runId": "Lauf-ID",
"detail.tokens": "Tokens",
"detail.tokensValue": "{prompt} Prompt · {completion} Antwort · {cached} aus dem Cache",
"detail.result": "Ergebnis",
"detail.receipt": "Beleg",
"detail.transcript": "Prompt und Antwort",
"detail.transcript.disabled": "Für diesen Agenten ist der Prompt-Verlauf abgeschaltet (Aufbewahrung: nie).",
"detail.transcript.unavailable": "Diese Instanz zeichnet keinen Prompt-Verlauf auf.",
"detail.transcript.missing": "Kein Transkript zu diesem Lauf – er lief vor Beginn der Aufzeichnung oder ist über seine Aufbewahrungsfrist hinaus.",
"detail.transcript.truncated": "Dieser Lauf hat die Größengrenze des Prompt-Verlaufs überschritten; spätere Einträge sind gekürzt.",
"detail.transcript.size": "{n} Einträge, {chars} Zeichen",
"detail.systemPrompt": "Systemprompt ({n} Zeichen)",
"detail.entry.user": "Auftrag / Nachricht",
"detail.entry.engine": "Nachfrage der Engine",
"detail.entry.event": "Ereignis",
"detail.entry.assistant": "Modellschritt {n}",
"detail.entry.usage": "{prompt} rein · {completion} raus",
"detail.entry.tool_result": "Ergebnis: {tool}",
"detail.entry.call": "Ruft {tool} auf",
"detail.entry.clipped": "gekürzt",
"detail.tool": "Werkzeug",
"detail.arguments": "Argumente",
"detail.toolCalls": "Werkzeugaufrufe",
"detail.toolCalls.hint": "Aus dem Audit-Log – auch ohne Transkript vorhanden.",
"detail.approvals": "Zur Freigabe vorgelegt",
"timeline.title": "Tokennutzung › Zeitverlauf",
"timeline.chart": "Zeitverlauf",
"timeline.metric": "Maß",
"timeline.metric.cost": "Kosten",
"timeline.metric.tokens": "Tokens",
"timeline.metric.runs": "Läufe",
"timeline.groupBy": "Nach",
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Modell",
"timeline.groupBy.source": "Ereignis",
"timeline.bucket": "Je",
"timeline.bucket.hour": "Stunde",
"timeline.bucket.day": "Tag",
"timeline.tooltipRuns": "{n} Lauf/Läufe",
"timeline.event.budget_exceeded": "Budget erschöpft",
"timeline.event.model_changed": "Modell gewechselt",
"timeline.hint": "Ein Klick auf einen Balken zeigt dessen Läufe.",
"history.title": "Prompt-Verlauf",
"history.intro": "Prompt und Antwort jedes Laufs, durchsuchbar über das ganze Gespräch samt Werkzeugaufrufen und Ergebnissen.",
"history.unavailable": "Prompt-Verlauf nicht verfügbar",
"history.empty.title": "Nichts aufgezeichnet",
"history.empty.text": "Keine Transkripte in diesem Zeitraum. Die Aufzeichnung beginnt mit Version 0.1.12 und folgt der Aufbewahrung unten.",
"history.empty.search": "Kein Transkript enthält diesen Text.",
"history.list": "Transkripte ({n})",
"history.prompt": "Prompt",
"history.response": "Antwort",
"history.truncated": "Größengrenze erreicht ({chars} Zeichen gespeichert).",
"history.settings.title": "Aufbewahrung",
"history.settings.intro": "Wie lange Prompts und Antworten aufbewahrt werden. Die Vorgabe gilt für jeden Agenten ohne eigene Einstellung; „nie“ löscht auch, was vorher aufgezeichnet wurde.",
"history.settings.default": "Vorgabe für alle Agenten:",
"history.settings.agent": "Agent",
"history.settings.retention": "Aufbewahrung",
"history.settings.effective": "Gilt",
"history.settings.mode": "Aufbewahrung",
"history.settings.mode.inherit": "Vorgabe",
"history.settings.mode.never": "Nie aufzeichnen",
"history.settings.mode.days": "Tage",
"history.settings.mode.forever": "Unbegrenzt",
"history.settings.days": "Tage",
"history.settings.daysUnit": "Tage",
"history.settings.effective.never": "wird nicht aufgezeichnet",
"history.settings.effective.forever": "unbegrenzt",
"history.settings.effective.days": "{n} Tage",
"history.settings.save": "Aufbewahrung speichern",
"history.settings.saved": "Aufbewahrung gespeichert.",
"history.settings.failed": "Speichern fehlgeschlagen: {error}"
}
@@ -0,0 +1,141 @@
{
"nav.usage.runs": "Runs",
"nav.usage.timeline": "Timeline",
"nav.promptHistory": "Prompt History",
"common.loading": "Loading…",
"common.previous": "Previous",
"common.next": "Next",
"filter.range": "Period",
"filter.range.24h": "Last 24 hours",
"filter.range.7d": "Last 7 days",
"filter.range.30d": "Last 30 days",
"filter.range.90d": "Last 90 days",
"filter.range.custom": "Custom…",
"filter.from": "From",
"filter.to": "To",
"filter.agent": "Agent",
"filter.model": "Model",
"filter.event": "Event",
"filter.status": "Status",
"filter.all": "All",
"filter.minCost": "Cost from ($)",
"filter.search": "Search",
"filter.search.placeholder": "Task, result, run id…",
"filter.apply": "Apply",
"filter.reset": "Reset",
"source.task": "Task",
"source.direct": "Direct run",
"source.approval": "Approval",
"source.webview": "Chat (window)",
"source.webui": "Chat (web)",
"source.telegram": "Telegram",
"source.agentcomm": "Delegation",
"source.job": "Job",
"source.unknown": "Unknown",
"runs.title": "Token usage › Runs",
"runs.unavailable": "No usage data",
"runs.totals": "Totals for this filter",
"runs.total.runs": "Runs",
"runs.total.prompt": "Prompt tokens",
"runs.total.completion": "Completion tokens",
"runs.total.cached": "From cache",
"runs.total.cost": "Cost",
"runs.total.notRun": "Refused (budget, shutdown)",
"runs.unknownCost": "{n} run(s) without a known price are counted with 0 $.",
"runs.empty.title": "No runs",
"runs.empty.text": "No run matches this filter.",
"runs.list": "Runs",
"runs.page": "{from}–{to} of {total}",
"runs.col.time": "Time",
"runs.col.agent": "Agent",
"runs.col.model": "Model",
"runs.col.event": "Event",
"runs.col.status": "Status",
"runs.col.steps": "Steps",
"runs.col.prompt": "Prompt",
"runs.col.completion": "Response",
"runs.col.cost": "Cost",
"runs.col.duration": "Duration",
"detail.back": "← Back",
"detail.title": "Run",
"detail.notFound": "Run not found",
"detail.runId": "Run id",
"detail.tokens": "Tokens",
"detail.tokensValue": "{prompt} prompt · {completion} response · {cached} from cache",
"detail.result": "Result",
"detail.receipt": "Receipt",
"detail.transcript": "Prompt and response",
"detail.transcript.disabled": "Prompt history is switched off for this agent (retention: never).",
"detail.transcript.unavailable": "This instance does not record a prompt history.",
"detail.transcript.missing": "No transcript for this run - it ran before recording started or is past its retention.",
"detail.transcript.truncated": "This run exceeded the size limit of the prompt history; later entries are shortened.",
"detail.transcript.size": "{n} entries, {chars} characters",
"detail.systemPrompt": "System prompt ({n} characters)",
"detail.entry.user": "Task / message",
"detail.entry.engine": "Follow-up by the engine",
"detail.entry.event": "Event",
"detail.entry.assistant": "Model step {n}",
"detail.entry.usage": "{prompt} in · {completion} out",
"detail.entry.tool_result": "Result: {tool}",
"detail.entry.call": "Calls {tool}",
"detail.entry.clipped": "shortened",
"detail.tool": "Tool",
"detail.arguments": "Arguments",
"detail.toolCalls": "Tool calls",
"detail.toolCalls.hint": "From the audit log - available even without a transcript.",
"detail.approvals": "Proposed for approval",
"timeline.title": "Token usage › Timeline",
"timeline.chart": "Timeline",
"timeline.metric": "Measure",
"timeline.metric.cost": "Cost",
"timeline.metric.tokens": "Tokens",
"timeline.metric.runs": "Runs",
"timeline.groupBy": "By",
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Model",
"timeline.groupBy.source": "Event",
"timeline.bucket": "Per",
"timeline.bucket.hour": "Hour",
"timeline.bucket.day": "Day",
"timeline.tooltipRuns": "{n} run(s)",
"timeline.event.budget_exceeded": "Budget exhausted",
"timeline.event.model_changed": "Model changed",
"timeline.hint": "Click a bar to see its runs.",
"history.title": "Prompt History",
"history.intro": "Prompt and response of every run, searchable over the whole conversation including tool calls and results.",
"history.unavailable": "Prompt history not available",
"history.empty.title": "Nothing recorded",
"history.empty.text": "No transcripts in this period. Recording starts with version 0.1.12 and follows the retention below.",
"history.empty.search": "No transcript contains this text.",
"history.list": "Transcripts ({n})",
"history.prompt": "Prompt",
"history.response": "Response",
"history.truncated": "Size limit reached ({chars} characters stored).",
"history.settings.title": "Retention",
"history.settings.intro": "How long prompts and responses are kept. The default applies to every agent without its own setting; \"never\" also deletes what was recorded before.",
"history.settings.default": "Default for all agents:",
"history.settings.agent": "Agent",
"history.settings.retention": "Retention",
"history.settings.effective": "Applies",
"history.settings.mode": "Retention",
"history.settings.mode.inherit": "Default",
"history.settings.mode.never": "Never record",
"history.settings.mode.days": "Days",
"history.settings.mode.forever": "Keep forever",
"history.settings.days": "Days",
"history.settings.daysUnit": "days",
"history.settings.effective.never": "not recorded",
"history.settings.effective.forever": "kept forever",
"history.settings.effective.days": "kept {n} days",
"history.settings.save": "Save retention",
"history.settings.saved": "Retention saved.",
"history.settings.failed": "Saving failed: {error}"
}
+13 -1
View File
@@ -71,12 +71,24 @@
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Analyse</div>
<a href="#/verbrauch" class="nav-link" title="Tokennutzung">
<a href="#/verbrauch" class="nav-link" title="Tokennutzung" data-exakt>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
</svg>
<span class="nav-label">Tokennutzung</span>
</a>
<a href="#/verbrauch/runs" class="nav-link nav-unterlink" data-i18n-title="nav.usage.runs">
<span class="nav-label" data-i18n="nav.usage.runs">Runs</span>
</a>
<a href="#/verbrauch/timeline" class="nav-link nav-unterlink" data-i18n-title="nav.usage.timeline">
<span class="nav-label" data-i18n="nav.usage.timeline">Timeline</span>
</a>
<a href="#/prompt-history" class="nav-link" data-i18n-title="nav.promptHistory">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 8v4l3 2 M3.05 11a9 9 0 1 1 .5 4 M3 20v-5h5"/>
</svg>
<span class="nav-label" data-i18n="nav.promptHistory">Prompt History</span>
</a>
<a href="#/agenten-chats" class="nav-link" title="Agenten-Chats">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
@@ -0,0 +1,63 @@
// ClawdDotNet — translations (#661)
//
// English is the main language: every key has its English text in i18n/en.json, and that
// pack is always loaded as the fallback. Other languages live in their own packs next to
// it (i18n/de.json, ...), separate from the code. A missing key falls back to English,
// a key missing there too shows the key itself - visible, so it gets noticed.
//
// The language selection itself is part of #661; until then the choice is read from
// localStorage ("clawd.language") and defaults to English.
export const LANGUAGE_KEY = "clawd.language";
export const DEFAULT_LANGUAGE = "en";
let fallbackPack = {};
let activePack = {};
let activeLanguage = DEFAULT_LANGUAGE;
async function loadPack(language) {
try {
const response = await fetch(`i18n/${encodeURIComponent(language)}.json`, { cache: "no-cache" });
return response.ok ? await response.json() : {};
} catch {
return {};
}
}
/** Loads English plus the selected language. Call once before the first render. */
export async function loadLanguage() {
let wanted = DEFAULT_LANGUAGE;
try {
wanted = localStorage.getItem(LANGUAGE_KEY) || DEFAULT_LANGUAGE;
} catch {
// Storage blocked: stay with English.
}
fallbackPack = await loadPack(DEFAULT_LANGUAGE);
activePack = wanted === DEFAULT_LANGUAGE ? fallbackPack : await loadPack(wanted);
activeLanguage = wanted;
document.documentElement.lang = wanted;
}
export function currentLanguage() {
return activeLanguage;
}
/**
* Text for a key. Placeholders in braces are replaced: t("runs.count", { n: 3 }).
* Values are inserted as plain text - callers put the result into textContent.
*/
export function t(key, values = {}) {
const text = activePack[key] ?? fallbackPack[key] ?? key;
return text.replace(/\{(\w+)\}/g, (match, name) => (name in values ? String(values[name]) : match));
}
/** Translates static markup: every element with data-i18n gets its text from the pack. */
export function translateDocument(root = document) {
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const element of root.querySelectorAll("[data-i18n-title]")) {
element.title = t(element.dataset.i18nTitle);
}
}
@@ -39,10 +39,24 @@ export function navigiere(hash) {
function parseHash() {
const hash = window.location.hash || "#/chat";
const pfad = hash.startsWith("#") ? hash.slice(1) : hash;
let pfad = hash.startsWith("#") ? hash.slice(1) : hash;
// #660: Filters travel in the address ("#/verbrauch/runs?agent=x") so a view can be
// linked. They are not part of the route pattern.
const frage = pfad.indexOf("?");
if (frage >= 0) pfad = pfad.slice(0, frage);
return pfad.startsWith("/") ? pfad : `/${pfad}`;
}
/**
* Query parameters of the current hash address (#660).
* @returns {URLSearchParams}
*/
export function currentQuery() {
const hash = window.location.hash || "";
const frage = hash.indexOf("?");
return new URLSearchParams(frage >= 0 ? hash.slice(frage + 1) : "");
}
function findePassendeRoute(pfad) {
const pfadTeile = pfad.split("/").filter(Boolean);
@@ -98,7 +112,15 @@ function aktualisiereAktivenNavEintrag(pfad) {
const zielPfad = href.startsWith("#") ? href.slice(1) : href;
const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0];
if (basisPfad === zielBasis) {
// #660: Sub-entries match their own path (and what lies below it), the parent entry
// only its exact path - otherwise "Token usage" and "Runs" would both light up.
const treffer = link.classList.contains("nav-unterlink")
? pfad === zielPfad || pfad.startsWith(zielPfad + "/")
: link.dataset.exakt !== undefined
? pfad === zielPfad
: basisPfad === zielBasis;
if (treffer) {
link.classList.add("aktiv");
} else {
link.classList.remove("aktiv");
@@ -0,0 +1,156 @@
// ClawdDotNet — filter bar of the usage analysis (#660)
//
// The filter lives in the hash address ("#/verbrauch/runs?range=7d&agent=hausmeister"), so a
// filtered view can be bookmarked and shared, and back/forward work. Each view reads it with
// readFilters() and writes it with applyFilters().
import { api } from "./api.js";
import { erstelleElement } from "./dom.js";
import { currentQuery, navigiere } from "./router.js";
import { t } from "./i18n.js";
export const RANGES = ["24h", "7d", "30d", "90d", "custom"];
// bucket, groupBy and metric belong to the timeline; the API query ignores them.
const FILTER_KEYS = ["range", "from", "to", "agent", "model", "source", "status", "minCost", "q", "sort", "dir",
"bucket", "groupBy", "metric"];
/** The filter of the current address, with defaults. */
export function readFilters() {
const query = currentQuery();
const filters = {};
for (const key of FILTER_KEYS) {
const value = query.get(key);
if (value) filters[key] = value;
}
filters.range ??= "7d";
filters.offset = Number(query.get("offset") || 0);
return filters;
}
/** Navigates to the same view with a new filter. Resets paging unless offset is given. */
export function applyFilters(path, filters) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === "" || (key === "offset" && !value)) continue;
query.set(key, String(value));
}
const text = query.toString();
navigiere(`#${path}${text ? "?" + text : ""}`);
}
/** Start and end of the selected range as ISO strings (UTC). */
export function rangeOf(filters) {
const now = new Date();
if (filters.range === "custom") {
// Date inputs are local calendar days; "to" includes the whole day.
const from = filters.from ? new Date(`${filters.from}T00:00:00`) : new Date(now.getTime() - 7 * 864e5);
const to = filters.to ? new Date(new Date(`${filters.to}T00:00:00`).getTime() + 864e5) : now;
return { from: from.toISOString(), to: to.toISOString() };
}
const hours = { "24h": 24, "7d": 168, "30d": 720, "90d": 2160 }[filters.range] ?? 168;
return { from: new Date(now.getTime() - hours * 36e5).toISOString(), to: new Date(now.getTime() + 60e3).toISOString() };
}
/** Query string for the API: range plus every set filter. */
export function toApiQuery(filters, extra = {}) {
const { from, to } = rangeOf(filters);
const query = new URLSearchParams({ from, to });
for (const key of ["agent", "model", "source", "status", "minCost", "q", "sort", "dir"]) {
if (filters[key]) query.set(key, filters[key]);
}
for (const [key, value] of Object.entries(extra)) {
if (value !== undefined && value !== null && value !== "") query.set(key, String(value));
}
return query.toString();
}
export async function loadFacets(filters) {
const { from, to } = rangeOf(filters);
try {
return await api(`/usage/facets?${new URLSearchParams({ from, to })}`);
} catch {
return { agents: [], models: [], sources: [], statuses: [] };
}
}
function select(name, label, options, current, allText) {
const element = erstelleElement("select", { name, "aria-label": label });
element.append(erstelleElement("option", { value: "", text: allText }));
for (const option of options) {
const value = typeof option === "string" ? option : option.value;
const text = typeof option === "string" ? option : option.text;
element.append(erstelleElement("option", { value, text }));
}
element.value = current || "";
return erstelleElement("label", { klasse: "filter-feld" }, erstelleElement("span", { text: label }), element);
}
/**
* The bar itself. `fields` chooses what is shown, e.g. ["range", "agent", "q"].
* `onApply(filters)` receives the complete new filter.
*/
export function buildFilterBar(filters, facets, onApply, fields = ["range", "agent", "model", "source", "status", "minCost", "q"]) {
const form = erstelleElement("form", { klasse: "filter-leiste" });
const show = (field) => fields.includes(field);
if (show("range")) {
form.append(select("range", t("filter.range"),
RANGES.map((r) => ({ value: r, text: t(`filter.range.${r}`) })), filters.range, t("filter.range.7d")));
const rangeSelect = form.querySelector("select[name=range]");
rangeSelect.querySelector("option[value='']").remove();
const from = erstelleElement("input", { type: "date", name: "from", value: filters.from || "", "aria-label": t("filter.from") });
const to = erstelleElement("input", { type: "date", name: "to", value: filters.to || "", "aria-label": t("filter.to") });
const custom = erstelleElement("span", { klasse: "filter-eigen" }, from, erstelleElement("span", { text: "–" }), to);
custom.hidden = filters.range !== "custom";
rangeSelect.addEventListener("change", () => { custom.hidden = rangeSelect.value !== "custom"; });
form.append(custom);
}
if (show("agent")) form.append(select("agent", t("filter.agent"), facets.agents ?? [], filters.agent, t("filter.all")));
if (show("model")) form.append(select("model", t("filter.model"), facets.models ?? [], filters.model, t("filter.all")));
if (show("source")) {
form.append(select("source", t("filter.event"),
(facets.sources ?? []).map((s) => ({ value: s, text: t(`source.${s}`) === `source.${s}` ? s : t(`source.${s}`) })),
filters.source, t("filter.all")));
}
if (show("status")) form.append(select("status", t("filter.status"), facets.statuses ?? [], filters.status, t("filter.all")));
if (show("minCost")) {
form.append(erstelleElement("label", { klasse: "filter-feld" },
erstelleElement("span", { text: t("filter.minCost") }),
erstelleElement("input", { type: "number", name: "minCost", min: "0", step: "0.001", value: filters.minCost || "", placeholder: "0.00" })));
}
if (show("q")) {
form.append(erstelleElement("label", { klasse: "filter-feld filter-suche" },
erstelleElement("span", { text: t("filter.search") }),
erstelleElement("input", { type: "search", name: "q", value: filters.q || "", placeholder: t("filter.search.placeholder") })));
}
const apply = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("filter.apply") });
const reset = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach klein",
text: t("filter.reset"),
onclick: () => onApply({ range: "7d" })
});
form.append(erstelleElement("div", { klasse: "filter-aktionen" }, apply, reset));
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const next = { sort: filters.sort, dir: filters.dir };
for (const [key, value] of data.entries()) {
if (value) next[key] = String(value);
}
if (next.range !== "custom") {
delete next.from;
delete next.to;
}
onApply(next);
});
return form;
}
@@ -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);
}