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
@@ -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"],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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." }
|
||||
}
|
||||
Reference in New Issue
Block a user