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
+91 -3
View File
@@ -9,9 +9,10 @@ import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, Altrest, AufraeumKandidat, EinstellungenStand, Hinweis, HomelabLauf, HomelabStand, Instanz,
InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start, TelegramTest, UpdateDetails,
ZielAktion, ZielStand,
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
import { gbText } from "./zeit"
@@ -361,3 +362,90 @@ export const useAltrestLoeschen = () =>
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.",
neuLaden: ["altreste", "start"],
})
// --- Monitoring ------------------------------------------------------------------------------------------------
/** Takt der Messwerte: je feiner der Zeitraum, desto öfter. Fehlt die Schnittstelle (ältere Box), bleibt es still. */
const MESS_TAKT: Record<Zeitraum, number> = { "1h": 60_000, "24h": 300_000, "7d": 900_000 }
export const useBoxMesswerte = (zeitraum: Zeitraum, aktiv = true) =>
useQuery({
queryKey: ["messwerte", "box", zeitraum],
queryFn: () => api<BoxMesswerte>(`/api/messwerte?zeitraum=${zeitraum}`),
enabled: aktiv,
refetchInterval: MESS_TAKT[zeitraum],
placeholderData: (vorher) => vorher,
retry: false,
})
export const useHomelabMesswerte = (zeitraum: Zeitraum, aktiv: boolean) =>
useQuery({
queryKey: ["messwerte", "homelab", zeitraum],
queryFn: () => api<HomelabMesswerte>(`/api/homelab/messwerte?zeitraum=${zeitraum}`),
enabled: aktiv,
refetchInterval: MESS_TAKT[zeitraum],
placeholderData: (vorher) => vorher,
retry: false,
})
// --- Homelab: „Alle aktualisieren“, Protokoll, Einstellungen ----------------------------------------------
export const useSammelPlan = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-sammelplan"],
queryFn: () => api<{ schritte: SammelPlanSchritt[]; hinweis: string | null }>("/api/homelab/alle/plan"),
enabled: aktiv,
retry: false,
})
export const useSammellauf = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-sammellauf"],
queryFn: () => api<{ sammellauf: Sammellauf | null }>("/api/homelab/sammellauf"),
enabled: aktiv,
// Läuft einer, alle 5 s nachsehen — sonst reicht der normale Takt.
refetchInterval: (q) => (q.state.data?.sammellauf?.status === "laeuft" ? 5_000 : 60_000),
retry: false,
})
export const useAlleAktualisieren = () =>
useAktion(() => post<AktionsErgebnis>("/api/homelab/alle"), {
erfolg: () => "Alle aktualisieren: gestartet. Die Schritte laufen nacheinander; das Ergebnis kommt als Meldung.",
neuLaden: ["homelab-sammellauf", "homelab-laeufe", "homelab"],
})
export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
useQuery({
queryKey: ["homelab-protokoll", berichte],
queryFn: () => api<{ eintraege: ProtokollEintrag[] }>(`/api/homelab/protokoll?grenze=300&berichte=${berichte ? 1 : 0}`),
enabled: aktiv,
refetchInterval: takt(30_000),
retry: false,
})
export const useHomelabEinstellungen = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-einstellungen"],
queryFn: () => api<HomelabEinstellungen>("/api/homelab/einstellungen"),
enabled: aktiv,
retry: false,
})
export const useArcaneSchluessel = () =>
useAktion(
(schluessel: string | null) =>
schluessel === null
? api<AktionsErgebnis>("/api/homelab/einstellungen/arcane-schluessel", { method: "DELETE" })
: post<AktionsErgebnis>("/api/homelab/einstellungen/arcane-schluessel", { schluessel }),
{
erfolg: (v, e) => (e as AktionsErgebnis).text ?? (v === null ? "Arcane-Schlüssel entfernt." : "Arcane-Schlüssel gespeichert."),
neuLaden: ["homelab-einstellungen", "homelab"],
},
)
export const useArcaneEcht = () =>
useAktion((an: boolean) => post<AktionsErgebnis>("/api/homelab/einstellungen/arcane-echt", { an }), {
erfolg: (an) => (an ? "Docker-Updates über Arcane laufen jetzt echt." : "Docker-Updates über Arcane sind wieder nur ein Probelauf."),
neuLaden: ["homelab-einstellungen", "homelab"],
})
+39
View File
@@ -0,0 +1,39 @@
import type { Messpunkt } from "@/lib/typen"
// Geometrie der Diagramme (Pfade aus Messreihen) — ohne DOM, damit sie sich testen lässt.
/** Pfad einer Reihe; Lücken (null) unterbrechen die Linie, statt sie zu überbrücken. */
export function linienPfad(punkte: Messpunkt[], x: (t: number) => number, y: (v: number) => number): string {
let pfad = ""
let offen = false
for (const [t, v] of punkte) {
if (v === null) {
offen = false
continue
}
pfad += `${offen ? "L" : "M"}${x(t).toFixed(1)},${y(v).toFixed(1)}`
offen = true
}
return pfad
}
/** Fläche unter einer Reihe bis zur Grundlinie — je zusammenhängendem Stück eine eigene Fläche. */
export function flaechenPfad(
punkte: Messpunkt[], x: (t: number) => number, y: (v: number) => number, grund: number,
): string {
let pfad = ""
let stueck: [number, number][] = []
const schliessen = () => {
if (stueck.length > 1) {
pfad += `M${stueck[0][0].toFixed(1)},${grund}` + stueck.map(([a, b]) => `L${a.toFixed(1)},${b.toFixed(1)}`).join("") +
`L${stueck[stueck.length - 1][0].toFixed(1)},${grund}Z`
}
stueck = []
}
for (const [t, v] of punkte) {
if (v === null) schliessen()
else stueck.push([x(t), y(v)])
}
schliessen()
return pfad
}
+10
View File
@@ -48,6 +48,16 @@ export function offeneUpdates(ziele: ZielStand[]): OffenesUpdate[] {
return [...alle.filter((u) => u.baustein.aktion), ...alle.filter((u) => !u.baustein.aktion)]
}
/** Reihenfolge der Update-Tabelle: 0 = etwas per Knopf, 1 = neu ohne Knopf (wartet, eigene Oberfläche),
* 2 = Stand unklar, 3 = alles aktuell. */
export function updateRang(ziel: ZielStand): number {
const neu = ziel.bausteine.filter((b) => b.zustand === "neu")
if (neu.some((b) => b.aktion)) return 0
if (neu.length > 0) return 1
if (ziel.bausteine.some((b) => b.zustand === "unbekannt")) return 2
return 3
}
/** Was vor dem Update gesichert wird, in wenigen Worten. */
export function rueckwegText(ziel: ZielStand): string {
const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg)
+74
View File
@@ -0,0 +1,74 @@
import type { Messpunkt } from "@/lib/typen"
// Formate und kleine Rechnungen für das Monitoring — ohne DOM, damit sie sich testen lassen.
export type Einheit = "prozent" | "grad" | "bytes_s" | "anzahl"
const zahl = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 })
const ganz = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 })
/** Bytes/s lesbar: 812 B/s, 45,3 KB/s, 12,1 MB/s. */
export function bytesProSekunde(v: number): string {
if (v < 1000) return `${ganz.format(v)} B/s`
if (v < 1_000_000) return `${zahl.format(v / 1000)} KB/s`
if (v < 1_000_000_000) return `${zahl.format(v / 1_000_000)} MB/s`
return `${zahl.format(v / 1_000_000_000)} GB/s`
}
export function wertText(v: number | null | undefined, einheit: Einheit): string {
if (v === null || v === undefined || Number.isNaN(v)) return "–"
if (einheit === "prozent") return `${ganz.format(v)} %`
if (einheit === "grad") return `${ganz.format(v)} °C`
if (einheit === "bytes_s") return bytesProSekunde(v)
return ganz.format(v)
}
/** Der letzte Wert einer Reihe, der keine Lücke ist. */
export function letzterWert(reihe: Messpunkt[] | undefined): number | null {
if (!reihe) return null
for (let i = reihe.length - 1; i >= 0; i--) {
const v = reihe[i][1]
if (v !== null) return v
}
return null
}
/** Obergrenze der y-Achse: Prozent immer 100; sonst ein runder Wert über dem Maximum (mindestens 1). */
export function obergrenze(reihen: (Messpunkt[] | undefined)[], einheit: Einheit): number {
if (einheit === "prozent") return 100
let max = 0
for (const r of reihen) for (const [, v] of r ?? []) if (v !== null && v > max) max = v
if (einheit === "grad") return Math.max(60, Math.ceil((max + 5) / 10) * 10)
if (max <= 0) return 1
const stufe = 10 ** Math.floor(Math.log10(max))
for (const f of [1, 2, 2.5, 5, 10]) if (f * stufe >= max) return f * stufe
return 10 * stufe
}
/** Bei Grenzwerten die Farbe des Zustands: ruhig, bernstein ab gelb, rot ab rot. */
export function zustandBei(v: number | null, gelb: number, rot: number): "ruhig" | "gelb" | "rot" {
if (v === null) return "ruhig"
return v >= rot ? "rot" : v >= gelb ? "gelb" : "ruhig"
}
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
const tagUhr = new Intl.DateTimeFormat("de-DE", {
weekday: "short", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin",
})
/** Zeit an der x-Achse: bis 24 h die Uhrzeit, darüber mit Wochentag. */
export function zeitText(t: number, langerZeitraum: boolean): string {
const d = new Date(t * 1000)
return (langerZeitraum ? tagUhr : uhr).format(d).replace(",", "")
}
/** Laufzeit kurz: „3 T 4 h“, „5 h 12 min“. */
export function laufzeit(s: number | null | undefined): string {
if (!s || s < 0) return "–"
const t = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
if (t > 0) return `${t} T ${h} h`
if (h > 0) return `${h} h ${m} min`
return `${m} min`
}
+31 -11
View File
@@ -1,41 +1,61 @@
import { useRouterState } from "@tanstack/react-router"
import { Cpu, LayoutDashboard, type LucideIcon, RefreshCw } from "lucide-react"
import {
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
} from "lucide-react"
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
import { offeneUpdates } from "@/lib/homelab"
import { useStromLage } from "@/lib/strom"
// Zwei Bereiche, sauber getrennt: die KI-Box (Box-Wart) und das Homelab (Proxmox-PC). Jeder hat seine
// eigenen Seiten und seine eigene Verbindungsanzeige; keine Seite zeigt etwas aus dem anderen Bereich.
// Oben die Übersicht über alles, darunter zwei sauber getrennte Bereiche — die KI-Box (Box-Wart) und das
// Homelab (Proxmox-PC) —, jeder mit eigenen Seiten und eigener Verbindungsanzeige, unten die Einstellungen.
export type Bereich = "box" | "homelab"
export type Ort = Bereich | "uebersicht" | "system"
export const BEREICHE: { id: Bereich; name: string }[] = [
{ id: "box", name: "KI-Box" },
{ id: "homelab", name: "Homelab" },
]
export type SeitenPfad =
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring"
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
export interface Seite {
to: "/" | "/updates" | "/modelle" | "/homelab" | "/homelab/updates"
to: SeitenPfad
label: string
Icon: LucideIcon
bereich: Bereich
ort: Ort
/** Zeigt die Zahl offener Updates des Bereichs. */
zaehlt?: boolean
}
export const SEITEN: Seite[] = [
{ to: "/", label: "Cockpit", Icon: LayoutDashboard, bereich: "box" },
{ to: "/updates", label: "Updates", Icon: RefreshCw, bereich: "box", zaehlt: true },
{ to: "/modelle", label: "Modelle", Icon: Cpu, bereich: "box" },
{ to: "/homelab", label: "Cockpit", Icon: LayoutDashboard, bereich: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, bereich: "homelab", zaehlt: true },
{ to: "/", label: "Übersicht", Icon: LayoutGrid, ort: "uebersicht" },
{ to: "/box", label: "Cockpit", Icon: Gauge, ort: "box" },
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
{ to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" },
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
{ to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
{ to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" },
{ to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" },
]
/** Was über dem Seitentitel steht. */
export const ORT_NAME: Record<Ort, string> = {
uebersicht: "Alles auf einen Blick",
box: "KI-Box",
homelab: "Homelab",
system: "Beide Instanzen",
}
export const bereichName = (b: Bereich) => BEREICHE.find((x) => x.id === b)?.name ?? b
/** Welche Seite gerade offen ist — für die Überschrift oben. */
export function useAktuelleSeite(): Seite {
const pfad = useRouterState({ select: (s) => s.location.pathname })
const pfad = useRouterState({ select: (s) => s.location.pathname }).replace(/\/+$/, "") || "/"
return SEITEN.find((s) => s.to === pfad) ?? SEITEN[0]
}
+84
View File
@@ -377,3 +377,87 @@ export interface Altrest {
groesse: string
hinweis: string
}
// --- Monitoring (GET /api/messwerte, GET /api/homelab/messwerte, seit 24.09.2026) --------------------------
export type Zeitraum = "1h" | "24h" | "7d"
/** Ein Punkt einer Messreihe: [Unix-Sekunden des Schrittbeginns, Wert oder null bei einer Lücke]. */
export type Messpunkt = [number, number | null]
/** KI-Box: cpu, ram, gpu, platte in %, temp_cpu/temp_gpu in °C, netz_rx/netz_tx in Bytes/s, tokens je Minute. */
export interface BoxMesswerte {
zeitraum: Zeitraum
schritt_s: number
reihen: Partial<Record<"cpu" | "ram" | "gpu" | "temp_cpu" | "temp_gpu" | "platte" | "netz_rx" | "netz_tx" | "tokens", Messpunkt[]>>
aktuell: Partial<Record<string, number | null>>
}
export interface GeraetMesswerte {
id: string
name: string
art: "proxmox-host" | "container" | "vm" | string
reihen: Partial<Record<"cpu" | "ram" | "platte" | "netz_rx" | "netz_tx" | "temp_cpu", Messpunkt[]>>
aktuell: {
cpu?: number | null
ram?: number | null
ram_used?: number | null
ram_total?: number | null
platte?: number | null
netz_rx?: number | null
netz_tx?: number | null
temp_cpu?: number | null
load1?: number | null
uptime_s?: number | null
}
}
export interface HomelabMesswerte {
zeitraum: Zeitraum
schritt_s: number
geraete: GeraetMesswerte[]
}
// --- Homelab: „Alle aktualisieren“, Protokoll, Einstellungen (seit 24.09.2026) ---------------------------
export interface SammelPlanSchritt {
ziel: string
name: string
baustein: string
was: string
rueckweg_art: "snapshot" | "sicherung" | "keiner"
}
export type SammelSchrittStatus = "wartet" | "laeuft" | "eingespielt" | "zurueckgerollt" | "fehler" | "uebersprungen"
export interface Sammellauf {
id: string
status: "laeuft" | "fertig" | "abgebrochen"
start: number
ende: number | null
aktuell: number | null
schritte: (Omit<SammelPlanSchritt, "rueckweg_art"> & { status: SammelSchrittStatus; lauf: string | null; text: string | null })[]
text: string | null
}
export interface ProtokollEintrag {
zeit: number
art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege"
titel: string
text: string | null
stufe: "ok" | "info" | "warn" | "fehler"
ziel: string | null
details: string | null
}
export interface HomelabEinstellungen {
arcane: {
url: string
schluessel: "hinterlegt" | "fehlt" | "umgebung"
echt: boolean
echt_quelle: "einstellung" | "umgebung" | "standard"
}
karenz_h: number
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
}
+80
View File
@@ -0,0 +1,80 @@
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { bytesProSekunde, laufzeit, letzterWert, obergrenze, wertText, zustandBei } from "@/lib/messung"
import type { BausteinStand, ZielStand } from "@/lib/typen"
import { gesamtlage, kurzform, kurzformen } from "@/lib/uebersicht"
const b = (x: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
name: x.id, installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null, ...x,
})
const knopf = { methode: "POST", pfad: "/x", frage: "?", label: "Jetzt updaten" }
const ziel = (id: string, name: string, bausteine: BausteinStand[], art = "container"): ZielStand => ({
id, name, art, instanz: "homelab", erreichbar: true, geprueft: null, rueckweg: null, zaehlung: {}, offen: 0, bausteine,
})
describe("kurzform", () => {
it("fasst App und Pakete eines Geräts in eine Zeile, samt Grund ohne Knopf", () => {
const netbird = ziel("ct-102", "NetBird", [
b({ id: "app", zustand: "neu", verfuegbar: "0.79.0",
grund: "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06." }),
b({ id: "os", zustand: "neu", kurz: "48 Pakete", aktion: knopf }),
])
expect(kurzform(netbird)).toEqual({ id: "ct-102", name: "NetBird", teile: ["App → 0.79.0 (ab 26.09. 08:06)", "48 Pakete"], knopf: true })
})
it("nennt Bausteine der KI-Box mit ihrem Namen", () => {
const box = ziel("ki-box", "KI-Box", [
b({ id: "os", name: "Betriebssystem", zustand: "neu", kurz: "10 Pakete", aktion: knopf }),
b({ id: "hermes", name: "Hermes", zustand: "neu", kurz: "926 Änderungen", aktion: knopf }),
b({ id: "engine", name: "Motor", zustand: "aktuell" }),
], "ki-box")
expect(kurzform(box)?.teile).toEqual(["10 Pakete", "Hermes · 926 Änderungen"])
})
it("lässt aktuelle Geräte weg und stellt Knopf-Updates nach vorn", () => {
const arcane = ziel("vm-106", "Arcane", [b({ id: "arcane", zustand: "neu", verfuegbar: "2.13.1", grund: "über die eigene Oberfläche" })])
const gitea = ziel("ct-104", "Gitea", [b({ id: "os", zustand: "neu", kurz: "73 Pakete", aktion: knopf })])
const ruhig = ziel("ct-103", "PVE Scripts", [b({ id: "app", zustand: "aktuell" })])
expect(kurzformen([arcane, ruhig, gitea]).map((z) => z.id)).toEqual(["ct-104", "vm-106"])
expect(kurzform(arcane)?.teile).toEqual(["App → 2.13.1 (über die App)"])
})
})
describe("gesamtlage", () => {
const leer = { getrennt: [], stumm: [], hinweiseRot: 0, hinweise: 0, updates: 0 }
it("stellt Verbindung vor Störung vor Hinweis vor Updates", () => {
expect(gesamtlage({ ...leer, getrennt: ["Der Homelab-Teil"], hinweise: 2 }).stufe).toBe("rot")
expect(gesamtlage({ ...leer, stumm: ["AdGuard"] })).toMatchObject({ stufe: "rot", titel: "Störung" })
expect(gesamtlage({ ...leer, hinweise: 1, updates: 3 })).toMatchObject({ stufe: "gelb", titel: "1 Hinweis" })
expect(gesamtlage({ ...leer, updates: 3 })).toMatchObject({ stufe: "info", text: "3 Updates stehen bereit." })
expect(gesamtlage(leer).stufe).toBe("ok")
})
})
describe("Messwerte", () => {
it("formatiert Raten, Laufzeiten und Einheiten", () => {
expect(bytesProSekunde(812)).toBe("812 B/s")
expect(bytesProSekunde(45_300)).toBe("45,3 KB/s")
expect(bytesProSekunde(12_100_000)).toBe("12,1 MB/s")
expect(laufzeit(3 * 86400 + 4 * 3600)).toBe("3 T 4 h")
expect(laufzeit(5 * 3600 + 12 * 60)).toBe("5 h 12 min")
expect(wertText(null, "prozent")).toBe("–")
expect(wertText(42.4, "grad")).toBe("42 °C")
})
it("findet runde Obergrenzen und den letzten Wert vor einer Lücke", () => {
expect(obergrenze([[[0, 12]]], "prozent")).toBe(100)
expect(obergrenze([[[0, 3_700_000]]], "bytes_s")).toBe(5_000_000)
expect(obergrenze([[[0, 71]]], "grad")).toBe(80)
expect(letzterWert([[0, 5], [60, 7], [120, null]])).toBe(7)
expect(zustandBei(92, 80, 90)).toBe("rot")
expect(zustandBei(null, 80, 90)).toBe("ruhig")
})
it("unterbricht Linien und Flächen an Lücken", () => {
const x = (t: number) => t
const y = (v: number) => 100 - v
expect(linienPfad([[0, 10], [1, 20], [2, null], [3, 30]], x, y)).toBe("M0.0,90.0L1.0,80.0M3.0,70.0")
const flaeche = flaechenPfad([[0, 10], [1, 20], [2, null], [3, 30], [4, 40]], x, y, 100)
expect(flaeche.match(/Z/g)?.length).toBe(2)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { systemBaustein, appBaustein } from "@/lib/homelab"
import type { BausteinStand, ZielStand } from "@/lib/typen"
// Die Kurzform für die Übersicht: wer hat wo welche Updates — in einer Zeile je Gerät.
export interface KurzZeile {
id: string
name: string
teile: string[]
/** Etwas davon lässt sich per Knopf einspielen. */
knopf: boolean
}
/** Warum ein Update keinen Knopf hat, in zwei, drei Worten. */
function wartetText(b: BausteinStand): string | null {
if (b.aktion) return null
const g = b.grund ?? ""
const ab = g.match(/erst ab (\d{2}\.\d{2}\.(?:\d{4})? ?\d{2}:\d{2})/)?.[1]
if (ab) return `ab ${ab}`
if (/eigene Oberfläche|eigenen Oberfläche|eigenes Update/i.test(g)) return "über die App"
return null
}
/** „App → 0.79.0 (ab 26.09. 08:06)“, „48 Pakete“, „Images“ — was an einem Gerät ansteht. */
export function kurzform(ziel: ZielStand): KurzZeile | null {
const neu = ziel.bausteine.filter((b) => b.zustand === "neu")
if (neu.length === 0) return null
const teile: string[] = []
const app = appBaustein(ziel)
if (app?.zustand === "neu") {
const w = wartetText(app)
teile.push(`App → ${app.verfuegbar ?? "neu"}${w ? ` (${w})` : ""}`)
}
const sys = systemBaustein(ziel)
if (sys?.zustand === "neu") teile.push(sys.id === "docker" ? "Images" : sys.kurz || "Pakete")
// KI-Box: Bausteine heißen selbst (Betriebssystem, Hermes, Motor …).
for (const b of neu) {
if (b === app || b === sys) continue
teile.push(`${b.name}${b.kurz ? ` · ${b.kurz}` : ""}`)
}
return { id: ziel.id, name: ziel.name, teile, knopf: neu.some((b) => !!b.aktion) }
}
/** Die KI-Box ist ein Gerät mit mehreren Bausteinen — dort steht je Baustein eine Zeile. */
function bausteinZeilen(ziel: ZielStand): KurzZeile[] {
return ziel.bausteine
.filter((b) => b.zustand === "neu")
.map((b) => ({ id: `${ziel.id}-${b.id}`, name: b.name, teile: [b.kurz || b.verfuegbar || "neu"], knopf: !!b.aktion }))
}
/** Alle Kurzzeilen eines Bereichs: erst, was per Knopf geht, dann der Rest. */
export function kurzformen(ziele: ZielStand[]): KurzZeile[] {
const zeilen = ziele.flatMap((z) => (z.art === "ki-box" ? bausteinZeilen(z) : [kurzform(z)]))
.filter((z): z is KurzZeile => z !== null)
return [...zeilen.filter((z) => z.knopf), ...zeilen.filter((z) => !z.knopf)]
}
export type Lagestufe = "ok" | "info" | "gelb" | "rot"
/** Die Gesamtlage über beide Bereiche — Störung vor Hinweis vor offenen Updates. */
export function gesamtlage(e: {
getrennt: string[]
stumm: string[]
hinweiseRot: number
hinweise: number
updates: number
}): { stufe: Lagestufe; titel: string; text: string } {
if (e.getrennt.length > 0) {
return { stufe: "rot", titel: "Verbindung weg", text: `${e.getrennt.join(" und ")} antwortet nicht.` }
}
if (e.stumm.length > 0 || e.hinweiseRot > 0) {
const was = e.stumm.length > 0 ? `${e.stumm.join(", ")} antwortet nicht` : "Ein roter Hinweis wartet"
return { stufe: "rot", titel: "Störung", text: `${was}.` }
}
if (e.hinweise > 0) {
return { stufe: "gelb", titel: e.hinweise === 1 ? "1 Hinweis" : `${e.hinweise} Hinweise`, text: "Bei Gelegenheit ansehen." }
}
if (e.updates > 0) {
return { stufe: "info", titel: "Alles läuft", text: `${e.updates} ${e.updates === 1 ? "Update steht" : "Updates stehen"} bereit.` }
}
return { stufe: "ok", titel: "Alles läuft", text: "Keine Hinweise, keine offenen Updates." }
}