Kosten je Einheit aufrechnen und Abweichungen melden (#699)

- IUsageRepository.GetDailyByAgentAsync: Tagessummen je Agent, Abo-Tokens getrennt
- UnitCosts: je Einheit samt Untereinheiten heute/7/30 Tage, Monat, Vormonat,
  Durchschnittstag (28 Tage, ab 7 Tagen Verlauf), Maximum = Summe der Agentengrenzen
- Meldungen: Abweichung vom eigenen Verlauf, Naehe zur Grenzsumme, Monatsschwellen
  der Instanz; CostWatch alle 15 min, je Einheit/Tag einmal (Tabelle CostAlerts),
  als Aufgabe an den Menschen; schaltet nichts ab
- API /usage/units, /usage/cost-alerts; Zeitreihe gruppiert nach Einheit
- WebUI: Karte "Kosten je Einheit" mit Meldungen in der Verbrauchsansicht; i18n en/de
- Einstellungen instance.json costWatch
- Tests: UnitCostsTests (8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 09:09:42 +02:00
co-authored by Claude Opus 5.5
parent 7eb211fd06
commit da34b215f7
15 changed files with 667 additions and 2 deletions
@@ -671,6 +671,22 @@
"usage.cost": "Kosten",
"usage.unknownPrice": "(Preis unbekannt)",
"usage.notAvailable": "Verbrauchsdaten nicht verfügbar",
"usage.units.title": "Kosten je Einheit",
"usage.units.hint": "Jede Einheit mit allem, was darunter liegt. Das Maximum ist die Summe der Tagesgrenzen ihrer Agenten — Grenzen bleiben am Agenten. Wichtig ist die Veränderung: Ein Tag weit über dem eigenen Durchschnitt der Einheit wird markiert, auch wenn nichts überschritten ist.",
"usage.units.unit": "Einheit",
"usage.units.today": "Heute",
"usage.units.average": "Durchschnittstag",
"usage.units.max": "Max. je Tag",
"usage.units.last7": "7 Tage",
"usage.units.thisMonth": "Dieser Monat",
"usage.units.previousMonth": "Vormonat",
"usage.units.quota": "Kontingent (30 T.)",
"usage.units.instance": "Instanz",
"usage.units.topLevel": "Oberste Ebene",
"usage.units.alerts": "Meldungen der letzten 7 Tage",
"usage.units.kind.Deviation": "Ungewöhnlicher Tag",
"usage.units.kind.NearLimit": "Nahe an den Grenzen",
"usage.units.kind.MonthlyThreshold": "Monatssumme überschritten",
"usage.forecastTitle": "Kostenprognose",
"usage.forecastDay": "Prognose je Tag",
"usage.forecastMonth": "Prognose je Monat (30 Tage)",
@@ -783,6 +799,7 @@
"timeline.metric.runs": "Läufe",
"timeline.groupBy": "Nach",
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.unit": "Einheit",
"timeline.groupBy.model": "Modell",
"timeline.groupBy.source": "Ereignis",
"timeline.groupBy.budget": "Budget",
@@ -671,6 +671,22 @@
"usage.cost": "Cost",
"usage.unknownPrice": "(price unknown)",
"usage.notAvailable": "Usage data not available",
"usage.units.title": "Costs by unit",
"usage.units.hint": "Each unit with everything below it. The maximum is the sum of its agents' daily limits — limits stay at the agents. What matters is the change: a day far above the unit's own average is marked even when nothing is exceeded.",
"usage.units.unit": "Unit",
"usage.units.today": "Today",
"usage.units.average": "Average day",
"usage.units.max": "Max per day",
"usage.units.last7": "7 days",
"usage.units.thisMonth": "This month",
"usage.units.previousMonth": "Last month",
"usage.units.quota": "Quota (30 d)",
"usage.units.instance": "Instance",
"usage.units.topLevel": "Top level",
"usage.units.alerts": "Alerts of the last 7 days",
"usage.units.kind.Deviation": "Unusual day",
"usage.units.kind.NearLimit": "Near the limits",
"usage.units.kind.MonthlyThreshold": "Monthly total crossed",
"usage.forecastTitle": "Cost Forecast",
"usage.forecastDay": "Forecast per day",
"usage.forecastMonth": "Forecast per month (30 days)",
@@ -783,6 +799,7 @@
"timeline.metric.runs": "Runs",
"timeline.groupBy": "By",
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.unit": "Unit",
"timeline.groupBy.model": "Model",
"timeline.groupBy.source": "Event",
"timeline.groupBy.budget": "Budget",
@@ -91,7 +91,7 @@ function buildControls(filters, bucket, groupBy, metric) {
bar.append(
group("metric", ["cost", "tokens", "runs"], metric),
group("groupBy", ["agent", "model", "source", "budget"], groupBy),
group("groupBy", ["agent", "unit", "model", "source", "budget"], groupBy),
group("bucket", ["hour", "day"], bucket));
return bar;
}
@@ -44,8 +44,9 @@ async function ladeVerbrauch() {
);
try {
const [laeufe, prognose] = await Promise.all([api("/usage?limit=100"), erstellePrognoseKarte()]);
const [laeufe, prognose, einheiten] = await Promise.all([api("/usage?limit=100"), erstellePrognoseKarte(), erstelleEinheitenKarte()]);
container.replaceChildren();
if (einheiten) container.append(einheiten);
if (prognose) container.append(prognose);
if (!laeufe || laeufe.length === 0) {
@@ -164,6 +165,59 @@ async function ladeVerbrauch() {
}
}
// ─── Costs by unit (#699) ───
/**
* What each unit costs — itself and everything below it — against the sum of its agents'
* limits and against its own course. The alert matters more than the limit: a unit far
* above its average has exceeded nothing and is exactly the one to look at.
*/
async function erstelleEinheitenKarte() {
let zeilen, warnungen;
try {
[zeilen, warnungen] = await Promise.all([api("/usage/units"), api("/usage/cost-alerts?days=7")]);
} catch {
return null;
}
if (!Array.isArray(zeilen) || zeilen.length === 0) return null;
const usd = (v) => (v == null ? "—" : `${Number(v).toFixed(2)} $`);
const tabelle = erstelleElement("table", { klasse: "daten-tabelle einheiten-kosten" });
tabelle.append(erstelleElement("thead", {}, erstelleElement("tr", {},
...["usage.units.unit", "usage.units.today", "usage.units.average", "usage.units.max", "usage.units.last7", "usage.units.thisMonth", "usage.units.previousMonth", "usage.units.quota"]
.map((k) => erstelleElement("th", { klasse: k === "usage.units.unit" ? "" : "zahl", text: t(k) })))));
const koerper = erstelleElement("tbody");
for (const z of zeilen) {
const name = z.unitId == null ? t("usage.units.instance") : z.unitId === "" ? t("usage.units.topLevel") : z.name;
const auffaellig = z.averageDay != null && z.today >= 1 && z.today >= 2 * z.averageDay;
const nahe = z.shareOfMaxToday != null && z.shareOfMaxToday >= 0.8;
const anteil = z.shareOfMaxToday == null ? "" : ` (${Math.round(z.shareOfMaxToday * 100)} %)`;
koerper.append(erstelleElement("tr", { klasse: auffaellig || nahe ? "zeile-warnung" : undefined },
erstelleElement("td", { stil: `padding-left: ${14 + z.depth * 16}px`, text: name, title: z.path || "" }),
erstelleElement("td", { klasse: "zahl", text: usd(z.today) + anteil }),
erstelleElement("td", { klasse: "zahl", text: usd(z.averageDay) }),
erstelleElement("td", { klasse: "zahl", text: usd(z.maxPerDay) }),
erstelleElement("td", { klasse: "zahl", text: usd(z.last7Days) }),
erstelleElement("td", { klasse: "zahl", text: usd(z.thisMonth) }),
erstelleElement("td", { klasse: "zahl", text: usd(z.previousMonth) }),
// #712: a subscription costs 0 $ but uses quota — shown as tokens, not as "free".
erstelleElement("td", { klasse: "zahl", text: z.subscriptionTokens30Days ? formatiereTokens(z.subscriptionTokens30Days) : "—" })));
}
tabelle.append(koerper);
const inhalt = [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("usage.units.hint") }), erstelleElement("div", { klasse: "tabelle-scroll" }, tabelle)];
if (Array.isArray(warnungen) && warnungen.length > 0) {
inhalt.push(erstelleElement("h3", { klasse: "fall-verlauf-titel", text: t("usage.units.alerts") }));
for (const w of warnungen) {
inhalt.push(erstelleElement("div", { klasse: "hinweis-box warnung" },
erstelleElement("strong", { text: `${formatiereDatumZeit(w.raisedAt)} · ${t(`usage.units.kind.${w.kind}`)}` }),
erstelleElement("div", { text: w.message })));
}
}
return erstelleKarte(t("usage.units.title"), inhalt);
}
// ─── Kostenprognose (#595) ───
function getGrundlageText(basis, sample) {