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:
Hitonabi
2026-09-24 22:18:56 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 1104200a30
37 changed files with 2244 additions and 493 deletions
@@ -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>
)
}