Merge branch 'worktree-agent-a7ad50f83f2d4236b' into wartung/helfer-nutzung-dienste
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { Saeule } from "@/lib/diagramm"
|
||||
import { Saeulen } from "./Saeulen"
|
||||
|
||||
const REIHEN = [{ name: "A", farbe: "var(--reihe-1)" }, { name: "B", farbe: "var(--reihe-2)" }, { name: "C", farbe: "var(--reihe-3)" }]
|
||||
|
||||
const stunden = Array.from({ length: 24 }, (_, i): Saeule => {
|
||||
const h = (15 + i) % 24
|
||||
return {
|
||||
schluessel: String(i), achse: `${String(h).padStart(2, "0")}:00`, raster: h, lang: `${h} Uhr`,
|
||||
werte: h === 3 ? { A: 139 } : { A: 5, B: i % 2 },
|
||||
}
|
||||
})
|
||||
|
||||
/** Der Behälter meldet eine feste Breite, wie ein Handy (jsdom kennt keinen ResizeObserver). */
|
||||
function breiteVortaeuschen(px: number) {
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(private melden: (e: { contentRect: { width: number } }[]) => void) {}
|
||||
observe() { this.melden([{ contentRect: { width: px } }]) }
|
||||
disconnect() {}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe("Saeulen", () => {
|
||||
it("dünnt die Stundenbeschriftung aus und nennt nur die höchste Summe", () => {
|
||||
const { container } = render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" summen="hoechste" />)
|
||||
const texte = [...container.querySelectorAll("svg text")].map((t) => t.textContent)
|
||||
expect(texte).toEqual(expect.arrayContaining(["15:00", "18:00", "00:00", "03:00", "139"]))
|
||||
expect(texte).not.toContain("16:00")
|
||||
expect(texte.filter((t) => t === "6")).toHaveLength(0) // die übrigen Summen stehen nur in Anzeige und Tabelle
|
||||
})
|
||||
|
||||
it("zeigt in der Legende nur Reihen mit Werten", () => {
|
||||
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
|
||||
expect(within(screen.getByRole("list", { name: "Legende" })).getAllByRole("listitem").map((l) => l.textContent))
|
||||
.toEqual(["A", "B"])
|
||||
})
|
||||
|
||||
it("stellt die Anzeige auf dem Handy unter das Diagramm statt darüber", () => {
|
||||
breiteVortaeuschen(300)
|
||||
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
|
||||
const bild = screen.getByRole("img", { name: "Je Stunde" })
|
||||
expect(bild).toHaveAttribute("width", "300")
|
||||
fireEvent.focus(bild)
|
||||
const anzeige = screen.getByRole("status")
|
||||
expect(anzeige).not.toHaveClass("absolute")
|
||||
expect(anzeige).toHaveTextContent("14 Uhr")
|
||||
expect(bild.compareDocumentPosition(anzeige) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it("sagt es, wenn es nichts zu zeigen gibt", () => {
|
||||
render(<Saeulen saeulen={[{ schluessel: "x", achse: "Sa", lang: "Samstag", werte: {} }]} reihen={REIHEN} titel="Leer" />)
|
||||
expect(screen.getByText("Keine Anfragen in diesem Zeitraum")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("list", { name: "Legende" })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type KeyboardEvent, type PointerEvent, useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { useBreite } from "@/lib/breite"
|
||||
import { type Saeule, saeulenPfad, stapeln, zaehlAchse } from "@/lib/diagramm"
|
||||
|
||||
export interface SaeulenReihe {
|
||||
name: string
|
||||
/** CSS-Farbe der Säulenstücke, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
|
||||
farbe: string
|
||||
}
|
||||
|
||||
const zahl = new Intl.NumberFormat("de-DE")
|
||||
/** Breite der Anzeige in px (w-60) — fest, damit sie sich ohne Messen im Diagramm halten lässt. */
|
||||
const TIPP_BREITE = 240
|
||||
/** Säulen bleiben schmal; der Rest des Platzes ist Luft. */
|
||||
const SAEULE_MAX = 24
|
||||
|
||||
const SCHRITT: Record<string, (i: number, n: number) => number> = {
|
||||
ArrowLeft: (i) => Math.max(0, i - 1),
|
||||
ArrowRight: (i, n) => Math.min(n - 1, i + 1),
|
||||
Home: () => 0,
|
||||
End: (_, n) => n - 1,
|
||||
}
|
||||
|
||||
/** Die Werte einer Säule: Zahl vorn, Name dahinter, die Farbe nur als kurzer Strich. */
|
||||
function Anzeige({ saeule, reihen, summe, className, links }: {
|
||||
saeule: Saeule
|
||||
reihen: SaeulenReihe[]
|
||||
summe: number
|
||||
className?: string
|
||||
links?: number
|
||||
}) {
|
||||
return (
|
||||
<div role="status" className={cn("flex flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2", className)}
|
||||
style={links === undefined ? undefined : { left: links }}>
|
||||
<span className="ziffern text-xs text-text-3">{saeule.lang}</span>
|
||||
{reihen.map((r) => (
|
||||
<span key={r.name} className="flex items-baseline gap-2 text-sm">
|
||||
<span aria-hidden className="h-0.5 w-3 shrink-0 self-center rounded-full" style={{ background: r.farbe }} />
|
||||
<span className="ziffern font-semibold text-foreground">{zahl.format(saeule.werte[r.name] ?? 0)}</span>
|
||||
<span className="min-w-0 text-text-3">{r.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{reihen.length > 1 && (
|
||||
<span className="flex items-baseline gap-2 border-t border-linie pt-1 text-sm">
|
||||
<span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span>
|
||||
<span className="text-text-3">zusammen</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestapelte Säulen für Zählwerte (Anfragen je Tag, je Stunde): eine Säule je Zeitraum, darin ein Stück je Reihe in
|
||||
* fester Farbe, 2 px Luft zwischen den Stücken. Zeiger, Finger oder Pfeiltasten wählen eine Säule; die Anzeige nennt
|
||||
* dann alle Reihen und die Summe. Darunter stehen die Werte zum Aufklappen als Tabelle — die Anzeige ist nie der
|
||||
* einzige Weg zu einer Zahl.
|
||||
*/
|
||||
export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200, spalte = "Zeitraum" }: {
|
||||
saeulen: Saeule[]
|
||||
/** In Stapelfolge, unten zuerst. */
|
||||
reihen: SaeulenReihe[]
|
||||
titel: string
|
||||
/** Summe über jeder Säule (bei wenigen Säulen) oder nur über der höchsten. */
|
||||
summen?: "alle" | "hoechste"
|
||||
hoehe?: number
|
||||
/** Kopf der ersten Tabellenspalte, z. B. „Tag“ oder „Stunde“. */
|
||||
spalte?: string
|
||||
}) {
|
||||
const [ref, breite] = useBreite()
|
||||
const [index, setIndex] = useState<number | null>(null)
|
||||
const n = saeulen.length
|
||||
// Nur Reihen, die im Zeitraum vorkommen — sonst stünden Farben ohne Säulen in der Legende.
|
||||
const sichtbar = reihen.filter((r) => saeulen.some((s) => (s.werte[r.name] ?? 0) > 0))
|
||||
const summenListe = saeulen.map((s) => sichtbar.reduce((a, r) => a + (s.werte[r.name] ?? 0), 0))
|
||||
const max = Math.max(0, ...summenListe)
|
||||
const ticks = zaehlAchse(max)
|
||||
const oben = ticks[ticks.length - 1]
|
||||
const rand = {
|
||||
links: Math.max(28, zahl.format(oben).length * 7 + 12),
|
||||
rechts: 4,
|
||||
oben: 20,
|
||||
unten: saeulen.some((s) => s.achse2) ? 34 : 22,
|
||||
}
|
||||
const innenB = Math.max(1, breite - rand.links - rand.rechts)
|
||||
const innenH = hoehe - rand.oben - rand.unten
|
||||
const band = innenB / Math.max(1, n)
|
||||
const dicke = Math.min(SAEULE_MAX, Math.max(3, band * 0.72))
|
||||
const y = (v: number) => rand.oben + innenH - (v / oben) * innenH
|
||||
const grund = rand.oben + innenH
|
||||
const mitte = (i: number) => rand.links + (i + 0.5) * band
|
||||
// Beschriftung ausdünnen, bis sie sich nicht berührt; bei Stunden landet sie so auf 0, 3, 6 … bzw. 0, 6, 12, 18 Uhr.
|
||||
const noetig = Math.max(1, ...saeulen.map((s) => s.achse.length)) * 6.6 + 8
|
||||
const jede = [1, 3, 6, 12].find((k) => k * band >= noetig) ?? 24
|
||||
const hoechste = summenListe.indexOf(max)
|
||||
const eng = band < 34
|
||||
|
||||
function zeigen(e: PointerEvent<SVGRectElement>) {
|
||||
if (n === 0) return
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
setIndex(Math.min(n - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * n))))
|
||||
}
|
||||
function taste(e: KeyboardEvent<SVGSVGElement>) {
|
||||
const schritt = SCHRITT[e.key]
|
||||
if (n === 0 || !schritt) return
|
||||
e.preventDefault()
|
||||
setIndex((i) => schritt(i ?? n - 1, n))
|
||||
}
|
||||
|
||||
const aktiv = index !== null ? saeulen[index] : undefined
|
||||
// Ab Tablet-Breite schwebt die Anzeige neben der Säule, auf der Seite mit mehr Platz und immer ganz im Diagramm.
|
||||
// Auf dem Handy bedeckte sie das halbe Diagramm — dort steht sie nach dem Antippen darunter.
|
||||
const schwebend = breite >= 360
|
||||
const tippLinks = index === null ? 0 : Math.max(0, Math.min(breite - TIPP_BREITE,
|
||||
mitte(index) + 12 + TIPP_BREITE > breite ? mitte(index) - 12 - TIPP_BREITE : mitte(index) + 12))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sichtbar.length > 1 && (
|
||||
<ul className="m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm text-text-2" aria-label="Legende">
|
||||
{sichtbar.map((r) => (
|
||||
<li key={r.name} className="flex items-center gap-2">
|
||||
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: r.farbe }} />
|
||||
{r.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={ref} className="relative w-full">
|
||||
<svg
|
||||
width={breite}
|
||||
height={hoehe}
|
||||
viewBox={`0 0 ${breite} ${hoehe}`}
|
||||
role="img"
|
||||
aria-label={titel}
|
||||
tabIndex={0}
|
||||
onKeyDown={taste}
|
||||
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
|
||||
onBlur={() => setIndex(null)}
|
||||
className="block h-auto max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
|
||||
>
|
||||
{aktiv && index !== null && (
|
||||
<rect x={rand.links + index * band} y={rand.oben - 4} width={band} height={innenH + 4} rx={4} fill="var(--erhaben)" />
|
||||
)}
|
||||
{ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={rand.links} x2={breite - rand.rechts} y1={y(v)} y2={y(v)} strokeWidth={1}
|
||||
stroke={v === 0 ? "var(--linie-stark)" : "var(--linie)"} />
|
||||
<text x={rand.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
|
||||
{zahl.format(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{saeulen.map((s, i) => {
|
||||
const x = mitte(i) - dicke / 2
|
||||
return (
|
||||
<g key={s.schluessel}>
|
||||
{stapeln(sichtbar.map((r) => s.werte[r.name] ?? 0), y).filter((t) => t.hoehe > 0).map((t) =>
|
||||
t.oben ? (
|
||||
<path key={t.index} d={saeulenPfad(x, t.y, dicke, t.hoehe)} fill={sichtbar[t.index].farbe} />
|
||||
) : (
|
||||
<rect key={t.index} x={x} y={t.y} width={dicke} height={t.hoehe} fill={sichtbar[t.index].farbe} />
|
||||
))}
|
||||
{summenListe[i] > 0 && (summen === "alle" || i === hoechste) && (
|
||||
<text x={mitte(i)} y={y(summenListe[i]) - 6} textAnchor="middle"
|
||||
className={cn("fill-text-2 font-mono", eng ? "text-[10px]" : "text-[11px]")}>
|
||||
{zahl.format(summenListe[i])}
|
||||
</text>
|
||||
)}
|
||||
{(s.raster ?? i) % jede === 0 && (
|
||||
<text x={mitte(i)} y={grund + 15} textAnchor="middle" className="fill-text-3 font-mono text-[11px]">
|
||||
{s.achse}
|
||||
{s.achse2 && <tspan x={mitte(i)} dy={13} className="text-[10px]">{s.achse2}</tspan>}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{max === 0 && (
|
||||
<text x={rand.links + innenB / 2} y={rand.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
|
||||
Keine Anfragen in diesem Zeitraum
|
||||
</text>
|
||||
)}
|
||||
{/* Trefferfläche: das ganze Band einer Säule samt Beschriftung — niemand muss ein schmales Stück treffen. */}
|
||||
<rect x={rand.links} y={0} width={innenB} height={hoehe} fill="transparent"
|
||||
onPointerMove={zeigen} onPointerDown={zeigen}
|
||||
onPointerLeave={(e) => { if (e.pointerType !== "touch") setIndex(null) }} />
|
||||
</svg>
|
||||
{aktiv && index !== null && schwebend && (
|
||||
<Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} links={tippLinks}
|
||||
className="pointer-events-none absolute top-1 z-10 w-60 shadow-lg" />
|
||||
)}
|
||||
</div>
|
||||
{aktiv && index !== null && !schwebend && <Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} />}
|
||||
<details className="text-sm">
|
||||
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="text-text-3">
|
||||
<th scope="col" className="py-1 pr-3 text-left font-normal">{spalte}</th>
|
||||
{sichtbar.map((r) => (
|
||||
<th key={r.name} scope="col" className="py-1 pr-3 text-right font-normal [overflow-wrap:anywhere]">{r.name}</th>
|
||||
))}
|
||||
{sichtbar.length > 1 && <th scope="col" className="py-1 text-right font-normal">Zusammen</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{saeulen.map((s, i) => (
|
||||
<tr key={s.schluessel} className="border-t border-linie">
|
||||
<th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th>
|
||||
{sichtbar.map((r) => (
|
||||
<td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td>
|
||||
))}
|
||||
{sichtbar.length > 1 && <td className="ziffern py-1 text-right text-foreground">{zahl.format(summenListe[i])}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
|
||||
import { type KeyboardEvent, type PointerEvent, useState } from "react"
|
||||
import { useBreite } from "@/lib/breite"
|
||||
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
|
||||
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
|
||||
import type { Messpunkt } from "@/lib/typen"
|
||||
@@ -12,20 +13,6 @@ export interface Reihe {
|
||||
|
||||
const RAND = { rechts: 12, oben: 10, unten: 24 }
|
||||
|
||||
/** Breite des Behälters; ohne ResizeObserver (Tests) bleibt es beim Standard. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitverlauf einer oder zweier Messreihen: dünne Linien, bei einer Reihe eine zarte Fläche, haarfeines Raster.
|
||||
* Ein Fadenkreuz folgt Zeiger oder Pfeiltasten und zeigt alle Reihen am selben Zeitpunkt. Darunter liegen die
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
erste Reihe Blau, zweite Magenta. Zustandsfarben bleiben für Zustände. */
|
||||
--reihe-1: #3987e5;
|
||||
--reihe-2: #d55181;
|
||||
/* Dritte Reihe Gelb (Modell-Nutzung, 25.09.2026): mit Blau und Magenta auch bei Rot-Grün-Schwäche jedes Paar
|
||||
sicher getrennt (ΔE ≥ 13 in OKLab, Kontrast ≥ 4,5:1). Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6),
|
||||
Violett gleicht der KI-Box-Farbe und neben Blau (ΔE 1,9). Ab der vierten Reihe fasst „Weitere“ grau zusammen —
|
||||
hell genug, dass es sich auch bei Farbfehlsichtigkeit von Magenta abhebt (ΔE 8,6). */
|
||||
--reihe-3: #c98500;
|
||||
--reihe-weitere: #9aa3ad;
|
||||
--aus-grund: #171b1f;
|
||||
--aus-rand: #2e343a;
|
||||
--aus-text: #9aa3ad;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Nutzung } from "@/lib/typen"
|
||||
|
||||
// Testdaten für „Wer nutzt die Modelle“: die echten Zahlen der Box vom 25.09.2026, 13:03 (aus dem Journal von
|
||||
// llama-swap), bei den Stunden und Ladezeiten gekürzt.
|
||||
|
||||
const NQ = "NerdQuiz auf Arcane"
|
||||
const LUCY = "Lucy und OpenChamber über MC2"
|
||||
const PC = "NerdQuiz-Tests vom PC"
|
||||
|
||||
/** 24 Stunden ab gestern 14:00; NerdQuiz nachts viel, Lucy tagsüber etwas. */
|
||||
function stunden(): NonNullable<Nutzung["stunden"]> {
|
||||
const beginn = Date.parse("2026-09-24T14:00:00+02:00")
|
||||
return Array.from({ length: 24 }, (_, i) => {
|
||||
const d = new Date(beginn + i * 3_600_000)
|
||||
const h = (14 + i) % 24
|
||||
const je: Record<string, number> = {}
|
||||
if (h === 3) je[NQ] = 139
|
||||
else if (h === 4) je[NQ] = 81
|
||||
else if (h !== 1 && h !== 2 && h !== 5) je[NQ] = 5
|
||||
if (h === 10) je[LUCY] = 8
|
||||
if (h === 11) je[LUCY] = 12
|
||||
// ISO mit Versatz wie von der Box (im September gilt +02:00)
|
||||
const iso = new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(d).replace(" ", "T")
|
||||
return { beginn: `${iso}+02:00`, je_absender: je }
|
||||
})
|
||||
}
|
||||
|
||||
export function nutzungBeispiel(): Nutzung {
|
||||
return {
|
||||
tage: 7,
|
||||
seit: "2026-09-19T00:00:00+02:00",
|
||||
stand: "2026-09-25T13:03:59+02:00",
|
||||
absender: [
|
||||
{ name: NQ, anfragen: 4032, reihe: 0, fehler: 1, fehler_codes: { "502": 1 }, dauer_typisch_s: 1.231,
|
||||
dauer_p90_s: 3.305, dauer_summe_s: 6517.5, zuletzt: "2026-09-25T12:55:04+02:00" },
|
||||
{ name: LUCY, anfragen: 325, reihe: 1, fehler: 0, fehler_codes: {}, dauer_typisch_s: 4.469,
|
||||
dauer_p90_s: 15.422, dauer_summe_s: 2136.7, zuletzt: "2026-09-25T12:53:24+02:00" },
|
||||
{ name: PC, anfragen: 7, reihe: 2, fehler: 0, fehler_codes: {}, dauer_typisch_s: 2.465,
|
||||
dauer_p90_s: 23.433, dauer_summe_s: 37.1, zuletzt: "2026-09-21T17:46:47+02:00" },
|
||||
],
|
||||
gesamt: { anfragen: 4364, fehler: 1, fehler_codes: { "502": 1 }, dauer_typisch_s: 1.27, dauer_p90_s: 4.037,
|
||||
dauer_summe_s: 8691.3 },
|
||||
je_tag: {
|
||||
"2026-09-19": { [NQ]: 535, [LUCY]: 15 },
|
||||
"2026-09-20": { [PC]: 1, [NQ]: 589, [LUCY]: 37 },
|
||||
"2026-09-21": { [NQ]: 520, [LUCY]: 14, [PC]: 6 },
|
||||
"2026-09-22": { [NQ]: 659, [LUCY]: 15 },
|
||||
"2026-09-23": { [NQ]: 678, [LUCY]: 77 },
|
||||
"2026-09-24": { [NQ]: 657, [LUCY]: 117 },
|
||||
"2026-09-25": { [NQ]: 394, [LUCY]: 50 },
|
||||
},
|
||||
stunden: stunden(),
|
||||
letzte_24h: Array.from({ length: 24 }, (_, h) => ({ stunde: String(h).padStart(2, "0"), je_absender: {} })),
|
||||
zuletzt_geladen: {
|
||||
"Qwen3.6-35B-A3B": "2026-09-25T11:25:46+02:00",
|
||||
"Qwen3.6-35B-A3B-Bild": "2026-09-25T00:35:29+02:00",
|
||||
"Qwen3.8-27B": "2026-09-24T13:03:13+02:00",
|
||||
},
|
||||
ladevorgaenge: [
|
||||
{ modell: "Qwen3.6-35B-A3B", anzahl: 14,
|
||||
zeiten: ["2026-09-25T11:25:46+02:00", "2026-09-25T11:25:09+02:00", "2026-09-25T06:09:02+02:00", "2026-09-24T16:03:40+02:00"] },
|
||||
{ modell: "Qwen3.6-35B-A3B-Bild", anzahl: 8, zeiten: ["2026-09-25T00:35:29+02:00", "2026-09-24T13:18:34+02:00"] },
|
||||
{ modell: "Qwen3.8-27B", anzahl: 3, zeiten: ["2026-09-24T13:03:13+02:00"] },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { Nutzung } from "@/lib/typen"
|
||||
import { nutzungBeispiel } from "@/test/nutzung"
|
||||
import { NutzungsPanel } from "./ModellNutzung"
|
||||
|
||||
const NQ = "NerdQuiz auf Arcane"
|
||||
const LUCY = "Lucy und OpenChamber über MC2"
|
||||
const PC = "NerdQuiz-Tests vom PC"
|
||||
|
||||
// „HEUTE 12:55“ hängt am heutigen Datum — die Uhr steht auf dem Stand der Testdaten.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] })
|
||||
vi.setSystemTime(new Date("2026-09-25T13:05:00+02:00"))
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
const zeigen = (n: Nutzung = nutzungBeispiel(), rollen?: Record<string, string>) =>
|
||||
render(<NutzungsPanel nutzung={n} laedt={false} fehler={null} rollen={rollen} />)
|
||||
|
||||
const zeileMit = (text: string) => screen.getByText(text).closest<HTMLElement>('[role="row"]')!
|
||||
|
||||
describe("Wer nutzt die Modelle", () => {
|
||||
it("zeigt oben die Kennzahlen mit Zeitraum und Stand", () => {
|
||||
zeigen()
|
||||
expect(screen.getByText("seit Sa 19.09. · Stand 13:03")).toBeInTheDocument()
|
||||
expect(screen.getByText("4.364")).toBeInTheDocument()
|
||||
expect(screen.getByText("im Schnitt 653 am Tag")).toBeInTheDocument()
|
||||
expect(screen.getByText("bis 13:03 · gestern 774")).toBeInTheDocument()
|
||||
expect(screen.getByText("1,3 s")).toBeInTheDocument()
|
||||
expect(screen.getByText("9 von 10 bis 4 s")).toBeInTheDocument()
|
||||
expect(screen.getByText("unter 0,1 % der Anfragen")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("nennt je Absender Anteil, Antwortzeiten, Rechenzeit, Fehler und die letzte Anfrage", () => {
|
||||
zeigen()
|
||||
const tabelle = screen.getByRole("table", { name: "Nutzung je Absender" })
|
||||
const nq = within(tabelle).getByText(NQ).closest<HTMLElement>('[role="row"]')!
|
||||
expect(within(nq).getByText("4.032")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("· 92 % aller Anfragen")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("1,2 s")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("9 von 10 bis 3,3 s")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("1 h 49 min")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("75 % aller Rechenzeit")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("1 · unter 0,1 %")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("1× Modell nicht erreichbar (502)")).toBeInTheDocument()
|
||||
expect(within(nq).getByText("HEUTE 12:55")).toBeInTheDocument()
|
||||
|
||||
const lucy = within(tabelle).getByText(LUCY).closest<HTMLElement>('[role="row"]')!
|
||||
expect(within(lucy).getByText("keine")).toBeInTheDocument()
|
||||
expect(within(lucy).getByText("36 min")).toBeInTheDocument()
|
||||
expect(within(lucy).getByText("25 % aller Rechenzeit")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("zeichnet die Tage gestapelt, mit Legende, Anzeige per Tastatur und Tabelle", () => {
|
||||
zeigen()
|
||||
const tag = screen.getByRole("region", { name: "Anfragen je Tag" })
|
||||
const legende = within(tag).getByRole("list", { name: "Legende" })
|
||||
expect(within(legende).getAllByRole("listitem").map((l) => l.textContent)).toEqual([NQ, LUCY, PC])
|
||||
|
||||
const bild = within(tag).getByRole("img", { name: /Anfragen je Tag der letzten 7 Tage/ })
|
||||
fireEvent.focus(bild)
|
||||
let anzeige = within(tag).getByRole("status")
|
||||
expect(within(anzeige).getByText("Freitag, 25.09. (heute bis 13:03)")).toBeInTheDocument()
|
||||
expect(within(anzeige).getByText("394")).toBeInTheDocument()
|
||||
expect(within(anzeige).getByText("444")).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(bild, { key: "ArrowLeft" })
|
||||
anzeige = within(tag).getByRole("status")
|
||||
expect(within(anzeige).getByText("Donnerstag, 24.09.")).toBeInTheDocument()
|
||||
expect(within(anzeige).getByText("117")).toBeInTheDocument()
|
||||
expect(within(anzeige).getByText("774")).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(bild, { key: "Home" })
|
||||
expect(within(tag).getByRole("status")).toHaveTextContent("Samstag, 19.09.")
|
||||
fireEvent.blur(bild)
|
||||
expect(within(tag).queryByRole("status")).not.toBeInTheDocument()
|
||||
|
||||
const tabelle = within(tag).getByRole("table")
|
||||
const samstag = within(tabelle).getByRole("rowheader", { name: "Samstag, 19.09." }).closest("tr")!
|
||||
expect(within(samstag).getAllByRole("cell").map((c) => c.textContent)).toEqual(["535", "15", "0", "550"])
|
||||
})
|
||||
|
||||
it("zeigt je Stunde nur, wer in den letzten 24 Stunden gefragt hat", () => {
|
||||
zeigen()
|
||||
const stunde = screen.getByRole("region", { name: "Anfragen je Stunde" })
|
||||
const legende = within(stunde).getByRole("list", { name: "Legende" })
|
||||
expect(within(legende).getAllByRole("listitem").map((l) => l.textContent)).toEqual([NQ, LUCY])
|
||||
const tabelle = within(stunde).getByRole("table")
|
||||
expect(within(tabelle).getAllByRole("rowheader")).toHaveLength(24)
|
||||
const nacht = within(tabelle).getByRole("rowheader", { name: "Heute 03:00–04:00" }).closest("tr")!
|
||||
expect(within(nacht).getAllByRole("cell").map((c) => c.textContent)).toEqual(["139", "0", "139"])
|
||||
})
|
||||
|
||||
it("klappt auf, wann welches Modell geladen wurde — mit Rolle", () => {
|
||||
zeigen(nutzungBeispiel(), { "Qwen3.6-35B-A3B": "Hirn" })
|
||||
expect(screen.getByText("3 Modelle · 25× geladen")).toBeInTheDocument()
|
||||
const hirn = zeileMit("Hirn")
|
||||
expect(within(hirn).getByText("Qwen3.6-35B-A3B")).toBeInTheDocument()
|
||||
expect(within(hirn).getByText("14×")).toBeInTheDocument()
|
||||
expect(hirn).toHaveTextContent("zuletzt HEUTE 11:25")
|
||||
expect(hirn).toHaveTextContent("davor HEUTE 11:25 · HEUTE 06:09 · GESTERN 16:03")
|
||||
})
|
||||
|
||||
it("färbt eine auffällige Fehlerquote und sagt es in Worten", () => {
|
||||
const n = nutzungBeispiel()
|
||||
n.gesamt = { ...n.gesamt!, fehler: 131, fehler_codes: { "502": 131 } }
|
||||
zeigen(n)
|
||||
expect(screen.getByText("3 % der Anfragen · auffällig")).toBeInTheDocument()
|
||||
expect(screen.getByText("131")).toHaveClass("text-bernstein")
|
||||
})
|
||||
|
||||
it("bleibt ohne Anfragen beim Hinweis", () => {
|
||||
const n = nutzungBeispiel()
|
||||
n.absender = []
|
||||
n.gesamt = { anfragen: 0, fehler: 0, fehler_codes: {}, dauer_typisch_s: null, dauer_p90_s: null, dauer_summe_s: 0 }
|
||||
zeigen(n)
|
||||
expect(screen.getByText("Keine Anfragen aufgezeichnet.")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("kommt mit dem Stand der Box vor dem 25.09. zurecht", () => {
|
||||
zeigen({
|
||||
tage: 7,
|
||||
absender: [{ name: NQ, anfragen: 10 }],
|
||||
je_tag: { "2026-09-25": { [NQ]: 10 } },
|
||||
letzte_24h: Array.from({ length: 24 }, (_, h): Nutzung["letzte_24h"][number] => ({
|
||||
stunde: String(h).padStart(2, "0"), je_absender: h === 3 ? { [NQ]: 10 } : {},
|
||||
})),
|
||||
zuletzt_geladen: { "Qwen3.6-35B-A3B": "2026-09-25T11:25:46+02:00" },
|
||||
})
|
||||
expect(screen.getByText("letzte 7 Tage")).toBeInTheDocument()
|
||||
const nq = within(screen.getByRole("table", { name: "Nutzung je Absender" })).getByText(NQ).closest<HTMLElement>('[role="row"]')!
|
||||
expect(within(nq).getAllByText("–")).toHaveLength(4) // Antwortzeit, Rechenzeit, Fehler, zuletzt
|
||||
expect(screen.getByText("1 Modell")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("sagt, wenn die Nutzung noch lädt oder fehlt", () => {
|
||||
const { rerender } = render(<NutzungsPanel nutzung={undefined} laedt fehler={null} />)
|
||||
expect(screen.getByText("Wird gelesen …")).toBeInTheDocument()
|
||||
rerender(<NutzungsPanel nutzung={undefined} laedt={false} fehler="MC2 ist nicht erreichbar. Läuft die Box?" />)
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Die Nutzung lässt sich gerade nicht lesen: MC2 ist nicht erreichbar.")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Saeulen } from "@/components/diagramme/Saeulen"
|
||||
import {
|
||||
type AbsenderZeile, anteilText, anzahlText, aufbereiten, dauerText, fehlerStufe, fehlerText, type ModellLadungen,
|
||||
} from "@/lib/nutzung"
|
||||
import type { Nutzung } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
// „Wer nutzt die Modelle“ auf der Seite Modelle (neu am 25.09.2026): Kennzahlen, je Absender Anfragen, Antwortzeiten,
|
||||
// Rechenzeit und Fehler, der Verlauf je Tag und je Stunde als gestapelte Säulen und wann welches Modell geladen wurde.
|
||||
// Die Zahlen rechnet die Box aus dem Journal von llama-swap (services/modell_nutzung.py), hier wird nur angezeigt.
|
||||
|
||||
const WERT_FARBE = { grau: "text-foreground", bernstein: "text-bernstein", rot: "text-rot-text" } as const
|
||||
|
||||
/** Eine Zahl mit Beschriftung, wie die Kacheln im Monitoring. */
|
||||
function Kachel({ label, wert, zusatz, art = "grau" }: {
|
||||
label: string
|
||||
wert: string
|
||||
zusatz?: string
|
||||
art?: keyof typeof WERT_FARBE
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-linie bg-erhaben/50 px-3.5 py-3">
|
||||
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
|
||||
<span className={cn("font-anzeige text-[28px] leading-none font-bold", WERT_FARBE[art])}>{wert}</span>
|
||||
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SPALTEN = "lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,0.8fr)]"
|
||||
|
||||
/** Eine Zelle der Absender-Tabelle; schmal steht die Beschriftung darüber, breit im Tabellenkopf. */
|
||||
function Zelle({ label, wert, zusatz, leise }: { label: string; wert: string; zusatz?: string; leise?: boolean }) {
|
||||
return (
|
||||
<span role="cell" className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs text-text-3 lg:hidden">{label}</span>
|
||||
<span className={cn("ziffern text-sm", leise ? "text-text-3" : "text-foreground")}>{wert}</span>
|
||||
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
|
||||
return (
|
||||
<div role="table" aria-label="Nutzung je Absender" className="flex flex-col">
|
||||
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs text-text-3 lg:grid", SPALTEN)}>
|
||||
<span role="columnheader">Absender</span>
|
||||
<span role="columnheader">Anfragen</span>
|
||||
<span role="columnheader">Typische Antwort</span>
|
||||
<span role="columnheader">Rechenzeit</span>
|
||||
<span role="columnheader">Fehler</span>
|
||||
<span role="columnheader">Zuletzt</span>
|
||||
</div>
|
||||
{zeilen.map((a) => (
|
||||
<div key={a.name} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2.5 border-t border-linie py-3 lg:items-start", SPALTEN)}>
|
||||
<span role="rowheader" className="col-span-2 flex min-w-0 items-center gap-2 text-[15px] font-medium lg:col-span-1">
|
||||
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: a.farbe }} />
|
||||
<span className="min-w-0 break-words">{a.name}</span>
|
||||
</span>
|
||||
<span role="cell" className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
|
||||
<span className="text-sm">
|
||||
<span className="ziffern">{anzahlText(a.anfragen)}</span>
|
||||
<span className="text-text-3"> · {anteilText(a.anteil)} aller Anfragen</span>
|
||||
</span>
|
||||
<span className="h-1.5 w-full max-w-56 overflow-hidden rounded-full bg-erhaben" aria-hidden>
|
||||
<span className="block h-full rounded-full" style={{ width: `${Math.max(1, a.anteil * 100)}%`, background: a.farbe }} />
|
||||
</span>
|
||||
</span>
|
||||
<Zelle label="Typische Antwort" wert={dauerText(a.typisch)}
|
||||
zusatz={a.p90 !== null ? `9 von 10 bis ${dauerText(a.p90)}` : undefined} />
|
||||
<Zelle label="Rechenzeit" wert={dauerText(a.summe, true)}
|
||||
zusatz={a.zeitAnteil !== null ? `${anteilText(a.zeitAnteil)} aller Rechenzeit` : undefined} />
|
||||
<Zelle label="Fehler" leise={a.fehler === 0}
|
||||
wert={a.fehler === null ? "–" : a.fehler === 0 ? "keine" : `${anzahlText(a.fehler)} · ${anteilText(a.fehler / a.anfragen)}`}
|
||||
zusatz={a.fehler ? fehlerText(a.fehlerCodes) : undefined} />
|
||||
<Zelle label="Zuletzt" wert={a.zuletzt ? flugplanZeit(a.zuletzt) : "–"} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Diagramm({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) {
|
||||
return (
|
||||
<section aria-label={titel} className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie p-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3>
|
||||
<span className="text-xs text-text-3">{zusatz}</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wann welches Modell geladen wurde — eingeklappt, die Zusammenfassung steht in der Kopfzeile. */
|
||||
function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen: Record<string, string> }) {
|
||||
if (modelle.length === 0) return null
|
||||
const gesamt = modelle.every((m) => m.anzahl !== null) ? modelle.reduce((s, m) => s + (m.anzahl ?? 0), 0) : null
|
||||
return (
|
||||
<details className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-2">
|
||||
<summary className="flex min-h-12 cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2 [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center gap-2 text-[15px] font-medium">
|
||||
<ChevronRight className="size-4 shrink-0 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
|
||||
Wann welches Modell geladen wurde
|
||||
</span>
|
||||
<span className="ziffern text-sm text-text-2">
|
||||
{modelle.length === 1 ? "1 Modell" : `${modelle.length} Modelle`}{gesamt !== null ? ` · ${anzahlText(gesamt)}× geladen` : ""}
|
||||
</span>
|
||||
</summary>
|
||||
<p className="pb-1 text-sm text-text-2">
|
||||
Gezählt wird, wie oft sich ein Modell nach dem Laden bereit meldete: nach einem Neustart von llama-swap oder
|
||||
wenn es auf Abruf gebraucht wurde.
|
||||
</p>
|
||||
<div role="table" aria-label="Ladevorgänge je Modell" className="flex flex-col">
|
||||
<div role="row" className="schild hidden gap-x-4 py-2 text-xs text-text-3 sm:grid sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)]">
|
||||
<span role="columnheader">Modell</span>
|
||||
<span role="columnheader">Geladen</span>
|
||||
<span role="columnheader">Zuletzt</span>
|
||||
<span role="columnheader">Davor</span>
|
||||
</div>
|
||||
{modelle.map((m) => (
|
||||
<div key={m.modell} role="row"
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)] sm:items-baseline">
|
||||
<span role="rowheader" className="flex min-w-0 flex-col">
|
||||
<span className="ziffern text-sm break-all">{m.modell}</span>
|
||||
{rollen[m.modell] && <span className="text-xs text-text-3">{rollen[m.modell]}</span>}
|
||||
</span>
|
||||
<span role="cell" className="ziffern text-right text-sm text-text-2 sm:text-left">
|
||||
{m.anzahl !== null ? `${anzahlText(m.anzahl)}×` : "–"}
|
||||
</span>
|
||||
<span role="cell" className="ziffern col-span-2 text-xs text-text-2 sm:col-span-1">
|
||||
<span className="font-sans text-text-3 sm:hidden">zuletzt </span>{flugplanZeit(m.zeiten[0])}
|
||||
</span>
|
||||
<span role="cell" className="ziffern col-span-2 text-xs text-text-3 sm:col-span-1">
|
||||
{m.zeiten.length > 1 && <span className="font-sans sm:hidden">davor </span>}
|
||||
{m.zeiten.length > 1 ? m.zeiten.slice(1, 4).map((z) => flugplanZeit(z)).join(" · ") : <span className="hidden sm:inline">–</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer die Modelle nutzt, wie viel, wie schnell sie antworten und wann welches Modell geladen wurde.
|
||||
* `rollen` nennt zu Modellnamen ihre Rolle („Hirn“, „Coder“ …), damit die Ladeliste lesbar bleibt.
|
||||
*/
|
||||
export function NutzungsPanel({ nutzung, laedt, fehler, rollen = {} }: {
|
||||
nutzung: Nutzung | undefined
|
||||
laedt: boolean
|
||||
fehler: string | null
|
||||
rollen?: Record<string, string>
|
||||
}) {
|
||||
if (!nutzung) {
|
||||
return (
|
||||
<Panel titel="Wer nutzt die Modelle">
|
||||
{laedt ? (
|
||||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
) : (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
const bild = aufbereiten(nutzung)
|
||||
const g = bild.gesamt
|
||||
const quote = g.fehler !== null && g.anfragen ? g.fehler / g.anfragen : null
|
||||
const stufe = fehlerStufe(quote)
|
||||
return (
|
||||
<Panel
|
||||
titel="Wer nutzt die Modelle"
|
||||
rechts={<span className="ziffern text-sm text-text-3">{bild.zeitraum}{bild.stand ? ` · Stand ${bild.stand}` : ""}</span>}
|
||||
>
|
||||
{g.anfragen === 0 ? (
|
||||
<p className="text-[15px] text-text-2">Keine Anfragen aufgezeichnet.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<Kachel label="Anfragen" wert={anzahlText(g.anfragen)}
|
||||
zusatz={bild.schnitt !== null ? `im Schnitt ${anzahlText(Math.round(bild.schnitt))} am Tag` : undefined} />
|
||||
<Kachel label="Heute" wert={anzahlText(bild.heute)}
|
||||
zusatz={`${bild.stand ? `bis ${bild.stand} · ` : ""}gestern ${anzahlText(bild.gestern)}`} />
|
||||
<Kachel label="Typische Antwort" wert={dauerText(g.typisch)}
|
||||
zusatz={g.p90 !== null ? `9 von 10 bis ${dauerText(g.p90)}` : undefined} />
|
||||
<Kachel label="Fehler" wert={g.fehler === null ? "–" : anzahlText(g.fehler)} art={stufe.art}
|
||||
zusatz={g.fehler === null ? undefined : g.fehler === 0 ? "alle beantwortet"
|
||||
: `${anteilText(quote ?? 0)} der Anfragen${stufe.wort ? ` · ${stufe.wort}` : ""}`} />
|
||||
</div>
|
||||
<AbsenderTabelle zeilen={bild.absender} />
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Diagramm titel="Anfragen je Tag" zusatz="letzte 7 Tage, heute bis jetzt">
|
||||
<Saeulen saeulen={bild.tage} reihen={bild.reihen} spalte="Tag"
|
||||
titel="Anfragen je Tag der letzten 7 Tage, gestapelt nach Absender" />
|
||||
</Diagramm>
|
||||
<Diagramm titel="Anfragen je Stunde" zusatz="letzte 24 Stunden">
|
||||
<Saeulen saeulen={bild.stunden} reihen={bild.reihen} spalte="Stunde" summen="hoechste"
|
||||
titel="Anfragen je Stunde der letzten 24 Stunden, gestapelt nach Absender" />
|
||||
</Diagramm>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Ladevorgaenge modelle={bild.modelle} rollen={rollen} />
|
||||
<p className="text-xs text-text-3">
|
||||
Chat-Anfragen an llama-swap, gelesen aus seinem Protokoll und alle 10 Minuten neu gerechnet. NerdQuiz fragt direkt
|
||||
dort an, Lucy und OpenChamber über MC2. Antwortzeit: bis die Antwort ganz da war. Rechenzeit: alle Antwortzeiten
|
||||
zusammen, gleichzeitige Anfragen zählen einzeln.
|
||||
</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
|
||||
import { ApiFehler } from "@/lib/api"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { Altrest, AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import type { Altrest, AufraeumKandidat, Modell, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import { letzteMeldung } from "@/lib/wartung"
|
||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||
import { flugplanZeit, gb, gbText } from "@/lib/zeit"
|
||||
import { NutzungsPanel } from "./ModellNutzung"
|
||||
import { ModellSuche } from "./ModellSuche"
|
||||
|
||||
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
|
||||
@@ -103,49 +104,6 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
|
||||
)
|
||||
}
|
||||
|
||||
function NutzungsPanel({ nutzung, laedt, fehler }: { nutzung: Nutzung | undefined; laedt: boolean; fehler: string | null }) {
|
||||
if (!nutzung) {
|
||||
return (
|
||||
<Panel titel="Wer nutzt die Modelle">
|
||||
{laedt ? (
|
||||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
) : (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
|
||||
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
|
||||
const maxStunde = Math.max(1, ...stunden)
|
||||
return (
|
||||
<Panel titel="Wer nutzt die Modelle" rechts={<span className="ziffern text-xs text-text-3">letzte {nutzung.tage} Tage</span>}>
|
||||
{nutzung.absender.length === 0 && <p className="text-[15px] text-text-2">Keine Anfragen aufgezeichnet.</p>}
|
||||
<ul className="m-0 flex list-none flex-col gap-3.5 p-0">
|
||||
{nutzung.absender.map((a) => (
|
||||
<li key={a.name} className="flex flex-col gap-1.5">
|
||||
<div className="flex justify-between gap-3 text-sm"><span>{a.name}</span><span className="ziffern">{zahl(a.anfragen)}</span></div>
|
||||
<div className="h-2 rounded-full bg-erhaben"><div className="h-2 rounded-full bg-cyan" style={{ width: `${(a.anfragen / max) * 100}%` }} /></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="schild text-xs text-text-3">Anfragen je Stunde, letzte 24 h</span>
|
||||
<svg viewBox="0 0 240 64" className="h-16 w-full" role="img" aria-label="Anfragen je Stunde in den letzten 24 Stunden">
|
||||
{stunden.map((n, i) => {
|
||||
const h = n ? Math.max(3, (n / maxStunde) * 50) : 2
|
||||
return <rect key={i} x={i * 10 + 1.5} y={52 - h} width={7} height={h} rx={1.5} fill={n ? "var(--cyan)" : "var(--linie)"} />
|
||||
})}
|
||||
{[0, 6, 12, 18].map((s) => (
|
||||
<text key={s} x={s * 10 + 5} y={63} textAnchor="middle" style={{ fontSize: 9, fill: "var(--text-3)", fontFamily: "var(--font-mono)" }}>{s}</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-text-3">Anfragen an llama-swap. NerdQuiz geht direkt dorthin, alles andere über MC2.</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_ART: Record<string, ChipArt> = {
|
||||
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
|
||||
}
|
||||
@@ -537,6 +495,12 @@ export function ModelleSeite() {
|
||||
const coderZwilling = zwilling(coder, "coder-bild")
|
||||
const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m))
|
||||
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
|
||||
// Für die Ladeliste der Nutzung: Modellname → Rolle, damit dort nicht nur Dateinamen stehen.
|
||||
const rollen: Record<string, string> = {}
|
||||
for (const [m, rolle] of [[hirn, "Hirn"], [coder, "Coder"], [hirnZwilling, "Bild-Zwilling des Hirns"],
|
||||
[coderZwilling, "Bild-Zwilling des Coders"]] as const) {
|
||||
if (m) rollen[m.name] = rolle
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -547,10 +511,9 @@ export function ModelleSeite() {
|
||||
<Rolle titel="Coder" modell={coder} zwilling={coderZwilling} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
|
||||
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
|
||||
</section>
|
||||
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||
<NutzungsPanel nutzung={nutzung.data} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} />
|
||||
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
|
||||
</div>
|
||||
{/* Die Nutzung braucht mit ihren Diagrammen die volle Breite (vorher halbe Zeile neben dem Radar). */}
|
||||
<NutzungsPanel nutzung={nutzung.data} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} rollen={rollen} />
|
||||
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
|
||||
<Panel
|
||||
titel="Weitere Einträge"
|
||||
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
|
||||
|
||||
Reference in New Issue
Block a user