modelle: „Wer nutzt die Modelle“ neu — Kennzahlen, Tabelle je Absender, lesbare Säulendiagramme

Der User: „Viel zu dünn. Und den Graphen kann ja kein Mensch lesen.“ Vorher drei Balken und ein winziges
Stunden-Histogramm ohne lesbare Achsen, halb so breit neben dem Radar.

- Eigene Ansicht views/ModellNutzung.tsx in voller Breite (Radar darunter, ebenfalls volle Breite).
- Kennzahlen: Anfragen mit Schnitt je vollem Tag, heute/gestern, typische Antwort mit „9 von 10 bis“, Fehler
  (ab 1 % bernstein, ab 5 % rot, immer mit Wort dazu, nie nur Farbe).
- Tabelle je Absender: Anfragen mit Anteil-Balken, Antwortzeiten, Rechenzeit mit Anteil (NerdQuiz stellt
  92 % der Anfragen, aber nur 75 % der Rechenzeit), Fehler in Worten („Modell nicht erreichbar (502)“),
  letzte Anfrage. Schmal als Karten mit Beschriftung.
- Neuer Baustein components/diagramme/Saeulen.tsx für „je Tag (7 Tage)“ und „je Stunde (24 h)“: gestapelt je
  Absender, glatte y-Achse, Wochentag/Datum bzw. Uhrzeit an der x-Achse (Stunden ausgedünnt auf 3 bzw. 6 h),
  Legende, Summen auf den Tagessäulen (bei den Stunden nur die höchste), Anzeige per Zeiger, Finger und
  Pfeiltasten, darunter „Werte als Tabelle“. Auf dem Handy steht die Anzeige unter dem Diagramm, sonst
  verdeckte sie das halbe Bild. Säulen höchstens 24 px, 2 px Luft zwischen den Stücken statt Rändern.
- Farben: neue Tokens --reihe-3 (Gelb) und --reihe-weitere (Grau; ab der vierten Farbe fasst „Weitere“
  zusammen). Mit dem Paletten-Prüfer gegen #15181b gemessen: jedes Paar auch bei Rot-Grün-Schwäche getrennt.
  Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6), Violett gleicht der KI-Box-Farbe.
  Die Farbe folgt dem Absender (feste Reihe der Box), nicht dem Rang.
- „Wann welches Modell geladen wurde“ eingeklappt darunter: Anzahl, zuletzt, davor — mit Rolle (Hirn, Coder,
  Bild-Zwilling) statt nur Dateiname.
- useBreite aus Verlauf.tsx nach lib/breite.ts, damit beide Diagramme ihn teilen.
- Ältere Stände der Box ohne die neuen Felder zeigen weiter Anzahlen und Verläufe, Zeiten als „–“ — die
  Homelab-Instanz fragt über den Partner und kann einen anderen Stand haben.

