Audit und Laufbelege: Tageswerte je Agent und Einheit, Monatsarchiv statt Loeschen (#700)

Nach der Vorgabe vom 2026-09-22 (so wenig loeschen wie moeglich): Laeufe
aelter als hotDays (Vorgabe 180, 0 = alles bleibt) wandern aus AuditLog und
RunReceipts in Archive/audit-JJJJ-MM.db - kopiert und entfernt in einer
Transaktion, Zeilen-IDs bleiben, spaeter dazugekommene Spalten werden im
Archiv ergaenzt. Vorher wird jeder Tag in RunDaily verdichtet (Laeufe,
Fehlschlaege, Schrittgrenze, Tokens, Kosten, Werkzeugaufrufe, Fehler,
Verweigert, Freigaben) mit der Einheit des Agenten; die Tageswerte bleiben.
Nachschlagen eines Laufs findet ihn auch im Archiv. API /usage/days,
/usage/archive; Karte 'Tageswerte' in der Verbrauchsansicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 11:44:50 +02:00
co-authored by Claude Opus 5.5
parent 0dd8a761b8
commit 095722ae9c
11 changed files with 761 additions and 13 deletions
@@ -796,6 +796,22 @@
"usage.units.kind.Deviation": "Ungewöhnlicher Tag",
"usage.units.kind.NearLimit": "Nahe an den Grenzen",
"usage.units.kind.MonthlyThreshold": "Monatssumme überschritten",
"usage.days.title": "Tageswerte",
"usage.days.hint": "Die letzten 30 Tage, summiert über alle Agenten (UTC-Tage). Jeder Tag wird je Agent und Einheit verdichtet und bleibt für immer — auch wenn seine Läufe ins Archiv wandern. Heute erscheint morgen.",
"usage.days.none": "Noch kein Tag verdichtet — die ersten Werte erscheinen wenige Minuten nach dem Start, ab gestern.",
"usage.days.day": "Tag",
"usage.days.runs": "Läufe",
"usage.days.failed": "Gescheitert",
"usage.days.loopLimit": "Schrittgrenze",
"usage.days.cost": "Kosten",
"usage.days.calls": "Werkzeugaufrufe",
"usage.days.errors": "Werkzeugfehler",
"usage.days.staged": "Zur Freigabe",
"usage.archive.title": "Archiv",
"usage.archive.hint": "Audit-Einträge und Laufbelege sind Belege und werden nie gelöscht. Nach so vielen Tagen wandern sie aus der Arbeitsdatenbank in eine Archivdatei je Monat; ein Lauf wird dort weiterhin gefunden. 0 behält alles in der Arbeitsdatenbank.",
"usage.archive.hotDays": "Tage in der Arbeitsdatenbank",
"usage.archive.none": "Noch keine Archivdatei.",
"usage.archive.files": "Archivdateien: {list}",
"usage.forecastTitle": "Kostenprognose",
"usage.forecastDay": "Prognose je Tag",
"usage.forecastMonth": "Prognose je Monat (30 Tage)",
@@ -796,6 +796,22 @@
"usage.units.kind.Deviation": "Unusual day",
"usage.units.kind.NearLimit": "Near the limits",
"usage.units.kind.MonthlyThreshold": "Monthly total crossed",
"usage.days.title": "Day values",
"usage.days.hint": "The last 30 days, summed over all agents (UTC days). Each day is condensed per agent and unit and stays for good — also when its runs move to the archive. Today appears tomorrow.",
"usage.days.none": "No day condensed yet — the first values appear a few minutes after the start, from yesterday on.",
"usage.days.day": "Day",
"usage.days.runs": "Runs",
"usage.days.failed": "Failed",
"usage.days.loopLimit": "Step limit",
"usage.days.cost": "Cost",
"usage.days.calls": "Tool calls",
"usage.days.errors": "Tool errors",
"usage.days.staged": "For approval",
"usage.archive.title": "Archive",
"usage.archive.hint": "Audit entries and receipts are evidence and are never deleted. After this many days they move from the working database into one archive file per month; a run is still found there. 0 keeps everything in the working database.",
"usage.archive.hotDays": "Days in the working database",
"usage.archive.none": "No archive file yet.",
"usage.archive.files": "Archive files: {list}",
"usage.forecastTitle": "Cost Forecast",
"usage.forecastDay": "Forecast per day",
"usage.forecastMonth": "Forecast per month (30 days)",
@@ -13,7 +13,8 @@ import {
erstelleKarte,
formatiereDatumZeit,
formatiereTokens,
formatiereKosten
formatiereKosten,
zeigeMeldung
} from "../modules/dom.js";
export async function rendereVerbrauch() {
@@ -44,10 +45,11 @@ async function ladeVerbrauch() {
);
try {
const [laeufe, prognose, einheiten] = await Promise.all([api("/usage?limit=100"), erstellePrognoseKarte(), erstelleEinheitenKarte()]);
const [laeufe, prognose, einheiten, tage] = await Promise.all([api("/usage?limit=100"), erstellePrognoseKarte(), erstelleEinheitenKarte(), erstelleTageswerteKarte()]);
container.replaceChildren();
if (einheiten) container.append(einheiten);
if (prognose) container.append(prognose);
if (tage) container.append(tage);
if (!laeufe || laeufe.length === 0) {
container.append(
@@ -218,6 +220,81 @@ async function erstelleEinheitenKarte() {
return erstelleKarte(t("usage.units.title"), inhalt);
}
// ─── Day values and the month archive (#700) ───
/**
* The last 30 days, summed per day over all agents — from the condensed day values, which stay
* when old runs move to the archive. Below: how long runs stay in the working database, and the
* archive files. Nothing is ever deleted.
*/
async function erstelleTageswerteKarte() {
let tage, archiv;
try {
[tage, archiv] = await Promise.all([api("/usage/days"), api("/usage/archive")]);
} catch {
return null;
}
const summe = new Map();
for (const d of tage ?? []) {
const s = summe.get(d.day) ?? { runs: 0, failed: 0, loopLimit: 0, cost: 0, calls: 0, errors: 0, staged: 0 };
s.runs += d.runs; s.failed += d.failed; s.loopLimit += d.loopLimit; s.cost += Number(d.costUsd);
s.calls += d.toolCalls; s.errors += d.toolErrors; s.staged += d.staged;
summe.set(d.day, s);
}
const inhalt = [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("usage.days.hint") })];
if (summe.size === 0) {
inhalt.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("usage.days.none") }));
} else {
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
tabelle.append(erstelleElement("thead", {}, erstelleElement("tr", {},
...["usage.days.day", "usage.days.runs", "usage.days.failed", "usage.days.loopLimit", "usage.days.cost", "usage.days.calls", "usage.days.errors", "usage.days.staged"]
.map((k) => erstelleElement("th", { klasse: k === "usage.days.day" ? "" : "zahl", text: t(k) })))));
const koerper = erstelleElement("tbody");
for (const [tag, s] of [...summe.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
koerper.append(erstelleElement("tr", { klasse: s.failed + s.loopLimit > 0 ? "zeile-warnung" : undefined },
erstelleElement("td", { text: tag }),
erstelleElement("td", { klasse: "zahl", text: String(s.runs) }),
erstelleElement("td", { klasse: "zahl", text: String(s.failed) }),
erstelleElement("td", { klasse: "zahl", text: String(s.loopLimit) }),
erstelleElement("td", { klasse: "zahl", text: `${s.cost.toFixed(2)} $` }),
erstelleElement("td", { klasse: "zahl", text: String(s.calls) }),
erstelleElement("td", { klasse: "zahl", text: String(s.errors) }),
erstelleElement("td", { klasse: "zahl", text: String(s.staged) })));
}
tabelle.append(koerper);
inhalt.push(erstelleElement("div", { klasse: "tabelle-scroll" }, tabelle));
}
// How long runs stay in the working database — set by a human; 0 keeps everything there.
const tageFeld = erstelleElement("input", { type: "number", min: "0", max: "36500", step: "1", value: String(archiv?.hotDays ?? 180), "aria-label": t("usage.archive.hotDays") });
const sichern = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach klein",
text: t("common.save"),
onclick: async () => {
try {
const r = await api("/usage/archive", { method: "PUT", body: { hotDays: Number(tageFeld.value) } });
zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 6000);
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
}
}
});
inhalt.push(
erstelleElement("h3", { klasse: "fall-verlauf-titel", text: t("usage.archive.title") }),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("usage.archive.hint") }),
erstelleElement("div", { klasse: "mcp-token-formular" },
erstelleElement("label", { text: t("usage.archive.hotDays") }), tageFeld, sichern));
const dateien = archiv?.files ?? [];
inhalt.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: dateien.length === 0
? t("usage.archive.none")
: t("usage.archive.files", { list: dateien.map((f) => `${f.month} (${(f.bytes / 1048576).toFixed(1)} MB)`).join(", ") }) }));
return erstelleKarte(t("usage.days.title"), inhalt);
}
// ─── Kostenprognose (#595) ───
function getGrundlageText(basis, sample) {