design v5: Homelab-Cockpit, Updates und Snapshots im neuen Look; Rückweg testen und Aufräumen per Klick

- Knöpfe in normaler Schreibung, Hauptknopf mit Verlauf und Schein, Rest Glas.
- Lagebild des Homelab-Cockpits: Statusblock mit Schein, Geräte als Kacheln
  mit Logo, Zustandspunkt und Version; Klick führt zu den Updates bzw. zur
  Zeile in der Geräte-Tabelle. Lage-Texte in normaler Schreibung.
- Geräte-, Update- und Snapshot-Liste mit Logos; Balken in Bereichsfarbe.
- Snapshots: je Gerät „Rückweg testen“ (Rückfrage vom Homelab-Teil) und oben
  „Alle Update-Rückwege löschen“ — nur Snapshots mit Herkunft „update“ und
  Sicherungen des Orchestrators, nacheinander, eine Meldung am Ende (nur per
  Klick des Users; per Knopf/von Hand angelegte bleiben).
- Test gegen Variablen namens „Symbol“ (React Compiler braucht Symbol.for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 15:40:17 +02:00
co-authored by Claude Opus 5.5
parent d524e03c68
commit db452aa38a
18 changed files with 394 additions and 115 deletions
+3 -2
View File
@@ -12,7 +12,8 @@ export function Panel({
children,
className,
id,
symbol: Symbol,
// Nicht „Symbol“ nennen: Der React Compiler braucht Symbol.for(…) im selben Gültigkeitsbereich.
symbol: Zeichen,
}: {
titel?: string
rechts?: ReactNode
@@ -31,7 +32,7 @@ export function Panel({
<div className="flex flex-wrap items-center justify-between gap-3">
{titel && (
<h2 className="flex items-center gap-2.5 text-[17px] leading-tight font-semibold tracking-[0.005em] text-foreground">
{Symbol && <Symbol className="size-[18px] text-text-3" aria-hidden />}
{Zeichen && <Zeichen className="size-[18px] text-text-3" aria-hidden />}
{titel}
</h2>
)}
+25 -16
View File
@@ -1,24 +1,27 @@
import { Link } from "@tanstack/react-router"
import { cn } from "cn"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild } from "@/lib/geraetebild"
import { artText, plattenAnteil } from "@/lib/homelab"
import { laufzeit } from "@/lib/messung"
import type { GeraetMesswerte, ZielStand } from "@/lib/typen"
const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]"
const SPALTEN = "md:grid-cols-[minmax(0,1.35fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]"
const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",")
/** Balken mit Wert: ruhig in Blau, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich in Zahlen. */
/** Balken mit Wert (v5): ruhig in der Farbe des Homelabs, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich
* in Zahlen. */
function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) {
if (wert === null || wert === undefined) return <span className="text-sm text-text-3">–</span>
const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "var(--reihe-1)"
const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "color-mix(in srgb, var(--lab) 80%, transparent)"
const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2"
return (
<span className="flex min-w-0 flex-col gap-1">
<span className="flex min-w-0 flex-col gap-1.5">
<span className={cn("ziffern text-sm", text)}>
{Math.round(wert)} %{zusatz && <span className="font-sans text-xs text-text-3"> · {zusatz}</span>}
</span>
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} />
</span>
</span>
@@ -34,15 +37,16 @@ function rang(z: ZielStand): number {
}
/**
* Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät. Die Zahlen kommen vom Monitoring
* (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des Ausführers.
* Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät, vorn das Logo mit Zustandspunkt. Die
* Zahlen kommen vom Monitoring (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des
* Ausführers.
*/
export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) {
const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell]))
const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b))
return (
<div role="table" aria-label="Zustand der Geräte" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid", SPALTEN)}>
<div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">Netz</span>
<span role="columnheader">CPU</span>
@@ -55,15 +59,20 @@ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte
const platte = a?.platte ?? (plattenAnteil(z) !== null ? plattenAnteil(z)! * 100 : null)
const ram = a?.ram_used && a.ram_total ? `${gb(a.ram_used)} / ${gb(a.ram_total)} GB` : undefined
return (
<div key={z.id} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2 border-t border-linie py-3 md:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1">
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text">
{z.name}
</Link>
{artText(z) !== z.name && <span className="text-sm text-text-3">{artText(z)}</span>}
<div key={z.id} id={`geraet-${z.id}`} role="row"
className={cn("grid scroll-mt-24 grid-cols-2 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3 md:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 items-center gap-3 md:col-span-1">
<Geraetesymbol bild={geraetebild(z)} groesse="sm"
punkt={z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : null} />
<span className="flex min-w-0 flex-col">
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text">
{z.name}
</Link>
{artText(z) !== z.name && <span className="text-[13px] text-text-3">{artText(z)}</span>}
</span>
</div>
<span role="cell" className={cn("schild flex items-center gap-1.5 text-xs", z.erreichbar === false ? "text-rot-text" : "text-text-3")}>
{z.erreichbar !== null && <span aria-hidden className={cn("size-2 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />}
<span role="cell" className={cn("flex items-center gap-1.5 text-[13px]", z.erreichbar === false ? "text-rot-text" : "text-text-3")}>
{z.erreichbar !== null && <span aria-hidden className={cn("size-1.5 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />}
{z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span>
+62 -29
View File
@@ -1,41 +1,74 @@
import { cn } from "cn"
import { Lampe } from "@/components/cockpit/Lampe"
import { LEUCHTE } from "@/lib/anzeige"
import type { Lage } from "@/lib/homelab"
import type { Lampe as LampenDaten } from "@/lib/typen"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
import type { LampenZustand, ZielStand } from "@/lib/typen"
// Farbe der Lage (Design v5): Tönung und Schein des Statusblocks. Zustand immer auch in Worten (oben/mitte/unten).
const ART: Record<Lage["art"], { block: string; glut: string; text: string }> = {
ok: { block: "bg-gruen/[0.07] ring-gruen/30", glut: "var(--gruen)", text: "text-gruen" },
info: { block: "bg-cyan/[0.07] ring-cyan/30", glut: "var(--cyan)", text: "text-cyan-text" },
gelb: { block: "bg-bernstein/[0.08] ring-bernstein/35", glut: "var(--bernstein)", text: "text-bernstein" },
rot: { block: "bg-rot/10 ring-rot/40", glut: "var(--rot)", text: "text-rot-text" },
stumm: { block: "bg-white/[0.04] ring-white/15", glut: "var(--text-3)", text: "text-text-2" },
}
const PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
const WERT: Record<LampenZustand, string> = {
ok: "text-text-3", aus: "text-text-3", warn: "text-bernstein", info: "text-cyan-text", fehler: "text-rot-text",
}
/**
* Die Lage im Homelab auf einen Blick — gebaut wie das Warnpanel der Startseite: links die große
* Leuchte, rechts je Gerät eine Lampe. Grün läuft, Cyan hat ein Update, Rot antwortet nicht.
* Die Lage im Homelab auf einen Blick: links der Statusblock (drücken führt dorthin, wo etwas zu tun ist), rechts je
* Gerät eine Kachel mit Logo, Zustandspunkt und dem, was ansteht — grün läuft, Cyan hat ein Update, Rot antwortet nicht.
*/
export function Lagebild({ lage, lampen, onAnsehen }: {
export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: {
lage: Lage
lampen: LampenDaten[]
ziele: ZielStand[]
imUpdate: Set<string>
onAnsehen: () => void
/** Klick auf eine Kachel: Updates, wenn etwas ansteht, sonst die Zeile in der Geräte-Tabelle. */
onGeraet: (ziel: ZielStand, hatUpdate: boolean) => void
}) {
const a = ART[lage.art]
const aktiv = lage.ziel !== null
return (
<section aria-label="Lage im Homelab" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${lage.oben} ${lage.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[lage.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{lage.oben}</span>
<span className="text-center text-[34px] leading-none font-bold tracking-[0.04em] sm:text-[38px]">{lage.mitte}</span>
<span className="font-sans text-sm opacity-80">{lage.unten}</span>
</button>
<ul aria-label="Geräte" className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => (
<Lampe key={l.id} lampe={l} />
))}
</ul>
<section aria-label="Lage im Homelab" className="auftauchen flaeche relative overflow-hidden rounded-3xl">
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
<div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${lage.oben}: ${lage.mitte}`}
className={cn(
"flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-colors",
a.block, aktiv ? "cursor-pointer hover:brightness-125" : "cursor-default",
)}
>
<span className="etikett text-[11px] tracking-[0.18em] text-text-3">{lage.oben}</span>
<span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{lage.mitte}</span>
<span className="text-[13.5px] text-text-2">{lage.unten}</span>
</button>
<ul aria-label="Geräte" className="m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4">
{ziele.map((z) => {
const l = lampeFuer(z, imUpdate.has(z.id))
return (
<li key={z.id}>
<button type="button" onClick={() => onGeraet(z, l.zustand === "info")}
className="flaeche-innen group flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:border-white/20 hover:bg-white/[0.05]">
<Geraetesymbol bild={geraetebild(z)} punkt={PUNKT[l.zustand]} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{kurzname(z)}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[l.zustand])}>{l.wert}</span>
</span>
</button>
</li>
)
})}
</ul>
</div>
</section>
)
}
@@ -79,16 +79,16 @@ export function SammellaufFortschritt({ lauf, onQuittieren }: { lauf: Sammellauf
const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length
const l = LAUF[lauf.status]
return (
<section aria-label="Alle aktualisieren" className="flex flex-col gap-3 rounded-xl border border-cyan-rand/60 bg-cyan-grund/40 p-4">
<section aria-label="Alle aktualisieren" className="flex flex-col gap-3 rounded-2xl bg-cyan/[0.06] p-4 ring-1 ring-cyan/30 ring-inset">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="schild text-sm tracking-[0.18em] text-cyan">Alle aktualisieren · {fertig} von {lauf.schritte.length}</span>
<span className="text-[15px] font-semibold text-cyan-text">Alle aktualisieren · {fertig} von {lauf.schritte.length}</span>
<span className="flex items-center gap-2">
<span className="ziffern text-xs text-text-3">{flugplanZeit(new Date(lauf.start * 1000).toISOString())}</span>
<Chip art={l.art}>{l.text}</Chip>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<div className="h-full rounded-full bg-cyan transition-all" style={{ width: `${(fertig / Math.max(1, lauf.schritte.length)) * 100}%` }} />
<div className="h-1.5 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<div className="h-full rounded-full bg-cyan shadow-[0_0_10px_var(--cyan)] transition-all" style={{ width: `${(fertig / Math.max(1, lauf.schritte.length)) * 100}%` }} />
</div>
<ol className="m-0 grid list-none gap-x-6 gap-y-1.5 p-0 sm:grid-cols-2">
{lauf.schritte.map((s) => {
@@ -1,9 +1,11 @@
import { type ReactNode, useState } from "react"
import { CircleCheck, CircleX, Plus, Trash2, Undo2 } from "lucide-react"
import { CircleCheck, CircleX, FlaskConical, Plus, Trash2, Undo2 } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots"
import type {
HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion,
@@ -17,7 +19,7 @@ type Frage = { name: string; aktion: ZielAktion; gefahr: boolean } | null
// ab xl alles in einer Zeile; die Knöpfe haben eine feste Breite, damit die Spalten fluchten
// (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.)
const ZEILE =
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-linie py-3 " +
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-white/[0.06] py-3 " +
"[grid-template-areas:'name_herkunft'_'zeit_zeit'_'text_text'_'knoepfe_knoepfe'] " +
"md:grid-rows-[auto_auto_1fr] md:gap-x-4 md:[grid-template-areas:'name_herkunft'_'zeit_knoepfe'_'text_knoepfe'] " +
"xl:grid-cols-[minmax(10.5rem,1.1fr)_7.5rem_minmax(0,1fr)_minmax(0,1.4fr)_11rem] xl:grid-rows-none xl:items-center " +
@@ -119,22 +121,35 @@ function Geraet({ g, jetzt, gesperrt, onFrage }: {
onFrage: (f: Frage) => void
}) {
const leer = g.snapshots.length === 0 && g.sicherungen.length === 0
const anlegen = g.anlegen
const { anlegen, probe } = g
return (
<section aria-label={g.name} className="flex min-w-0 flex-col gap-2 border-t border-linie-stark pt-4 first:border-t-0 first:pt-0">
<section aria-label={g.name} className="flaeche-innen flex min-w-0 flex-col gap-2 rounded-2xl px-4 py-3.5">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="flex min-w-0 flex-col">
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3>
<span className="text-sm text-text-3">{g.wo}</span>
<div className="flex min-w-0 items-center gap-3">
<Geraetesymbol bild={geraetebild({ art: g.art, name: g.name })} punkt={g.status === "running" ? "ok" : g.status ? "aus" : null} />
<div className="flex min-w-0 flex-col">
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3>
<span className="text-[13px] text-text-3">{g.wo}</span>
</div>
</div>
{g.laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {g.laeuft} …</p>
<p role="status" className="text-sm text-cyan-text">Läuft: {g.laeuft} …</p>
) : (
anlegen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: anlegen, gefahr: false })}>
<Plus aria-hidden /> Snapshot anlegen
</Button>
(anlegen || probe) && (
<span className="flex flex-wrap gap-2">
{probe && (
<Button variant="ghost" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: probe, gefahr: true })}>
<FlaskConical aria-hidden /> Rückweg testen
</Button>
)}
{anlegen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: anlegen, gefahr: false })}>
<Plus aria-hidden /> Snapshot anlegen
</Button>
)}
</span>
)
)}
</div>
@@ -186,7 +201,7 @@ export function SnapshotListe({ daten, gesperrt, onAktion }: {
const [frage, setFrage] = useState<Frage>(null)
return (
<>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
{daten.geraete.map((g) => (
<Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} />
))}
+10 -8
View File
@@ -11,7 +11,9 @@ import type {
import { flugplanZeit } from "@/lib/zeit"
const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" }
const FARBE: Record<SpeicherStufe, string> = { ok: "var(--reihe-1)", gelb: "var(--bernstein)", rot: "var(--rot)" }
const FARBE: Record<SpeicherStufe, string> = {
ok: "color-mix(in srgb, var(--lab) 80%, transparent)", gelb: "var(--bernstein)", rot: "var(--rot)",
}
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
/** Füllstand mit Marken bei 80 % (gelb) und 90 % (rot, 10 % frei). */
@@ -24,7 +26,7 @@ function Fuellstand({ anteil, stufe, name }: { anteil: number; stufe: SpeicherSt
aria-valuenow={prozent}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-2 w-full overflow-hidden rounded-full bg-erhaben"
className="relative h-2 w-full overflow-hidden rounded-full bg-white/[0.07]"
>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, prozent))}%`, background: FARBE[stufe] }} />
<span aria-hidden className="absolute inset-y-0 left-[80%] w-px bg-bernstein/70" />
@@ -41,7 +43,7 @@ function Kachel({ titel, unter, chip, chipText, children }: {
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie bg-erhaben/40 p-4">
<div className="flaeche-innen flex min-w-0 flex-col gap-3 rounded-2xl p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{titel}</span>
@@ -164,7 +166,7 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna
<SicherungsKachel s={daten.sicherungen} />
</div>
{daten.empfehlungen.length > 0 && (
<details open={knapp} className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-3">
<details open={knapp} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
@@ -205,7 +207,7 @@ const ORT: Record<string, string> = { pool: "Speicherpool", systemplatte: "Syste
function MitBefehlen({ text }: { text: string }) {
return text.split(/`([^`]+)`/).map((teil, i) =>
i % 2 === 1 ? (
<code key={i} className="rounded bg-erhaben px-1 font-mono text-[13px] text-foreground [font-variant-ligatures:none]">
<code key={i} className="rounded-md bg-white/[0.07] px-1 font-mono text-[13px] text-foreground [font-variant-ligatures:none]">
{teil}
</code>
) : (
@@ -222,16 +224,16 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: {
verweis?: ReactNode
}) {
return (
<li className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
<li className="flex flex-col gap-1 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-[15px] font-medium break-words">{e.titel}</span>
<span className="ziffern text-sm text-text-2">{e.platz ? `≈ ${groesse(e.platz)}` : "Größe unbekannt"}</span>
</div>
{e.ort && <span className="schild text-xs text-text-3">{ORT[e.ort] ?? e.ort}</span>}
{e.ort && <span className="etikett text-[10.5px] tracking-[0.14em] text-text-3">{ORT[e.ort] ?? e.ort}</span>}
<p className="text-sm break-words text-text-2"><MitBefehlen text={e.text} /></p>
{verweis}
{e.aktion && (laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {laeuft}</p>
<p role="status" className="text-sm text-cyan-text">Läuft: {laeuft}</p>
) : (
<Button variant="outline" size="sm" className="mt-1 self-start" disabled={gesperrt} onClick={() => onFrage(e.aktion!)}>
{e.aktion.label}
@@ -3,7 +3,9 @@ import { Search } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import {
appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText,
} from "@/lib/homelab"
@@ -25,7 +27,7 @@ function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
<span className="flex min-w-0 flex-col gap-0.5">
<span className="ziffern flex flex-wrap items-baseline gap-x-2 text-sm">
<span className={b.zustand === "aktuell" ? "text-foreground" : "text-text-2"}>{laeuft}</span>
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan">→ {b.verfuegbar}</span>}
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan-text">→ {b.verfuegbar}</span>}
{b.zustand === "aktuell" && <span className="font-sans text-xs text-gruen-text">aktuell</span>}
{b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span>
@@ -39,7 +41,7 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker"
let text
if (b.zustand === "neu") text = <span className="text-cyan">{b.kurz || "Update bereit"}</span>
if (b.zustand === "neu") text = <span className="text-cyan-text">{b.kurz || "Update bereit"}</span>
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else {
const datum = listenAlter(b.grund)
@@ -80,14 +82,17 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
<div
role="row"
className={cn(
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-linie py-3.5 md:items-center",
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3.5 md:items-center",
SPALTEN,
ruhig && "opacity-75",
)}
>
<div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
{artText(ziel) !== ziel.name && <span className="text-sm text-text-3">{artText(ziel)}</span>}
<div role="rowheader" className="flex min-w-0 items-center gap-3">
<Geraetesymbol bild={geraetebild(ziel)} groesse="sm" punkt={knoepfe.length ? "info" : null} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
{artText(ziel) !== ziel.name && <span className="text-[13px] text-text-3">{artText(ziel)}</span>}
</span>
</div>
<span role="cell" className="min-w-0"><span className="text-xs text-text-3 md:hidden">App </span><AppZelle ziel={ziel} b={app} /></span>
<span role="cell" className="min-w-0">
@@ -97,7 +102,7 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
<span role="cell" className="text-sm text-text-3">{knoepfe.length ? rueckwegText(ziel) : ""}</span>
<span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-stretch">
{laeuft ? (
<span role="status" className="text-sm text-cyan">Läuft: {laeuft}</span>
<span role="status" className="text-sm text-cyan-text">Läuft: {laeuft}</span>
) : (
knoepfe.map((b) => (
<Button key={b.id} variant="outline" size="sm" disabled={gesperrt} onClick={() => b.aktion && onFrage({ ziel, aktion: b.aktion })}>
@@ -126,7 +131,7 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
return (
<>
<div role="table" aria-label="Updates je Gerät" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid", SPALTEN)}>
<div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">App</span>
<span role="columnheader">Pakete</span>
+11 -11
View File
@@ -3,24 +3,24 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
// shadcn-Knopf im Cockpit-Stil: Instrumenten-Schrift, mindestens 44 px hoch (Touch am Handy).
// default = Bernstein (die eine Hauptaktion), outline = neutral, info = Cyan, gefahr = Rot.
// shadcn-Knopf im Cockpit-Stil (Design v5): normale Schreibung, halbfett, mindestens 44 px hoch (Touch am Handy).
// default = Bernstein mit leichtem Verlauf und Schein (die eine Hauptaktion), outline = Glas, info = Cyan, gefahr = Rot.
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent font-anzeige font-semibold uppercase tracking-[0.12em] whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-xl border border-transparent font-semibold tracking-[0.005em] whitespace-nowrap transition-[background-color,border-color,color,box-shadow,filter] outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-bernstein text-primary-foreground hover:bg-bernstein-hell",
outline: "border-linie-stark bg-erhaben text-foreground hover:border-text-3 hover:bg-[#232931]",
info: "border-cyan-rand bg-transparent text-cyan hover:bg-cyan-grund",
gefahr: "border-rot-rand bg-rot-grund text-rot-text hover:bg-[#3a1512]",
ghost: "text-text-2 hover:bg-erhaben hover:text-foreground",
link: "px-0 text-cyan normal-case tracking-normal underline-offset-4 hover:underline",
default: "bg-gradient-to-b from-bernstein-hell to-bernstein text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.35),0_8px_22px_-10px_var(--bernstein)] hover:brightness-110",
outline: "border-white/12 bg-white/[0.04] text-foreground hover:border-white/20 hover:bg-white/[0.08]",
info: "border-cyan/35 bg-cyan/[0.08] text-cyan-text hover:bg-cyan/15",
gefahr: "border-rot/40 bg-rot/10 text-rot-text hover:bg-rot/[0.18]",
ghost: "text-text-2 hover:bg-white/[0.06] hover:text-foreground",
link: "px-0 text-cyan underline-offset-4 hover:underline",
},
size: {
default: "h-11 px-5 text-[15px]",
sm: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
sm: "h-10 px-4 text-[14px]",
lg: "h-12 px-6 text-[15.5px]",
icon: "size-11",
// Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch.
"icon-sm": "size-11",
+7 -7
View File
@@ -67,21 +67,21 @@ describe("lampeFuer", () => {
describe("lage", () => {
it("zählt nur Updates mit Knopf", () => {
expect(lage(stand([netbird, arcane]), [], 0, null)).toMatchObject({ art: "info", mitte: "1 UPDATE", ziel: "updates" })
expect(lage(stand([netbird, arcane]), [], 0, null)).toMatchObject({ art: "info", mitte: "1 Update", ziel: "updates" })
})
it("stellt Störung vor Hinweis vor Update", () => {
expect(lage(stand([{ ...npm, erreichbar: false }, netbird]), [hinweis("gelb")], 0, null)).toMatchObject({
art: "rot", mitte: "1 GERÄT STUMM", unten: "NPMplus",
art: "rot", mitte: "1 Gerät stumm", unten: "NPMplus",
})
expect(lage(stand([netbird]), [hinweis("gelb")], 0, null)).toMatchObject({ art: "gelb", mitte: "1 HINWEIS" })
expect(lage(stand([netbird]), [hinweis("rot"), hinweis("gelb")], 0, null)).toMatchObject({ art: "rot", mitte: "2 HINWEISE" })
expect(lage(stand([netbird], false), [], 0, null)).toMatchObject({ art: "gelb", mitte: "AUSFÜHRER STUMM" })
expect(lage(stand([netbird]), [], 1, null)).toMatchObject({ art: "info", mitte: "UPDATE LÄUFT" })
expect(lage(stand([netbird]), [hinweis("gelb")], 0, null)).toMatchObject({ art: "gelb", mitte: "1 Hinweis" })
expect(lage(stand([netbird]), [hinweis("rot"), hinweis("gelb")], 0, null)).toMatchObject({ art: "rot", mitte: "2 Hinweise" })
expect(lage(stand([netbird], false), [], 0, null)).toMatchObject({ art: "gelb", mitte: "Ausführer stumm" })
expect(lage(stand([netbird]), [], 1, null)).toMatchObject({ art: "info", mitte: "Update läuft" })
})
it("sagt „alles aktuell“ nur ohne offene Updates", () => {
expect(lage(stand([npm]), [], 0, "HEUTE 20:14")).toMatchObject({ art: "ok", mitte: "ALLES AKTUELL", unten: "Stand HEUTE 20:14" })
expect(lage(stand([npm]), [], 0, "HEUTE 20:14")).toMatchObject({ art: "ok", mitte: "Alles aktuell", unten: "Stand HEUTE 20:14" })
})
})
+6 -6
View File
@@ -127,32 +127,32 @@ export interface Lage {
ziel: "hinweise" | "updates" | "geraete" | null
}
const hinweisZahl = (n: number) => `${n} HINWEIS${n === 1 ? "" : "E"}`
const hinweisZahl = (n: number) => `${n} Hinweis${n === 1 ? "" : "e"}`
/** Die große Leuchte oben links — Störung vor Hinweis vor laufendem Update vor offenen Updates. */
export function lage(stand: HomelabStand, hinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
const stumm = stand.ziele.filter((z) => z.erreichbar === false)
if (stumm.length > 0) {
const mitte = stumm.length === 1 ? "1 GERÄT STUMM" : `${stumm.length} GERÄTE STUMM`
const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm`
return { art: "rot", oben: "Störung", mitte, unten: stumm.map(kurzname).join(", "), ziel: "geraete" }
}
if (hinweise.some((h) => h.stufe === "rot")) {
return { art: "rot", oben: "Störung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
}
if (!stand.ausfuehrer.verbunden) {
return { art: "gelb", oben: "Achtung", mitte: "AUSFÜHRER STUMM", unten: "Die Geräte zeigen den letzten Stand", ziel: null }
return { art: "gelb", oben: "Achtung", mitte: "Ausführer stumm", unten: "Die Geräte zeigen den letzten Stand", ziel: null }
}
if (hinweise.length > 0) {
return { art: "gelb", oben: "Achtung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
}
if (laufen > 0) {
return { art: "info", oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Das Ergebnis kommt als Meldung", ziel: "updates" }
return { art: "info", oben: "Wartung", mitte: "Update läuft", unten: "Das Ergebnis kommt als Meldung", ziel: "updates" }
}
const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length
if (knopf > 0) {
return { art: "info", oben: "Homelab", mitte: `${knopf} UPDATE${knopf === 1 ? "" : "S"}`, unten: "Drücken zum Ansehen", ziel: "updates" }
return { art: "info", oben: "Homelab", mitte: `${knopf} Update${knopf === 1 ? "" : "s"}`, unten: "Drücken zum Ansehen", ziel: "updates" }
}
return { art: "ok", oben: "Homelab", mitte: "ALLES AKTUELL", unten: berichtZeit ? `Stand ${berichtZeit}` : "Keine offenen Updates", ziel: null }
return { art: "ok", oben: "Homelab", mitte: "Alles aktuell", unten: berichtZeit ? `Stand ${berichtZeit}` : "Keine offenen Updates", ziel: null }
}
/** „unklar · Listen vom 22.10.2025“ statt des ganzen Satzes aus dem Grund. */
+6
View File
@@ -611,6 +611,8 @@ export interface SnapshotGeraet {
erlaubt: boolean
/** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */
anlegen: ZielAktion | null
/** Knopf „Rückweg testen“ (seit 25.09.2026): Snapshot bzw. Sicherung, absichtlich rot, zurück, Prüfung. */
probe?: ZielAktion | null
grund: string | null
/** Was für das Gerät gerade läuft (auch ein Update), sonst null. */
laeuft: string | null
@@ -631,4 +633,8 @@ export interface HomelabSnapshots {
details: boolean
geraete: SnapshotGeraet[]
summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null }
/** „Alle Update-Rückwege löschen“ (seit 25.09.2026) — nur, wo es welche gibt; Löschen nur per Klick. */
aufraeumen?: ZielAktion | null
/** Das Aufräumen arbeitet gerade seine Liste ab. */
aufraeumen_laeuft?: boolean
}
+20
View File
@@ -0,0 +1,20 @@
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join } from "node:path"
// Der React Compiler setzt seinen Zwischenspeicher oben in jeder Komponente mit Symbol.for(…) zurück. Heißt eine
// Variable oder ein ausgepackter Prop in derselben Funktion „Symbol“, stürzt die Seite ab („Cannot access 'Symbol'
// before initialization“) — passiert am 24.09. (Protokoll) und am 25.09.2026 (Panel). Dieser Test hält das fern.
function quellen(ordner: string): string[] {
return readdirSync(ordner).flatMap((name) => {
const pfad = join(ordner, name)
if (statSync(pfad).isDirectory()) return quellen(pfad)
return /\.(tsx|ts)$/.test(name) && !name.endsWith(".test.ts") ? [pfad] : []
})
}
it("keine Variable und kein ausgepackter Prop heißt Symbol", () => {
const verboten = /\b(?:const|let|var)\s+Symbol\b|[{,(]\s*\w+\s*:\s*Symbol\s*[,})=]/
const treffer = quellen(join(__dirname, "..")).filter((pfad) => verboten.test(readFileSync(pfad, "utf-8")))
expect(treffer).toEqual([])
})
+11 -6
View File
@@ -12,7 +12,7 @@ import {
useSammellauf, useSammellaufQuittieren,
useZielAktion,
} from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import { lage, offeneUpdates } from "@/lib/homelab"
import { istSnapshotLauf } from "@/lib/snapshots"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
@@ -48,7 +48,7 @@ function NochNichtEingerichtet() {
zurück.
</p>
<div className="flex flex-col gap-2">
<h3 className="schild text-sm tracking-[0.18em] text-text-3">Dazu kommen</h3>
<h3 className="etikett text-[11.5px] tracking-[0.16em] text-text-3">Dazu kommen</h3>
<ul className="flex flex-wrap gap-2">
{GERAETE.map((g) => (
<li key={g}>
@@ -87,7 +87,7 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}>
<ul className="m-0 flex list-none flex-col p-0">
{hinweise.map((h) => (
<li key={h.id} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-0">
<li key={h.id} className="flex flex-col gap-1 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-0">
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
{h.titel}
</span>
@@ -112,7 +112,7 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis })
: { art: "grau" as ChipArt, text: "unterbrochen" }
return (
<li key={l.id} className="flex flex-col gap-1.5 border-t border-linie py-3 first:border-t-0 first:pt-0">
<li key={l.id} className="flex flex-col gap-1.5 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{zeitAus(l.start)}</span>
<span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
@@ -134,7 +134,7 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
function AusfuehrerHinweis({ stand }: { stand: HomelabStand }) {
if (!stand.ausfuehrer.verbunden) {
return (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
<p role="alert" className="rounded-2xl bg-bernstein/[0.08] px-4 py-3 text-[15px] text-bernstein ring-1 ring-bernstein/35 ring-inset">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Die Geräte zeigen den letzten
bekannten Stand; Updates starten erst, wenn er wieder da ist.
@@ -207,11 +207,16 @@ export function HomelabSeite() {
<>
<Lagebild
lage={l}
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))}
ziele={stand.ziele}
imUpdate={imUpdate}
onAnsehen={() => {
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
}}
onGeraet={(z, hatUpdate) => {
if (hatUpdate) void navigate({ to: "/homelab/updates" })
else document.getElementById(`geraet-${z.id}`)?.scrollIntoView({ behavior: "smooth", block: "center" })
}}
/>
<AusfuehrerHinweis stand={stand} />
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
+36 -2
View File
@@ -1,5 +1,9 @@
import { useState } from "react"
import { Trash2 } from "lucide-react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import { SnapshotListe } from "@/components/homelab/SnapshotListe"
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
import { summeText, zeitpunktText } from "@/lib/snapshots"
@@ -14,6 +18,7 @@ export function HomelabSnapshotsSeite() {
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const abfrage = useHomelabSnapshots(aktiv)
const aktion = useSnapshotAktion()
const [aufraeumenFrage, setAufraeumenFrage] = useState(false)
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
@@ -34,10 +39,25 @@ export function HomelabSnapshotsSeite() {
angelegte stehen nur zur Ansicht da.
</p>
{grund && (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
<p role="alert" className="rounded-2xl bg-bernstein/[0.08] px-4 py-3 text-[15px] text-bernstein ring-1 ring-bernstein/35 ring-inset">
{grund}
</p>
)}
{/* Die Rückwege der letzten Updates auf einen Klick wegräumen (User-Wunsch 25.09.2026). */}
{(d.aufraeumen || d.aufraeumen_laeuft) && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-white/[0.03] px-4 py-3 ring-1 ring-white/10 ring-inset">
<p className="min-w-0 flex-1 text-[14.5px] text-text-2">
{d.aufraeumen_laeuft
? "Die Rückwege der letzten Updates werden gerade nacheinander gelöscht; am Ende kommt eine Meldung."
: "Nach grünen Updates bleibt je Gerät der Rückweg liegen, bis zum nächsten Update. Wegräumen geht hier auf einmal."}
</p>
{d.aufraeumen && (
<Button variant="outline" size="sm" disabled={aktion.isPending || !!grund} onClick={() => setAufraeumenFrage(true)}>
<Trash2 aria-hidden /> {d.aufraeumen.label}
</Button>
)}
</div>
)}
{d.bericht?.veraltet && (
<p className="text-[15px] text-bernstein">
Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein.
@@ -53,7 +73,8 @@ export function HomelabSnapshotsSeite() {
läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots.
</p>
)}
<SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund} onAktion={(a) => aktion.mutate(a)} />
<SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund || !!d.aufraeumen_laeuft}
onAktion={(a) => aktion.mutate(a)} />
<p className="text-sm text-text-3">
Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und
wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und
@@ -61,6 +82,19 @@ export function HomelabSnapshotsSeite() {
</p>
</>
)}
<Bestaetigen
offen={aufraeumenFrage && !!d.aufraeumen}
titel={d.aufraeumen?.label ?? ""}
text={d.aufraeumen?.frage ?? ""}
knopf={d.aufraeumen?.label ?? ""}
gefahr
gesperrt={aktion.isPending}
onJa={() => {
if (d.aufraeumen) aktion.mutate(d.aufraeumen)
setAufraeumenFrage(false)
}}
onNein={() => setAufraeumenFrage(false)}
/>
</Panel>
)
}
+2 -2
View File
@@ -232,14 +232,14 @@ function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" |
<ul aria-label="Hinweise" className="m-0 flex list-none flex-col gap-1.5 p-0">
{sortiert.slice(0, 3).map((h) => {
const rot = h.stufe === "rot"
const Symbol = rot ? TriangleAlert : CircleAlert
const Zeichen = rot ? TriangleAlert : CircleAlert
return (
<li key={h.id}>
<Link to={ziel} className={cn(
"flex items-start gap-2.5 rounded-xl px-3 py-2.5 text-[14px] ring-1 ring-inset transition-colors",
rot ? "bg-rot/10 text-rot-text ring-rot/35 hover:bg-rot/15" : "bg-bernstein/[0.08] text-bernstein ring-bernstein/30 hover:bg-bernstein/[0.13]",
)}>
<Symbol className="mt-0.5 size-4 shrink-0" aria-hidden />
<Zeichen className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">{h.titel}</span>
<span className="etikett shrink-0 pt-0.5 text-[10px] opacity-80">{rot ? "Störung" : "Hinweis"}</span>
</Link>