frontend/dist ist bewusst nicht gebaut (kommt beim Zusammenführen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 13:26:45 +02:00
co-authored by Claude Opus 5.5
parent 7834de9592
commit dfe0971180
14 changed files with 1275 additions and 65 deletions
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useRef, useState } from "react"
/** Breite des Behälters für die Diagramme; ohne ResizeObserver (Tests) bleibt es beim Standard. */
export function useBreite(standard = 640) {
const ref = useRef<HTMLDivElement>(null)
const [breite, setBreite] = useState(standard)
useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
return [ref, breite] as const
}
+49
View File
@@ -0,0 +1,49 @@
import { saeulenPfad, stapeln, zaehlAchse } from "@/lib/diagramm"
describe("zaehlAchse", () => {
it("teilt in glatte, ganzzahlige Schritte mit höchstens fünf Strichen", () => {
expect(zaehlAchse(774)).toEqual([0, 200, 400, 600, 800])
expect(zaehlAchse(139)).toEqual([0, 50, 100, 150])
expect(zaehlAchse(68)).toEqual([0, 20, 40, 60, 80])
expect(zaehlAchse(12)).toEqual([0, 5, 10, 15])
expect(zaehlAchse(5)).toEqual([0, 2, 4, 6])
expect(zaehlAchse(4)).toEqual([0, 1, 2, 3, 4])
expect(zaehlAchse(1)).toEqual([0, 1])
})
it("hält auch ohne Werte eine Achse", () => {
expect(zaehlAchse(0)).toEqual([0, 1])
expect(zaehlAchse(Number.NaN)).toEqual([0, 1])
expect(zaehlAchse(Number.POSITIVE_INFINITY)).toEqual([0, 1])
})
})
describe("saeulenPfad", () => {
it("rundet nur die oberen Ecken", () => {
expect(saeulenPfad(10, 20, 24, 100)).toBe("M10,120V24Q10,20 14,20H30Q34,20 34,24V120Z")
})
it("kappt den Radius bei flachen Säulen", () => {
expect(saeulenPfad(0, 50, 24, 2)).toBe("M0,52V52Q0,50 2,50H22Q24,50 24,52V52Z")
})
})
describe("stapeln", () => {
const y = (v: number) => 100 - v // 1 px je Einheit, Grundlinie bei 100
it("stapelt von unten nach oben mit 2 px Luft und lässt Nullen aus", () => {
expect(stapeln([50, 0, 10], y)).toEqual([
{ index: 0, y: 50, hoehe: 50, oben: false },
{ index: 2, y: 40, hoehe: 8, oben: true },
])
})
it("rundet das oberste sichtbare Stück, auch wenn darüber ein zu kleines liegt", () => {
const stuecke = stapeln([50, 10, 1], y)
expect(stuecke.map((s) => [s.index, s.hoehe, s.oben])).toEqual([[0, 50, false], [1, 8, true], [2, 0, false]])
})
it("liefert für eine leere Säule nichts", () => {
expect(stapeln([0, 0], y)).toEqual([])
})
})
+60
View File
@@ -37,3 +37,63 @@ export function flaechenPfad(
schliessen()
return pfad
}
// --- Gestapelte Säulen (Modell-Nutzung, seit 25.09.2026) ------------------------------------------------
/** Eine Säule eines gestapelten Säulendiagramms: ein Tag, eine Stunde … mit einem Wert je Reihe. */
export interface Saeule {
schluessel: string
/** Beschriftung an der Achse, z. B. „Sa“ oder „15:00“. */
achse: string
/** Zweite Zeile an der Achse, z. B. „19.“ oder „heute“. */
achse2?: string
/** Ausführlich für Tooltip und Tabelle, z. B. „Samstag, 19.09.“. */
lang: string
/** Wonach die Beschriftungen ausgedünnt werden (die Stunde 0–23); ohne: die Nummer der Säule. */
raster?: number
/** Reihenname → Wert */
werte: Record<string, number>
}
/** Glatte Teilung der y-Achse für Zählwerte: höchstens fünf Striche in Schritten von 1, 2, 2,5 oder 5 × 10ⁿ,
* immer ganzzahlig; der oberste liegt auf oder knapp über dem Größten. */
export function zaehlAchse(max: number): number[] {
if (!Number.isFinite(max) || max <= 0) return [0, 1]
for (let e = 0; ; e++) {
for (const f of [1, 2, 2.5, 5]) {
const schritt = f * 10 ** e
if (!Number.isInteger(schritt)) continue
const anzahl = Math.ceil(max / schritt)
if (anzahl <= 4) return Array.from({ length: anzahl + 1 }, (_, i) => i * schritt)
}
}
}
const px = (v: number) => Number(v.toFixed(1))
/** Umriss einer Säule mit runden oberen Ecken — die Daten enden oben, unten steht sie gerade auf der Grundlinie. */
export function saeulenPfad(x: number, y: number, breite: number, hoehe: number, radius = 4): string {
const r = Math.max(0, Math.min(radius, breite / 2, hoehe))
return `M${px(x)},${px(y + hoehe)}V${px(y + r)}Q${px(x)},${px(y)} ${px(x + r)},${px(y)}` +
`H${px(x + breite - r)}Q${px(x + breite)},${px(y)} ${px(x + breite)},${px(y + r)}V${px(y + hoehe)}Z`
}
/**
* Die Stücke einer gestapelten Säule von unten nach oben (y wächst nach unten). Jedes Stück über einem anderen
* hält 2 px Abstand — die Lücke trennt die Farben, kein Rand. Nur Werte > 0; `oben` trägt das oberste sichtbare
* Stück, es bekommt die runden Ecken.
*/
export function stapeln(werte: number[], y: (v: number) => number, luecke = 2) {
const stuecke: { index: number; y: number; hoehe: number; oben: boolean }[] = []
let summe = 0
werte.forEach((wert, index) => {
if (!(wert > 0)) return
const unten = y(summe)
summe += wert
const oben = y(summe)
stuecke.push({ index, y: oben, hoehe: Math.max(0, unten - oben - (stuecke.length ? luecke : 0)), oben: false })
})
const oberstes = stuecke.filter((s) => s.hoehe >= 1).at(-1)
if (oberstes) oberstes.oben = true
return stuecke
}
+123
View File
@@ -0,0 +1,123 @@
import { anteilText, aufbereiten, dauerText, fehlerStufe, fehlerText } from "@/lib/nutzung"
import type { Nutzung } from "@/lib/typen"
import { nutzungBeispiel } from "@/test/nutzung"
const NQ = "NerdQuiz auf Arcane"
const LUCY = "Lucy und OpenChamber über MC2"
describe("Texte der Nutzung", () => {
it("schreibt Dauern so, wie man sie liest", () => {
expect(dauerText(0.388)).toBe("388 ms")
expect(dauerText(1.231)).toBe("1,2 s")
expect(dauerText(4.037)).toBe("4 s")
expect(dauerText(15.422)).toBe("15 s")
expect(dauerText(62)).toBe("1 min 2 s")
expect(dauerText(120)).toBe("2 min")
expect(dauerText(6517.5, true)).toBe("1 h 49 min")
expect(dauerText(2136.7, true)).toBe("36 min")
expect(dauerText(37.1, true)).toBe("37 s")
expect(dauerText(null)).toBe("–")
})
it("schreibt Anteile deutsch und nie als 0 %, wenn es welche gibt", () => {
expect(anteilText(4032 / 4364)).toBe("92 %")
expect(anteilText(325 / 4364)).toBe("7,4 %")
expect(anteilText(1 / 4364)).toBe("unter 0,1 %")
expect(anteilText(0)).toBe("0 %")
})
it("nennt Fehler in Worten, häufigste zuerst", () => {
expect(fehlerText({ "502": 1, "499": 2 })).toBe("2× vom Absender abgebrochen (499) · 1× Modell nicht erreichbar (502)")
expect(fehlerText({ "418": 1 })).toBe("1× Status 418")
expect(fehlerText(undefined)).toBe("")
})
it("färbt die Fehlerquote erst ab 1 % und sagt es dazu", () => {
expect(fehlerStufe(null)).toEqual({ art: "grau", wort: null })
expect(fehlerStufe(1 / 4364)).toEqual({ art: "grau", wort: null })
expect(fehlerStufe(0.02)).toEqual({ art: "bernstein", wort: "auffällig" })
expect(fehlerStufe(0.08)).toEqual({ art: "rot", wort: "hoch" })
})
})
describe("aufbereiten", () => {
it("rechnet Anteile an Anfragen und Rechenzeit je Absender", () => {
const bild = aufbereiten(nutzungBeispiel())
const [nq, lucy, pc] = bild.absender
expect(nq).toMatchObject({ name: NQ, farbe: "var(--reihe-1)", anfragen: 4032, fehler: 1, typisch: 1.231, p90: 3.305 })
expect(nq.anteil).toBeCloseTo(0.924, 3)
expect(nq.zeitAnteil).toBeCloseTo(0.75, 2)
expect(lucy.farbe).toBe("var(--reihe-2)")
expect(pc.farbe).toBe("var(--reihe-3)")
expect(bild.reihen.map((r) => r.name)).toEqual([NQ, LUCY, "NerdQuiz-Tests vom PC"])
expect(bild.gesamt).toMatchObject({ anfragen: 4364, fehler: 1, typisch: 1.27, p90: 4.037 })
})
it("macht aus den Tagen sieben Säulen, heute als letzte", () => {
const bild = aufbereiten(nutzungBeispiel())
expect(bild.tage.map((t) => t.achse)).toEqual(["Sa", "So", "Mo", "Di", "Mi", "Do", "Fr"])
expect(bild.tage.map((t) => t.achse2)).toEqual(["19.", "20.", "21.", "22.", "23.", "24.", "heute"])
expect(bild.tage[0]).toMatchObject({ lang: "Samstag, 19.09.", werte: { [NQ]: 535, [LUCY]: 15 } })
expect(bild.tage[6].lang).toBe("Freitag, 25.09. (heute bis 13:03)")
expect(bild.heute).toBe(444)
expect(bild.gestern).toBe(774)
expect(bild.schnitt).toBeCloseTo(3920 / 6)
expect(bild.zeitraum).toBe("seit Sa 19.09.")
expect(bild.stand).toBe("13:03")
})
it("legt die Stunden von gestern nach heute, die laufende rechts", () => {
const bild = aufbereiten(nutzungBeispiel())
expect(bild.stunden).toHaveLength(24)
expect(bild.stunden[0]).toMatchObject({ achse: "14:00", raster: 14, lang: "Gestern 14:00–15:00" })
expect(bild.stunden[13]).toMatchObject({ achse: "03:00", lang: "Heute 03:00–04:00", werte: { [NQ]: 139 } })
expect(bild.stunden[23].lang).toBe("Heute 13:00 bis jetzt")
})
it("lässt die Farbe beim Absender, auch wenn sich die Rangfolge dreht", () => {
const n = nutzungBeispiel()
n.absender = [{ ...n.absender[1], anfragen: 5000 }, n.absender[0], n.absender[2]]
const bild = aufbereiten(n)
expect(bild.absender.map((a) => [a.name, a.farbe])).toEqual([
[LUCY, "var(--reihe-2)"], [NQ, "var(--reihe-1)"], ["NerdQuiz-Tests vom PC", "var(--reihe-3)"],
])
// Gestapelt bleibt NerdQuiz unten.
expect(bild.reihen.map((r) => r.name)).toEqual([NQ, LUCY, "NerdQuiz-Tests vom PC"])
})
it("fasst Absender ab der vierten Farbe als „Weitere“ zusammen", () => {
const n = nutzungBeispiel()
n.absender.push({ name: "10.0.0.9", anfragen: 3, reihe: 3, zuletzt: "2026-09-25T09:00:00+02:00" })
n.je_tag["2026-09-25"] = { ...n.je_tag["2026-09-25"], "10.0.0.9": 3 }
const bild = aufbereiten(n)
expect(bild.absender[3]).toMatchObject({ name: "10.0.0.9", reihe: "Weitere", farbe: "var(--reihe-weitere)" })
expect(bild.reihen.at(-1)).toEqual({ name: "Weitere", farbe: "var(--reihe-weitere)" })
expect(bild.tage[6].werte.Weitere).toBe(3)
})
it("kommt mit dem Stand der Box vor dem 25.09. zurecht", () => {
const alt: Nutzung = {
tage: 7,
absender: [{ name: NQ, anfragen: 10 }, { name: LUCY, anfragen: 5 }],
je_tag: { "2026-09-24": { [NQ]: 6 }, "2026-09-25": { [NQ]: 4, [LUCY]: 5 } },
letzte_24h: Array.from({ length: 24 }, (_, h): Nutzung["letzte_24h"][number] => ({
stunde: String(h).padStart(2, "0"),
je_absender: h === 3 ? { [NQ]: 4 } : h === 13 ? { [LUCY]: 5 } : h === 14 ? { [NQ]: 6 } : {},
})),
zuletzt_geladen: { "Qwen3-Reranker-0.6B": "2026-09-24T14:52:19+02:00", "Qwen3.6-35B-A3B": "2026-09-25T11:25:46+02:00" },
}
const bild = aufbereiten(alt, new Date("2026-09-25T13:10:00+02:00"))
expect(bild.absender[0]).toMatchObject({ farbe: "var(--reihe-1)", typisch: null, fehler: null, zeitAnteil: null })
expect(bild.gesamt).toMatchObject({ anfragen: 15, fehler: null, typisch: null })
expect(bild.stunden[0]).toMatchObject({ achse: "14:00", werte: { [NQ]: 6 } })
expect(bild.stunden[23]).toMatchObject({ achse: "13:00", werte: { [LUCY]: 5 } })
expect(bild.modelle).toEqual([
{ modell: "Qwen3.6-35B-A3B", anzahl: null, zeiten: ["2026-09-25T11:25:46+02:00"] },
{ modell: "Qwen3-Reranker-0.6B", anzahl: null, zeiten: ["2026-09-24T14:52:19+02:00"] },
])
expect(bild.zeitraum).toBe("letzte 7 Tage")
expect(bild.stand).toBeNull()
expect(bild.heute).toBe(9)
expect(bild.gestern).toBe(6)
})
})
+264
View File
@@ -0,0 +1,264 @@
import type { Saeule } from "@/lib/diagramm"
import type { Nutzung } from "@/lib/typen"
// Aufbereitung von GET /api/modelle/nutzung für das Panel „Wer nutzt die Modelle“ — ohne DOM, damit sie sich
// testen lässt. Ältere Stände der Box liefern nur Anzahlen (vor dem 25.09.2026); dann bleiben Antwortzeiten
// und Fehler leer („–“), alles andere geht wie gewohnt.
const ZONE = "Europe/Berlin"
/** Farben der Absender in fester Reihe (Tokens in index.css). Ab der vierten fasst „Weitere“ sie grau zusammen. */
export const FARBEN = ["var(--reihe-1)", "var(--reihe-2)", "var(--reihe-3)"] as const
export const WEITERE = { name: "Weitere", farbe: "var(--reihe-weitere)" } as const
const ganzzahl = new Intl.NumberFormat("de-DE")
const eineStelle = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 })
const tagIso = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
const stundeTeile = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", hourCycle: "h23", timeZone: ZONE })
// Kalendertage kommen als „2026-09-19“; mittags in UTC gelesen, damit keine Zone den Tag verschiebt.
const wochentagKurz = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: "UTC" })
const wochentagLang = new Intl.DateTimeFormat("de-DE", { weekday: "long", timeZone: "UTC" })
/** Deutsche Zahl mit Tausenderpunkt: „4.364“. */
export const anzahlText = (n: number) => ganzzahl.format(n)
/** Anteil (0…1) in Prozent: „92 %“, „7,4 %“, „unter 0,1 %“. */
export function anteilText(anteil: number): string {
if (!(anteil > 0)) return "0 %"
const p = anteil * 100
if (p < 0.1) return "unter 0,1 %"
return `${p < 10 ? eineStelle.format(p) : ganzzahl.format(Math.round(p))} %`
}
/** Sekunden lesbar: „388 ms“, „1,2 s“, „15 s“, „1 min 2 s“, „1 h 49 min“. `grob` lässt die Sekunden ab einer
* Minute weg (für Summen wie die Rechenzeit). */
export function dauerText(s: number | null | undefined, grob = false): string {
if (s == null || !Number.isFinite(s) || s < 0) return "–"
if (Math.round(s * 1000) < 1000) return `${Math.round(s * 1000)} ms`
if (s < 9.95) return `${eineStelle.format(s)} s`
const sek = Math.round(s)
if (sek < 60) return `${sek} s`
if (sek < 3600 && !grob) return sek % 60 ? `${Math.floor(sek / 60)} min ${sek % 60} s` : `${sek / 60} min`
const min = Math.round(s / 60)
if (min < 60) return `${min} min`
return min % 60 ? `${Math.floor(min / 60)} h ${min % 60} min` : `${min / 60} h`
}
/** Was ein Status von llama-swap heißt — die häufigen in Worten, der Rest als Zahl. */
const STATUS_TEXT: Record<string, string> = {
"400": "fehlerhafte Anfrage",
"404": "nicht gefunden",
"408": "Zeitüberschreitung",
"413": "Anfrage zu groß",
"429": "zu viele Anfragen",
"499": "vom Absender abgebrochen",
"500": "Fehler beim Modell",
"502": "Modell nicht erreichbar",
"503": "gerade nicht verfügbar",
"504": "Zeitüberschreitung",
}
/** „1× Modell nicht erreichbar (502) · 1× vom Absender abgebrochen (499)“ — häufigste zuerst. */
export function fehlerText(codes: Record<string, number> | undefined): string {
return Object.entries(codes ?? {})
.sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
.map(([code, n]) => (STATUS_TEXT[code] ? `${anzahlText(n)}× ${STATUS_TEXT[code]} (${code})` : `${anzahlText(n)}× Status ${code}`))
.join(" · ")
}
/** Wie auffällig eine Fehlerquote ist: ab 1 % bernstein, ab 5 % rot — mit einem Wort dazu, nie nur Farbe. */
export function fehlerStufe(quote: number | null): { art: "grau" | "bernstein" | "rot"; wort: string | null } {
if (quote === null || quote < 0.01) return { art: "grau", wort: null }
return quote >= 0.05 ? { art: "rot", wort: "hoch" } : { art: "bernstein", wort: "auffällig" }
}
export interface AbsenderZeile {
name: string
/** CSS-Farbe der Marke; der Text bleibt in Textfarbe. */
farbe: string
/** Die Diagramm-Reihe, in der der Absender steckt: sein Name oder „Weitere“. */
reihe: string
anfragen: number
/** Anteil an allen Anfragen, 0…1 */
anteil: number
fehler: number | null
fehlerCodes: Record<string, number>
/** Sekunden */
typisch: number | null
p90: number | null
summe: number | null
/** Anteil an der Rechenzeit, 0…1 */
zeitAnteil: number | null
/** ISO-Zeit */
zuletzt: string | null
}
export interface DiagrammReihe {
name: string
farbe: string
}
export interface ModellLadungen {
modell: string
/** null bei älteren Ständen der Box: Die kannten nur den letzten Ladezeitpunkt. */
anzahl: number | null
/** ISO-Zeiten, neueste zuerst */
zeiten: string[]
}
export interface NutzungsBild {
absender: AbsenderZeile[]
/** Reihen der Diagramme in Stapelfolge (unten → oben), feste Farben. */
reihen: DiagrammReihe[]
tage: Saeule[]
stunden: Saeule[]
gesamt: {
anfragen: number
fehler: number | null
fehlerCodes: Record<string, number>
typisch: number | null
p90: number | null
summe: number | null
}
heute: number
gestern: number
/** Durchschnitt der vollen Tage (ohne heute) oder null, wenn es keinen gibt. */
schnitt: number | null
/** „seit Sa 19.09.“ bzw. „letzte 7 Tage“ */
zeitraum: string
/** Uhrzeit der Auswertung, z. B. „13:03“ (die Box rechnet alle 10 Minuten neu). */
stand: string | null
modelle: ModellLadungen[]
}
const zwei = (h: number) => String(h).padStart(2, "0")
const summe = (werte: Record<string, number> | undefined) => Object.values(werte ?? {}).reduce((s, v) => s + v, 0)
const kurzerTag = (d: Date) => wochentagKurz.format(d).replace(".", "")
/** Stunde 0–23 in Box-Zeit. */
function stundeIn(d: Date): number {
return Number(stundeTeile.formatToParts(d).find((t) => t.type === "hour")?.value ?? 0) % 24
}
/** „Samstag, 19.09.“ aus „2026-09-19“. */
function tagLang(tag: string): string {
const [, monat, tagImMonat] = tag.split("-")
return `${wochentagLang.format(new Date(`${tag}T12:00:00Z`))}, ${tagImMonat}.${monat}.`
}
/** Alles, was das Panel zeigt, aus der Antwort der Box. `jetzt` gilt nur, wenn die Box keinen Stand mitschickt. */
export function aufbereiten(n: Nutzung, jetzt = new Date()): NutzungsBild {
const stand = n.stand ? new Date(n.stand) : jetzt
const heute = tagIso.format(stand)
// Über den Kalender statt „minus 24 Stunden“ — sonst verrutscht gestern in der Nacht nach der Zeitumstellung.
const gestern = new Date(Date.parse(`${heute}T12:00:00Z`) - 86_400_000).toISOString().slice(0, 10)
const anfragen = n.gesamt?.anfragen ?? n.absender.reduce((s, a) => s + a.anfragen, 0)
const zeitGesamt = n.gesamt?.dauer_summe_s ?? null
// Die Farbe folgt dem Absender (feste Reihe der Box), nicht seinem Rang. Ältere Stände: nach Rang.
const platz = new Map(n.absender.map((a, i) => [a.name, a.reihe ?? i]))
const absender: AbsenderZeile[] = n.absender.map((a) => {
const p = platz.get(a.name) ?? FARBEN.length
const eigene = p < FARBEN.length
return {
name: a.name,
farbe: eigene ? FARBEN[p] : WEITERE.farbe,
reihe: eigene ? a.name : WEITERE.name,
anfragen: a.anfragen,
anteil: anfragen ? a.anfragen / anfragen : 0,
fehler: a.fehler ?? null,
fehlerCodes: a.fehler_codes ?? {},
typisch: a.dauer_typisch_s ?? null,
p90: a.dauer_p90_s ?? null,
summe: a.dauer_summe_s ?? null,
zeitAnteil: a.dauer_summe_s != null && zeitGesamt ? a.dauer_summe_s / zeitGesamt : null,
zuletzt: a.zuletzt ?? null,
}
})
const reiheVon = new Map(absender.map((a) => [a.name, a.reihe]))
const reihen: DiagrammReihe[] = []
for (const a of [...absender].sort((x, y) => (platz.get(x.name) ?? 99) - (platz.get(y.name) ?? 99))) {
if (!reihen.some((r) => r.name === a.reihe)) reihen.push({ name: a.reihe, farbe: a.farbe })
}
/** Absender → Reihe; wer in der Liste fehlt, landet bei „Weitere“. */
const werte = (je: Record<string, number> | undefined) => {
const w: Record<string, number> = {}
for (const [name, anzahl] of Object.entries(je ?? {})) {
const r = reiheVon.get(name) ?? WEITERE.name
w[r] = (w[r] ?? 0) + anzahl
if (r === WEITERE.name && !reihen.some((x) => x.name === r)) reihen.push({ ...WEITERE })
}
return w
}
const tagListe = Object.keys(n.je_tag ?? {}).sort()
const tage: Saeule[] = tagListe.map((tag) => {
const istHeute = tag === heute
return {
schluessel: tag,
achse: kurzerTag(new Date(`${tag}T12:00:00Z`)),
achse2: istHeute ? "heute" : `${Number(tag.slice(8))}.`,
lang: `${tagLang(tag)}${istHeute ? ` (heute bis ${uhr.format(stand)})` : ""}`,
werte: werte(n.je_tag[tag]),
}
})
let stunden: Saeule[]
if (n.stunden?.length) {
stunden = n.stunden.map((s, i, alle) => {
const d = new Date(s.beginn)
const h = stundeIn(d)
const tag = tagIso.format(d)
const tagText = tag === heute ? "Heute" : tag === gestern ? "Gestern" : tagLang(tag)
const bis = i === alle.length - 1 ? " bis jetzt" : `–${zwei((h + 1) % 24)}:00`
return {
schluessel: s.beginn, achse: `${zwei(h)}:00`, raster: h,
lang: `${tagText} ${zwei(h)}:00${bis}`,
werte: werte(s.je_absender),
}
})
} else {
// Älterer Stand der Box: nur Stunden des Tages (00–23). Die laufende Stunde kommt nach rechts.
const jetztStunde = stundeIn(stand)
const liste = n.letzte_24h ?? []
stunden = [...liste.slice(jetztStunde + 1), ...liste.slice(0, jetztStunde + 1)].map((s) => {
const h = Number(s.stunde)
return {
schluessel: s.stunde, achse: `${zwei(h)}:00`, raster: h,
lang: `${zwei(h)}:00–${zwei((h + 1) % 24)}:00`, werte: werte(s.je_absender),
}
})
}
const volle = tagListe.filter((t) => t !== heute)
const seit = n.seit ? tagIso.format(new Date(n.seit)) : null // Kalendertag in Box-Zeit, „2026-09-19“
const modelle: ModellLadungen[] = n.ladevorgaenge ??
Object.entries(n.zuletzt_geladen ?? {})
.sort(([, a], [, b]) => Date.parse(b) - Date.parse(a))
.map(([modell, zeit]) => ({ modell, anzahl: null, zeiten: [zeit] }))
return {
absender,
reihen,
tage,
stunden,
gesamt: {
anfragen,
fehler: n.gesamt?.fehler ?? null,
fehlerCodes: n.gesamt?.fehler_codes ?? {},
typisch: n.gesamt?.dauer_typisch_s ?? null,
p90: n.gesamt?.dauer_p90_s ?? null,
summe: zeitGesamt,
},
heute: summe(n.je_tag?.[heute]),
gestern: summe(n.je_tag?.[gestern]),
schnitt: volle.length ? volle.reduce((s, t) => s + summe(n.je_tag[t]), 0) / volle.length : null,
zeitraum: seit
? `seit ${kurzerTag(new Date(`${seit}T12:00:00Z`))} ${seit.slice(8)}.${seit.slice(5, 7)}.`
: `letzte ${n.tage} Tage`,
stand: n.stand ? uhr.format(stand) : null,
modelle,
}
}
+34 -1
View File
@@ -135,12 +135,45 @@ export interface UpdateDetails {
action_text?: string
}
/** Antwortzeiten und Fehler aus dem Journal von llama-swap (seit 25.09.2026; ältere Stände der Box liefern sie nicht). */
export interface NutzungKennwerte {
/** Anfragen mit einem Status außerhalb von 2xx, je Code („502“ → 1). */
fehler?: number
fehler_codes?: Record<string, number>
/** Median der erfolgreichen Antworten in Sekunden. */
dauer_typisch_s?: number | null
/** 9 von 10 erfolgreichen Antworten dauerten höchstens so lange (Sekunden). */
dauer_p90_s?: number | null
/** Summe aller Antwortzeiten in Sekunden („Rechenzeit“; gleichzeitige Anfragen zählen einzeln). */
dauer_summe_s?: number
}
export interface NutzungAbsender extends NutzungKennwerte {
name: string
anfragen: number
/** Feste Reihenfolge aus der Namensliste der Box: bestimmt die Farbe, nicht der Rang. */
reihe?: number
/** ISO-Zeit der letzten Anfrage */
zuletzt?: string
}
export interface Nutzung {
tage: number
absender: { name: string; anfragen: number }[]
absender: NutzungAbsender[]
/** ISO-Datum → Absender → Anfragen; seit 25.09.2026 genau die letzten `tage` Kalendertage, auch leere. */
je_tag: Record<string, Record<string, number>>
/** Stunden des Tages (00–23) der letzten 24 h — nicht chronologisch; dafür gibt es `stunden`. */
letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
zuletzt_geladen: Record<string, string>
// --- seit 25.09.2026 ---
/** Beginn des Fensters (ISO, Mitternacht vor `tage` − 1 Tagen) und Zeitpunkt der Auswertung. */
seit?: string
stand?: string
gesamt?: NutzungKennwerte & { anfragen: number }
/** Die letzten 24 Stunden chronologisch; die letzte ist die laufende. */
stunden?: { beginn: string; je_absender: Record<string, number> }[]
/** Je Modell, wie oft es sich nach dem Laden bereit meldete — neueste zuerst. */
ladevorgaenge?: { modell: string; anzahl: number; zeiten: string[] }[]
}
export interface Modell {