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:
co-authored by
Claude Opus 5.5
parent
7834de9592
commit
dfe0971180
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user