oberflaeche v4: Uebersicht als Startseite, KI-Box und Homelab mit Monitoring, Homelab-Updates je Geraet mit Alle aktualisieren, Protokoll, Einstellungsseite, neues Tab-Symbol
- Startseite "Alles auf einen Blick": Gesamtlage, je Bereich Messwerte mit Verlauf, wer welche Updates hat (Kurzform, Klick fuehrt in den Bereich), Hinweise, als Naechstes und zuletzt eingespielt - Menue: Uebersicht, KI-Box (Cockpit, Updates, Modelle, Monitoring), Homelab (Cockpit, Updates, Monitoring, Protokoll), Einstellungen; Bereichsfarben Indigo und Violett; alte Adressen /updates und /modelle leiten weiter - Homelab-Updates je Geraet: App-Version laeuft -> neu (mit Grund, wenn sie wartet), Pakete, Rueckweg, Knoepfe; Alle aktualisieren mit Plan in der Rueckfrage und Fortschritt; Cockpit zeigt die Gesundheit der Geraete - Einstellungen als Seite: KI-Box, Homelab (Arcane-Schluessel, Docker echt/Probelauf, Ausfuehrer), Meldungen, Software-Stand - Diagramme: Verlauf mit Fadenkreuz, Legende und Tabellenansicht, Kennzahlen mit Linie; Farben geprueft - Tab-Symbol: favicon.ico/.svg, apple-touch-icon, Manifest-Icons Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bf1153cb66
commit
1104200a30
@@ -0,0 +1,187 @@
|
||||
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
|
||||
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
|
||||
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
|
||||
import type { Messpunkt } from "@/lib/typen"
|
||||
|
||||
export interface Reihe {
|
||||
name: string
|
||||
punkte: Messpunkt[]
|
||||
/** CSS-Farbe der Linie, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
|
||||
farbe: string
|
||||
}
|
||||
|
||||
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
|
||||
* Werte zum Aufklappen als Tabelle — der Tooltip ist nie der einzige Weg zu einer Zahl.
|
||||
*/
|
||||
export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }: {
|
||||
reihen: Reihe[]
|
||||
einheit: Einheit
|
||||
/** 7 Tage: Zeitachse mit Wochentag. */
|
||||
langerZeitraum: boolean
|
||||
hoehe?: number
|
||||
titel: string
|
||||
}) {
|
||||
const [ref, breite] = useBreite()
|
||||
const [index, setIndex] = useState<number | null>(null)
|
||||
const basis = reihen[0]?.punkte ?? []
|
||||
const n = basis.length
|
||||
const max = obergrenze(reihen.map((r) => r.punkte), einheit)
|
||||
const ticksY = einheit === "prozent" ? [0, 25, 50, 75, 100] : [0, max / 2, max]
|
||||
// Der linke Rand richtet sich nach der längsten Beschriftung („500 KB/s“ braucht mehr als „75 %“).
|
||||
const links = Math.max(40, Math.max(...ticksY.map((v) => wertText(v, einheit).length)) * 7 + 12)
|
||||
const RAND_L = { ...RAND, links }
|
||||
const innenB = breite - RAND_L.links - RAND.rechts
|
||||
const innenH = hoehe - RAND.oben - RAND.unten
|
||||
const t0 = basis[0]?.[0] ?? 0
|
||||
const t1 = basis[n - 1]?.[0] ?? 1
|
||||
const x = (t: number) => RAND_L.links + (t1 > t0 ? ((t - t0) / (t1 - t0)) * innenB : 0)
|
||||
const y = (v: number) => RAND.oben + innenH - (Math.min(v, max) / (max || 1)) * innenH
|
||||
const grund = RAND.oben + innenH
|
||||
const ticksX = n > 1 ? [0, 0.25, 0.5, 0.75, 1].map((f) => basis[Math.round(f * (n - 1))][0]) : []
|
||||
const hatWerte = reihen.some((r) => r.punkte.some(([, v]) => v !== null))
|
||||
|
||||
function zeigen(e: PointerEvent<SVGRectElement>) {
|
||||
if (n === 0) return
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
const anteil = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
|
||||
setIndex(Math.round(anteil * (n - 1)))
|
||||
}
|
||||
function taste(e: KeyboardEvent<SVGSVGElement>) {
|
||||
if (n === 0) return
|
||||
if (e.key === "ArrowLeft") setIndex((i) => Math.max(0, (i ?? n - 1) - 1))
|
||||
else if (e.key === "ArrowRight") setIndex((i) => Math.min(n - 1, (i ?? n - 1) + 1))
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const aktiv = index !== null && basis[index] ? basis[index][0] : null
|
||||
const tipLinks = aktiv !== null ? x(aktiv) : 0
|
||||
const letzte = basis.slice(-12)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reihen.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">
|
||||
{reihen.map((r) => (
|
||||
<li key={r.name} className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-0.5 w-4 rounded-full" style={{ background: r.farbe }} />
|
||||
{r.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={ref} className="relative w-full">
|
||||
<svg
|
||||
width={breite}
|
||||
height={hoehe}
|
||||
role="img"
|
||||
aria-label={titel}
|
||||
tabIndex={0}
|
||||
onKeyDown={taste}
|
||||
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
|
||||
onBlur={() => setIndex(null)}
|
||||
className="block max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
|
||||
>
|
||||
{ticksY.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={RAND_L.links} x2={breite - RAND.rechts} y1={y(v)} y2={y(v)} stroke="var(--linie)" strokeWidth={1} />
|
||||
<text x={RAND_L.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
|
||||
{wertText(v, einheit)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{ticksX.map((t, i) => (
|
||||
<text key={t} x={x(t)} y={hoehe - 6} className="fill-text-3 font-mono text-[11px]"
|
||||
textAnchor={i === 0 ? "start" : i === ticksX.length - 1 ? "end" : "middle"}>
|
||||
{zeitText(t, langerZeitraum)}
|
||||
</text>
|
||||
))}
|
||||
{hatWerte ? (
|
||||
reihen.map((r) => (
|
||||
<g key={r.name}>
|
||||
{reihen.length === 1 && <path d={flaechenPfad(r.punkte, x, y, grund)} fill={r.farbe} opacity={0.1} />}
|
||||
<path d={linienPfad(r.punkte, x, y)} fill="none" stroke={r.farbe} strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
))
|
||||
) : (
|
||||
<text x={RAND_L.links + innenB / 2} y={RAND.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
|
||||
Noch keine Messwerte in diesem Zeitraum
|
||||
</text>
|
||||
)}
|
||||
{aktiv !== null && (
|
||||
<g aria-hidden>
|
||||
<line x1={x(aktiv)} x2={x(aktiv)} y1={RAND.oben} y2={grund} stroke="var(--text-3)" strokeWidth={1} />
|
||||
{reihen.map((r) => {
|
||||
const v = r.punkte[index!]?.[1]
|
||||
return v === null || v === undefined ? null : (
|
||||
<circle key={r.name} cx={x(aktiv)} cy={y(v)} r={4} fill={r.farbe} stroke="var(--panel)" strokeWidth={2} />
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
{/* Trefferfläche: die ganze Zeichenfläche — niemand muss eine 2-px-Linie treffen. */}
|
||||
<rect x={RAND_L.links} y={RAND.oben} width={innenB} height={innenH} fill="transparent"
|
||||
onPointerMove={zeigen} onPointerLeave={() => setIndex(null)} />
|
||||
</svg>
|
||||
{aktiv !== null && (
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2 shadow-lg"
|
||||
style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }}
|
||||
>
|
||||
<span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span>
|
||||
{reihen.map((r) => (
|
||||
<span key={r.name} className="flex items-center gap-2 text-sm">
|
||||
<span aria-hidden className="h-0.5 w-3 rounded-full" style={{ background: r.farbe }} />
|
||||
<span className="font-semibold text-foreground">{wertText(r.punkte[index!]?.[1], einheit)}</span>
|
||||
{reihen.length > 1 && <span className="text-text-3">{r.name}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<details className="group text-sm">
|
||||
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
|
||||
<table className="ziffern mt-2 w-full max-w-md border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-text-3">
|
||||
<th className="py-1 pr-4 font-normal">Zeit</th>
|
||||
{reihen.map((r) => <th key={r.name} className="py-1 pr-4 font-normal">{r.name}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{letzte.map(([t], i) => (
|
||||
<tr key={t} className="border-t border-linie">
|
||||
<td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td>
|
||||
{reihen.map((r) => (
|
||||
<td key={r.name} className="py-1 pr-4 text-text-2">
|
||||
{wertText(r.punkte[n - letzte.length + i]?.[1], einheit)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user