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:
co-authored by
Claude Opus 5.5
parent
d524e03c68
commit
db452aa38a
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user