Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter
This commit is contained in:
@@ -45,6 +45,10 @@ const homelabUpdates = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/updates",
|
||||
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
|
||||
})
|
||||
const homelabSnapshots = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/snapshots",
|
||||
component: lazyRouteComponent(() => import("@/views/HomelabSnapshots"), "HomelabSnapshotsSeite"),
|
||||
})
|
||||
const homelabMonitoring = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/monitoring",
|
||||
component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"),
|
||||
@@ -79,6 +83,7 @@ export const router = createRouter({
|
||||
routeTree: wurzel.addChildren([
|
||||
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
|
||||
homelabProtokoll, einstellungen, altUpdates, altModelle,
|
||||
homelabSnapshots,
|
||||
]),
|
||||
defaultPreload: "intent",
|
||||
defaultErrorComponent: Absturz,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { HomelabSnapshots, SnapshotGeraet, ZielAktion } from "@/lib/typen"
|
||||
import { SnapshotListe } from "./SnapshotListe"
|
||||
|
||||
// Der Stand vom 25.09.2026 13:40: Gitea mit dem Rückweg des Updates von 11:26 und einem von Hand angelegten Snapshot,
|
||||
// der PBS mit seiner Sicherung (kein Snapshot möglich), Arcane ohne Freigabe, NetBird mitten im Anlegen.
|
||||
const JETZT = 1_790_334_000
|
||||
const aktion = (pfad: string, label: string, frage: string): ZielAktion => ({ methode: "POST", pfad, label, frage })
|
||||
|
||||
const ZURUECK = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/zuruecksetzen", "Zurücksetzen",
|
||||
"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 (mc2-20260925-112639) zurücksetzen? Alles, was sich " +
|
||||
"seitdem im Container geändert hat — Daten, Einstellungen, Updates —, ist danach verloren.")
|
||||
const LOESCHEN = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/loeschen", "Snapshot löschen",
|
||||
"Den Snapshot mc2-20260925-112639 von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen?")
|
||||
const ANLEGEN = aktion("/api/homelab/snapshots/ct-104/anlegen", "Snapshot anlegen",
|
||||
"Jetzt einen Snapshot von Gitea (Container 104) anlegen? Der Container läuft dabei weiter.")
|
||||
const SICHERUNG_WEG = aktion("/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen",
|
||||
"Sicherung löschen", "Die Sicherung vzdump-lxc-105-2026_09_25-11_29_57.tar.zst von Proxmox Backup Server … löschen?")
|
||||
|
||||
const basis = { vmid: 0, art: "container" as const, status: "running", erlaubt: true, anlegen: null, grund: null,
|
||||
laeuft: null, zuletzt: null, snapshots: [], sicherungen: [] }
|
||||
|
||||
const gitea: SnapshotGeraet = {
|
||||
...basis, id: "ct-104", vmid: 104, name: "Gitea", wo: "Container 104", anlegen: ANLEGEN,
|
||||
zuletzt: { ok: true, ende: JETZT - 600, text: "Gitea: Snapshot mc2-20260925-112639 gelöscht." },
|
||||
snapshots: [
|
||||
{ name: "vor-dem-umbau", zeit: 1_789_000_000, beschreibung: null, herkunft: "proxmox", eigen: false,
|
||||
loeschen: null, zuruecksetzen: null },
|
||||
{ name: "mc2-20260925-112639", zeit: 1_790_328_400, beschreibung: "Vor einem Update durch den Homelab Orchestrator",
|
||||
herkunft: "update", eigen: true, loeschen: LOESCHEN, zuruecksetzen: ZURUECK },
|
||||
],
|
||||
}
|
||||
const pbs: SnapshotGeraet = {
|
||||
...basis, id: "ct-105", vmid: 105, name: "Proxmox Backup Server", wo: "Container 105",
|
||||
grund: "Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich.",
|
||||
sicherungen: [{ datei: "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", speicher: "local", zeit: 1_790_328_597,
|
||||
groesse: 360_932_725, loeschen: SICHERUNG_WEG }],
|
||||
}
|
||||
const arcane: SnapshotGeraet = {
|
||||
...basis, id: "vm-106", vmid: 106, art: "vm", name: "Arcane (Docker)", wo: "VM 106", erlaubt: false,
|
||||
grund: "Nicht freigegeben: Das Etikett „watcher“ fehlt.",
|
||||
snapshots: [{ name: "mc2-20260920-100000", zeit: 1_789_891_200, beschreibung: null, herkunft: "update", eigen: true,
|
||||
loeschen: null, zuruecksetzen: null }],
|
||||
}
|
||||
const netbird: SnapshotGeraet = {
|
||||
...basis, id: "ct-102", vmid: 102, name: "NetBird", wo: "Container 102", laeuft: "Snapshot wird angelegt",
|
||||
anlegen: aktion("/api/homelab/snapshots/ct-102/anlegen", "Snapshot anlegen", "…"),
|
||||
snapshots: [{ ...gitea.snapshots[1], name: "mc2-20260925-112118",
|
||||
loeschen: aktion("/x/loeschen", "Snapshot löschen", "…"), zuruecksetzen: aktion("/x/z", "Zurücksetzen", "…") }],
|
||||
}
|
||||
const adguard: SnapshotGeraet = { ...basis, id: "ct-100", vmid: 100, name: "AdGuard Home", wo: "Container 100",
|
||||
anlegen: aktion("/api/homelab/snapshots/ct-100/anlegen", "Snapshot anlegen", "…") }
|
||||
|
||||
const daten: HomelabSnapshots = {
|
||||
jetzt: JETZT, bericht: { empfangen: JETZT - 60, veraltet: false },
|
||||
ausfuehrer: { verbunden: true, zuletzt: JETZT - 60, nur_lesen: false }, sperre: null, details: true,
|
||||
geraete: [adguard, netbird, gitea, pbs, arcane],
|
||||
summe: { snapshots: 4, eigene: 3, sicherungen: 1, sicherungen_groesse: 360_932_725 },
|
||||
}
|
||||
|
||||
const bereich = (name: string) => screen.getByRole("region", { name })
|
||||
const zeile = (name: string) => screen.getByRole("rowheader", { name }).closest("[role=row]") as HTMLElement
|
||||
|
||||
describe("SnapshotListe", () => {
|
||||
it("zeigt je Gerät Name, Herkunft, Zeitpunkt, Alter und Beschreibung", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
|
||||
const eigen = zeile("mc2-20260925-112639")
|
||||
expect(within(eigen).getByText("vor Update")).toBeInTheDocument()
|
||||
expect(within(eigen).getByText("HEUTE 11:26")).toBeInTheDocument()
|
||||
expect(within(eigen).getByText("vor 2 h")).toBeInTheDocument()
|
||||
expect(within(eigen).getByText("Vor einem Update durch den Homelab Orchestrator")).toBeInTheDocument()
|
||||
// Von Hand angelegt: nur zur Ansicht, mit dem Grund statt Knöpfen.
|
||||
const hand = zeile("vor-dem-umbau")
|
||||
expect(within(hand).getByText("von Hand")).toBeInTheDocument()
|
||||
expect(within(hand).getByText("10.09. 02:26")).toBeInTheDocument()
|
||||
expect(within(hand).getByText("Nur in Proxmox")).toBeInTheDocument()
|
||||
expect(within(hand).queryByRole("button")).not.toBeInTheDocument()
|
||||
// Gerät ohne Snapshots
|
||||
expect(within(bereich("AdGuard Home")).getByText("Keine Snapshots.")).toBeInTheDocument()
|
||||
// Das Ergebnis der letzten Aktion steht beim Gerät, ohne seinen Namen noch einmal.
|
||||
expect(within(bereich("Gitea")).getByText("Snapshot mc2-20260925-112639 gelöscht.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("fragt vor dem Zurücksetzen mit dem Text des Homelab-Teils", () => {
|
||||
const start = vi.fn()
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={start} />)
|
||||
fireEvent.click(within(zeile("mc2-20260925-112639")).getByRole("button", { name: "Zurücksetzen" }))
|
||||
const dialog = screen.getByRole("dialog")
|
||||
expect(within(dialog).getByText("Gitea: Zurücksetzen")).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/ist danach verloren/)).toBeInTheDocument()
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Zurücksetzen" }))
|
||||
expect(start).toHaveBeenCalledWith(ZURUECK)
|
||||
})
|
||||
|
||||
it("legt nach Rückfrage einen Snapshot an und löscht eine Sicherung", () => {
|
||||
const start = vi.fn()
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={start} />)
|
||||
fireEvent.click(within(bereich("Gitea")).getByRole("button", { name: "Snapshot anlegen" }))
|
||||
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Snapshot anlegen" }))
|
||||
expect(start).toHaveBeenLastCalledWith(ANLEGEN)
|
||||
|
||||
const sicherung = zeile("vzdump-lxc-105-2026_09_25-11_29_57.tar.zst")
|
||||
expect(within(sicherung).getByText("Sicherung")).toBeInTheDocument()
|
||||
expect(within(sicherung).getByText("361 MB auf „local“, vor einem Update angelegt")).toBeInTheDocument()
|
||||
fireEvent.click(within(sicherung).getByRole("button", { name: "Löschen" }))
|
||||
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sicherung löschen" }))
|
||||
expect(start).toHaveBeenLastCalledWith(SICHERUNG_WEG)
|
||||
})
|
||||
|
||||
it("sagt, warum es keinen Knopf gibt", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
|
||||
const p = bereich("Proxmox Backup Server")
|
||||
expect(within(p).getByText(/Kein Snapshot möglich: Bind-Mount mp0/)).toBeInTheDocument()
|
||||
expect(within(p).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument()
|
||||
const a = bereich("Arcane (Docker)")
|
||||
expect(within(a).getByText(/Nicht freigegeben: Das Etikett/)).toBeInTheDocument()
|
||||
expect(within(a).getByText("Nicht freigegeben")).toBeInTheDocument() // eigener Snapshot, aber kein Knopf
|
||||
expect(within(a).queryByRole("button")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("zeigt „Läuft: …“ statt des Knopfs und sperrt die übrigen Knöpfe des Geräts", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
|
||||
const n = bereich("NetBird")
|
||||
expect(within(n).getByRole("status")).toHaveTextContent("Läuft: Snapshot wird angelegt …")
|
||||
expect(within(n).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument()
|
||||
expect(within(n).getByRole("button", { name: "Löschen" })).toBeDisabled()
|
||||
expect(within(bereich("Gitea")).getByRole("button", { name: "Löschen" })).toBeEnabled()
|
||||
})
|
||||
|
||||
it("sperrt alles, solange es die Seite sagt", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt onAktion={() => {}} />)
|
||||
for (const knopf of screen.getAllByRole("button")) expect(knopf).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { CircleCheck, CircleX, Plus, Trash2, Undo2 } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots"
|
||||
import type {
|
||||
HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion,
|
||||
} from "@/lib/typen"
|
||||
|
||||
type Frage = { name: string; aktion: ZielAktion; gefahr: boolean } | null
|
||||
|
||||
// Eine Zeile je Snapshot bzw. Sicherung: Name, Herkunft, Zeitpunkt, Beschreibung, Knöpfe — in drei Anordnungen:
|
||||
// Handy Name und Herkunft oben, darunter Zeitpunkt, Beschreibung und die Knöpfe nebeneinander
|
||||
// ab md links Name, Zeitpunkt, Beschreibung untereinander, rechts die Knöpfe übereinander
|
||||
// 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-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 " +
|
||||
"xl:[grid-template-areas:'name_herkunft_zeit_text_knoepfe']"
|
||||
// Namen brechen an ihren Bindestrichen (mc2-20260925- | 113700), nicht mitten in der Zahl.
|
||||
const NAME = "ziffern min-w-0 pt-1 text-sm break-words text-foreground [grid-area:name] xl:pt-0"
|
||||
const HERKUNFT_ZELLE = "justify-self-end [grid-area:herkunft] xl:justify-self-start"
|
||||
|
||||
function Zeitpunkt({ zeit, jetzt }: { zeit: number | null; jetzt: number }) {
|
||||
return (
|
||||
<span role="cell" className="flex flex-wrap items-baseline gap-x-2 text-sm [grid-area:zeit]">
|
||||
<span className="ziffern text-text-2">{zeitpunktText(zeit, jetzt)}</span>
|
||||
{zeit !== null && <span className="text-text-3">{alterText(zeit, jetzt)}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Beschreibung({ children }: { children: ReactNode }) {
|
||||
return <span role="cell" className="min-w-0 text-sm break-words text-text-2 [grid-area:text]">{children}</span>
|
||||
}
|
||||
|
||||
function Knoepfe({ children, ohne }: { children: ReactNode; ohne: string | null }) {
|
||||
return (
|
||||
<span role="cell"
|
||||
className="flex flex-wrap gap-2 [grid-area:knoepfe] md:flex-col md:items-stretch md:justify-self-end xl:justify-self-stretch">
|
||||
{ohne ? <span className="text-xs text-text-3">{ohne}</span> : children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotZeile({ g, s, jetzt, gesperrt, onFrage }: {
|
||||
g: SnapshotGeraet
|
||||
s: HomelabSnapshot
|
||||
jetzt: number
|
||||
gesperrt: boolean
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const { loeschen, zuruecksetzen } = s
|
||||
const ohne = loeschen || zuruecksetzen ? null : s.eigen ? "Nicht freigegeben" : "Nur in Proxmox"
|
||||
return (
|
||||
<div role="row" className={ZEILE}>
|
||||
<span role="rowheader" className={NAME}>{s.name}</span>
|
||||
<span role="cell" className={HERKUNFT_ZELLE} title={HERKUNFT[s.herkunft].text}>
|
||||
<Chip>{HERKUNFT[s.herkunft].chip}</Chip>
|
||||
</span>
|
||||
<Zeitpunkt zeit={s.zeit} jetzt={jetzt} />
|
||||
<Beschreibung>{s.beschreibung ?? <span className="text-text-3">{HERKUNFT[s.herkunft].text}</span>}</Beschreibung>
|
||||
<Knoepfe ohne={ohne}>
|
||||
{zuruecksetzen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: zuruecksetzen, gefahr: true })}>
|
||||
<Undo2 aria-hidden /> Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
{loeschen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: loeschen, gefahr: true })}>
|
||||
<Trash2 aria-hidden /> Löschen
|
||||
</Button>
|
||||
)}
|
||||
</Knoepfe>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SicherungZeile({ g, e, jetzt, gesperrt, onFrage }: {
|
||||
g: SnapshotGeraet
|
||||
e: HomelabSicherungsArchiv
|
||||
jetzt: number
|
||||
gesperrt: boolean
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const groesse = groesseText(e.groesse)
|
||||
const loeschen = e.loeschen
|
||||
return (
|
||||
<div role="row" className={ZEILE}>
|
||||
<span role="rowheader" className={NAME}>{e.datei}</span>
|
||||
<span role="cell" className={HERKUNFT_ZELLE}><Chip>Sicherung</Chip></span>
|
||||
<Zeitpunkt zeit={e.zeit} jetzt={jetzt} />
|
||||
<Beschreibung>
|
||||
{groesse ? `${groesse} auf „${e.speicher}“` : `Auf „${e.speicher}“`}, vor einem Update angelegt
|
||||
</Beschreibung>
|
||||
<Knoepfe ohne={loeschen ? null : "Nicht freigegeben"}>
|
||||
{loeschen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: loeschen, gefahr: true })}>
|
||||
<Trash2 aria-hidden /> Löschen
|
||||
</Button>
|
||||
)}
|
||||
</Knoepfe>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Geraet({ g, jetzt, gesperrt, onFrage }: {
|
||||
g: SnapshotGeraet
|
||||
jetzt: number
|
||||
gesperrt: boolean
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const leer = g.snapshots.length === 0 && g.sicherungen.length === 0
|
||||
const anlegen = g.anlegen
|
||||
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">
|
||||
<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>
|
||||
{g.laeuft ? (
|
||||
<p role="status" className="text-sm text-cyan">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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{g.grund && <p className="text-sm text-text-3">{g.grund}</p>}
|
||||
{g.zuletzt && (
|
||||
<p className={cn("flex items-start gap-1.5 text-sm", g.zuletzt.ok ? "text-gruen-text" : "text-rot-text")}>
|
||||
{g.zuletzt.ok
|
||||
? <CircleCheck className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
: <CircleX className="mt-0.5 size-4 shrink-0" aria-hidden />}
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
||||
<span className="break-words">{zuletztText(g.zuletzt.text, g.name)}</span>
|
||||
<span className="ziffern text-xs text-text-3">{zeitpunktText(g.zuletzt.ende, jetzt)}</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{leer ? (
|
||||
<p className="text-sm text-text-3">Keine Snapshots.</p>
|
||||
) : (
|
||||
<div role="table" aria-label={`Snapshots von ${g.name}`} className="flex flex-col">
|
||||
<div role="row" className="sr-only">
|
||||
<span role="columnheader">Snapshot</span>
|
||||
<span role="columnheader">Herkunft</span>
|
||||
<span role="columnheader">Zeitpunkt</span>
|
||||
<span role="columnheader">Beschreibung</span>
|
||||
<span role="columnheader">Aktion</span>
|
||||
</div>
|
||||
{g.snapshots.map((s) => (
|
||||
<SnapshotZeile key={s.name} g={g} s={s} jetzt={jetzt} gesperrt={gesperrt} onFrage={onFrage} />
|
||||
))}
|
||||
{g.sicherungen.map((e) => (
|
||||
<SicherungZeile key={e.datei} g={g} e={e} jetzt={jetzt} gesperrt={gesperrt} onFrage={onFrage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots und Sicherungen je Gerät. Knöpfe gibt es nur, wo der Homelab-Teil eine Aktion mitschickt (Snapshots des
|
||||
* Orchestrators, freigegebene Geräte); jede fragt vorher mit dem Text des Homelab-Teils. Läuft für ein Gerät etwas,
|
||||
* steht „Läuft: …“ statt des Knopfs, und seine übrigen Knöpfe ruhen.
|
||||
*/
|
||||
export function SnapshotListe({ daten, gesperrt, onAktion }: {
|
||||
daten: HomelabSnapshots
|
||||
gesperrt: boolean
|
||||
onAktion: (a: ZielAktion) => void
|
||||
}) {
|
||||
const [frage, setFrage] = useState<Frage>(null)
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5">
|
||||
{daten.geraete.map((g) => (
|
||||
<Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} />
|
||||
))}
|
||||
</div>
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
titel={frage ? `${frage.name}: ${frage.aktion.label}` : ""}
|
||||
text={frage?.aktion.frage ?? ""}
|
||||
knopf={frage?.aktion.label ?? ""}
|
||||
gefahr={frage?.gefahr}
|
||||
gesperrt={gesperrt}
|
||||
onJa={() => {
|
||||
if (frage) onAktion(frage.aktion)
|
||||
setFrage(null)
|
||||
}}
|
||||
onNein={() => setFrage(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,13 @@ describe("Speicher", () => {
|
||||
expect(screen.getByText("fast voll")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("verweist beim Vorschlag zu den Snapshots auf deren Seite (seit 25.09.2026)", () => {
|
||||
render(<Speicher daten={daten} snapshotsLink={<a href="/homelab/snapshots">Zu den Snapshots</a>} />)
|
||||
const links = screen.getAllByRole("link", { name: "Zu den Snapshots" })
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].closest("li")).toHaveTextContent("Gitea: Snapshot mc2-20260924-192352")
|
||||
})
|
||||
|
||||
it("nennt Störungen des NAS und fehlende Sicherungen", () => {
|
||||
render(<Speicher daten={{
|
||||
...daten,
|
||||
|
||||
@@ -137,12 +137,14 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
|
||||
* Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool
|
||||
* Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
|
||||
*/
|
||||
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
|
||||
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink }: {
|
||||
daten: HomelabSpeicher
|
||||
/** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */
|
||||
laeuft?: string | null
|
||||
gesperrt?: boolean
|
||||
onAktion?: (a: ZielAktion) => void
|
||||
/** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */
|
||||
snapshotsLink?: ReactNode
|
||||
}) {
|
||||
const [frage, setFrage] = useState<ZielAktion | null>(null)
|
||||
const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok"
|
||||
@@ -175,7 +177,8 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
|
||||
</summary>
|
||||
<ol className="flex flex-col">
|
||||
{daten.empfehlungen.map((e) => (
|
||||
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage} />
|
||||
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage}
|
||||
verweis={e.art === "snapshot" ? snapshotsLink : null} />
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
@@ -211,11 +214,12 @@ function MitBefehlen({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
|
||||
function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: {
|
||||
e: SpeicherEmpfehlung
|
||||
laeuft: string | null
|
||||
gesperrt: boolean
|
||||
onFrage: (a: ZielAktion) => void
|
||||
verweis?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<li className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
|
||||
@@ -225,6 +229,7 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
|
||||
</div>
|
||||
{e.ort && <span className="schild text-xs 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>
|
||||
) : (
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
import type { HomelabSnapshots } from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||
@@ -483,6 +484,27 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Snapshots und Sicherungen je Gerät (Seite Homelab → Snapshots, seit 25.09.2026). Der Schlüssel beginnt mit
|
||||
* „homelab“: Wer „homelab“ neu lädt (Live-Strom nach einem neuen Bericht, die Update-Knöpfe), lädt diese Liste mit.
|
||||
* Läuft für ein Gerät etwas, alle 3 s nachsehen.
|
||||
*/
|
||||
export const useHomelabSnapshots = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab", "snapshots"],
|
||||
queryFn: () => api<HomelabSnapshots>("/api/homelab/snapshots"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: (q) => (q.state.data?.geraete.some((g) => g.laeuft) ? 3000 : 30_000),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Knöpfe der Seite Snapshots: Pfad und Rückfrage liefert der Homelab-Teil mit der Liste. */
|
||||
export const useSnapshotAktion = () =>
|
||||
useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), {
|
||||
erfolg: (a) => `${a.label}: gestartet. Das Ergebnis steht gleich beim Gerät.`,
|
||||
neuLaden: ["homelab", "homelab-laeufe"],
|
||||
})
|
||||
|
||||
export const useHomelabEinstellungen = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-einstellungen"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRouterState } from "@tanstack/react-router"
|
||||
import {
|
||||
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
|
||||
} from "lucide-react"
|
||||
import { History } from "lucide-react"
|
||||
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
|
||||
import { offeneUpdates } from "@/lib/homelab"
|
||||
import { useStromLage } from "@/lib/strom"
|
||||
@@ -20,6 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
|
||||
export type SeitenPfad =
|
||||
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
|
||||
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
|
||||
| "/homelab/snapshots"
|
||||
|
||||
export interface Seite {
|
||||
to: SeitenPfad
|
||||
@@ -40,6 +42,8 @@ export const SEITEN: Seite[] = [
|
||||
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, ort: "box" },
|
||||
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
|
||||
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
|
||||
// Nach „Updates“: Snapshots sind der Rückweg der Updates (seit 25.09.2026).
|
||||
{ to: "/homelab/snapshots", label: "Snapshots", Icon: History, ort: "homelab" },
|
||||
{ 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" },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { alterText, groesseText, istSnapshotLauf, summeText, zeitpunktText, zuletztText } from "@/lib/snapshots"
|
||||
|
||||
// 25.09.2026 13:40 in Berlin — der Snapshot von Gitea ist von 11:26 desselben Tages.
|
||||
const JETZT = 1_790_334_000
|
||||
|
||||
describe("Snapshot-Texte", () => {
|
||||
it("nennt Zeitpunkt und Alter nach der Uhr des Homelab-Teils", () => {
|
||||
expect(zeitpunktText(1_790_328_400, JETZT)).toBe("HEUTE 11:26")
|
||||
expect(alterText(1_790_328_400, JETZT)).toBe("vor 2 h")
|
||||
expect(zeitpunktText(1_789_000_000, JETZT)).toBe("10.09. 02:26")
|
||||
expect(alterText(1_789_000_000, JETZT)).toBe("vor 15 Tagen")
|
||||
expect(zeitpunktText(null, JETZT)).toBe("Zeitpunkt unbekannt")
|
||||
expect(alterText(null, JETZT)).toBe("")
|
||||
})
|
||||
|
||||
it("rechnet Sicherungen unter einem GB in MB", () => {
|
||||
expect(groesseText(360_932_725)).toBe("361 MB")
|
||||
expect(groesseText(1_234_000_000)).toBe("1,2 GB")
|
||||
expect(groesseText(null)).toBeNull()
|
||||
})
|
||||
|
||||
it("zählt oben rechts und lässt fehlende Sicherungen weg", () => {
|
||||
expect(summeText({ snapshots: 3, eigene: 1, sicherungen: 1, sicherungen_groesse: 1 })).toBe("3 Snapshots · 1 Sicherung")
|
||||
expect(summeText({ snapshots: 1, eigene: 1, sicherungen: 0, sicherungen_groesse: null })).toBe("1 Snapshot")
|
||||
})
|
||||
|
||||
it("trennt Snapshot-Aktionen von Updates", () => {
|
||||
expect(istSnapshotLauf({ baustein: "snapshot-zurueck" })).toBe(true)
|
||||
expect(istSnapshotLauf({ baustein: "sicherung-loeschen" })).toBe(true)
|
||||
expect(istSnapshotLauf({ baustein: "app" })).toBe(false)
|
||||
expect(istSnapshotLauf({ baustein: "kernel" })).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt den Gerätenamen aus dem Ergebnis, das ja beim Gerät steht", () => {
|
||||
expect(zuletztText("Gitea: auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.", "Gitea"))
|
||||
.toBe("Auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.")
|
||||
expect(zuletztText("Snapshot anlegen unterbrochen: …", "Gitea")).toBe("Snapshot anlegen unterbrochen: …")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HomelabLauf, HomelabSnapshots, SnapshotHerkunft } from "@/lib/typen"
|
||||
import { flugplanZeit, vor } from "@/lib/zeit"
|
||||
|
||||
// Die Seite Homelab → Snapshots (seit 25.09.2026): kleine Texte, damit die Ansicht nur noch zeichnet. Was erlaubt ist
|
||||
// und die Rückfragen kommen vom Homelab-Teil (backend/services/homelab/snapshots.py).
|
||||
|
||||
/** Die Läufe der Seite Snapshots — keine Updates: Sie gehören weder in den Update-Verlauf noch in „Zuletzt eingespielt“. */
|
||||
export const SNAPSHOT_LAEUFE = new Set(["snapshot-anlegen", "snapshot-loeschen", "snapshot-zurueck", "sicherung-loeschen"])
|
||||
|
||||
export const istSnapshotLauf = (l: Pick<HomelabLauf, "baustein">) => SNAPSHOT_LAEUFE.has(l.baustein)
|
||||
|
||||
export const HERKUNFT: Record<SnapshotHerkunft, { chip: string; text: string }> = {
|
||||
update: { chip: "vor Update", text: "Rückweg vor einem Update" },
|
||||
knopf: { chip: "per Knopf", text: "Hier per Knopf angelegt" },
|
||||
proxmox: { chip: "von Hand", text: "Von Hand in Proxmox angelegt" },
|
||||
}
|
||||
|
||||
/** „HEUTE 11:26“, „SA 08:00“, „20.09. 11:26“ — nach der Uhr des Homelab-Teils (jetzt in Unix-Sekunden). */
|
||||
export function zeitpunktText(zeit: number | null, jetzt: number): string {
|
||||
if (zeit === null) return "Zeitpunkt unbekannt"
|
||||
return flugplanZeit(new Date(zeit * 1000).toISOString(), new Date(jetzt * 1000))
|
||||
}
|
||||
|
||||
/** „vor 2 h“, „vor 5 Tagen“ — leer, wo der Zeitpunkt fehlt. */
|
||||
export function alterText(zeit: number | null, jetzt: number): string {
|
||||
return zeit === null ? "" : vor(zeit, jetzt * 1000)
|
||||
}
|
||||
|
||||
/** „361 MB“, „1,2 GB“ — wie die Rückfrage des Homelab-Teils; null, wo die Größe fehlt. Sicherungen sind oft kleiner
|
||||
* als ein GB (die Übersicht importiert diese Datei, darum ohne lib/speicher). */
|
||||
export function groesseText(bytes: number | null): string | null {
|
||||
if (bytes === null || bytes <= 0) return null
|
||||
return bytes < 1e9 ? `${Math.round(bytes / 1e6)} MB` : `${(bytes / 1e9).toFixed(1).replace(".", ",")} GB`
|
||||
}
|
||||
|
||||
/** Das Ergebnis der letzten Aktion beim Gerät: ohne den Gerätenamen vorn, den die Meldung für Telegram braucht
|
||||
* („Gitea: auf den Snapshot … zurückgesetzt“ → „Auf den Snapshot … zurückgesetzt“). */
|
||||
export function zuletztText(text: string, name: string): string {
|
||||
const rest = text.startsWith(`${name}: `) ? text.slice(name.length + 2) : text
|
||||
return rest.charAt(0).toUpperCase() + rest.slice(1)
|
||||
}
|
||||
|
||||
/** Oben rechts: „3 Snapshots · 1 Sicherung“. */
|
||||
export function summeText(s: HomelabSnapshots["summe"]): string {
|
||||
const teile = [`${s.snapshots} ${s.snapshots === 1 ? "Snapshot" : "Snapshots"}`]
|
||||
if (s.sicherungen > 0) teile.push(`${s.sicherungen} ${s.sicherungen === 1 ? "Sicherung" : "Sicherungen"}`)
|
||||
return teile.join(" · ")
|
||||
}
|
||||
@@ -331,7 +331,8 @@ export interface HomelabStand {
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
|
||||
}
|
||||
|
||||
/** Ein „Jetzt updaten“-Lauf im Homelab (GET /api/homelab/laeufe). */
|
||||
/** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite
|
||||
* Snapshots (Bausteine snapshot-anlegen, snapshot-loeschen, snapshot-zurueck, sicherung-loeschen). */
|
||||
export interface HomelabLauf {
|
||||
id: string
|
||||
ziel: string
|
||||
@@ -340,7 +341,9 @@ export interface HomelabLauf {
|
||||
status: "laeuft" | "fertig" | "unterbrochen"
|
||||
start: number
|
||||
ende: number | null
|
||||
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null
|
||||
ergebnis:
|
||||
| "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen"
|
||||
| "angelegt" | "geloescht" | "zurueckgesetzt" | null
|
||||
text: string | null
|
||||
schritte: string[]
|
||||
}
|
||||
@@ -480,7 +483,7 @@ export interface Sammellauf {
|
||||
|
||||
export interface ProtokollEintrag {
|
||||
zeit: number
|
||||
art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege"
|
||||
art: "auftrag" | "lauf" | "snapshot" | "sammellauf" | "hinweis" | "meldung" | "pflege"
|
||||
titel: string
|
||||
text: string | null
|
||||
stufe: "ok" | "info" | "warn" | "fehler"
|
||||
@@ -568,3 +571,63 @@ export interface HomelabSpeicher {
|
||||
sicherungen: SicherungsLage
|
||||
empfehlungen: SpeicherEmpfehlung[]
|
||||
}
|
||||
|
||||
// --- Snapshots des Homelabs (GET /api/homelab/snapshots, seit 25.09.2026) ---------------------------------------
|
||||
|
||||
/** Woher ein Snapshot stammt: Rückweg vor einem Update, per Knopf auf der Seite Snapshots, von Hand in Proxmox. */
|
||||
export type SnapshotHerkunft = "update" | "knopf" | "proxmox"
|
||||
|
||||
export interface HomelabSnapshot {
|
||||
name: string
|
||||
/** Unix-Sekunden; null, wo Proxmox keinen Zeitpunkt nennt. */
|
||||
zeit: number | null
|
||||
beschreibung: string | null
|
||||
herkunft: SnapshotHerkunft
|
||||
/** Vom Orchestrator (mc2-…): Nur solche lassen sich hier löschen oder zurücksetzen. */
|
||||
eigen: boolean
|
||||
loeschen: ZielAktion | null
|
||||
zuruecksetzen: ZielAktion | null
|
||||
}
|
||||
|
||||
/** Eine Sicherung des Orchestrators (vzdump-Archiv), wo vor Updates kein Snapshot geht (PBS). */
|
||||
export interface HomelabSicherungsArchiv {
|
||||
datei: string
|
||||
speicher: string
|
||||
zeit: number | null
|
||||
/** Bytes; null, solange der Ausführer sie nicht nennt. */
|
||||
groesse: number | null
|
||||
loeschen: ZielAktion | null
|
||||
}
|
||||
|
||||
export interface SnapshotGeraet {
|
||||
id: string
|
||||
vmid: number
|
||||
art: "container" | "vm"
|
||||
name: string
|
||||
/** „Container 104“ */
|
||||
wo: string
|
||||
status: string | null
|
||||
erlaubt: boolean
|
||||
/** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */
|
||||
anlegen: ZielAktion | null
|
||||
grund: string | null
|
||||
/** Was für das Gerät gerade läuft (auch ein Update), sonst null. */
|
||||
laeuft: string | null
|
||||
/** Ergebnis der letzten Aktion dieser Seite, solange es frisch ist. */
|
||||
zuletzt: { ok: boolean; ende: number; text: string } | null
|
||||
snapshots: HomelabSnapshot[]
|
||||
sicherungen: HomelabSicherungsArchiv[]
|
||||
}
|
||||
|
||||
export interface HomelabSnapshots {
|
||||
/** Uhr des Homelab-Teils (Unix-Sekunden) — danach rechnet die Seite das Alter. */
|
||||
jetzt: number
|
||||
bericht: { empfangen: number; veraltet: boolean } | null
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean }
|
||||
/** Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update irgendwo, der Ausführer) — sonst null. */
|
||||
sperre: string | null
|
||||
/** false: Der Ausführer ist älter als der 25.09.2026 und nennt nur Namen (Zeitpunkt aus dem Namen). */
|
||||
details: boolean
|
||||
geraete: SnapshotGeraet[]
|
||||
summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null }
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||
import { istSnapshotLauf } from "@/lib/snapshots"
|
||||
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher,
|
||||
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Monitoring und
|
||||
// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
|
||||
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Snapshots, Monitoring
|
||||
// und Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
|
||||
|
||||
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
|
||||
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
|
||||
@@ -29,6 +30,7 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
|
||||
fehler: { art: "rot", text: "Fehler" },
|
||||
neustart: { art: "cyan", text: "Neustart" },
|
||||
aufgeraeumt: { art: "gruen", text: "aufgeräumt" },
|
||||
offen: { art: "cyan", text: "Probelauf" }, // Docker über Arcane, solange es nur probt
|
||||
}
|
||||
|
||||
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||
@@ -97,14 +99,18 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */
|
||||
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. Die Aktionen der
|
||||
* Seite Snapshots sind keine Updates und stehen dort. */
|
||||
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 12)
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 12)
|
||||
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
|
||||
return (
|
||||
<ol className="@container m-0 flex list-none flex-col p-0">
|
||||
{fertig.map((l) => {
|
||||
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
|
||||
// Ein Ergebnis ohne eigenen Eintrag riss die Seite vorher um (ERGEBNIS[…] war undefined).
|
||||
const e = l.ergebnis
|
||||
? (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">
|
||||
<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]">
|
||||
@@ -188,18 +194,20 @@ function useHomelabBereich() {
|
||||
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
|
||||
export function HomelabSeite() {
|
||||
const navigate = useNavigate()
|
||||
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
|
||||
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
|
||||
const mess = useHomelabMesswerte("1h", !!stand)
|
||||
const speicher = useHomelabSpeicher(!!stand)
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
||||
const l = lage(stand, hinweise, laufend.size, berichtZeit)
|
||||
// „Update läuft“ nur für Updates — ein Snapshot anlegen oder löschen ist keins.
|
||||
const imUpdate = new Set(laeufe.filter((x) => x.status === "laeuft" && !istSnapshotLauf(x)).map((x) => x.ziel))
|
||||
const l = lage(stand, hinweise, imUpdate.size, berichtZeit)
|
||||
return (
|
||||
<>
|
||||
<Lagebild
|
||||
lage={l}
|
||||
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
|
||||
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))}
|
||||
onAnsehen={() => {
|
||||
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
|
||||
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
@@ -231,6 +239,11 @@ export function HomelabSeite() {
|
||||
laeuft={laufend.get("pve") ?? null}
|
||||
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
|
||||
onAktion={(a) => aktion.mutate(a)}
|
||||
snapshotsLink={
|
||||
<Link to="/homelab/snapshots" className="inline-flex min-h-10 items-center gap-1 self-start text-sm text-lab-text hover:underline">
|
||||
Zu den Snapshots <ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { Bell, ChevronRight, Cog, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
|
||||
import { Bell, ChevronRight, Cog, History, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
@@ -14,6 +14,7 @@ type Art = ProtokollEintrag["art"]
|
||||
const ARTEN: { id: Art | "alle"; name: string }[] = [
|
||||
{ id: "alle", name: "Alles" },
|
||||
{ id: "lauf", name: "Updates" },
|
||||
{ id: "snapshot", name: "Snapshots" },
|
||||
{ id: "auftrag", name: "Ausführer" },
|
||||
{ id: "hinweis", name: "Hinweise" },
|
||||
{ id: "meldung", name: "Meldungen" },
|
||||
@@ -22,6 +23,7 @@ const ARTEN: { id: Art | "alle"; name: string }[] = [
|
||||
|
||||
const SYMBOL: Record<Art, LucideIcon> = {
|
||||
auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search,
|
||||
snapshot: History,
|
||||
}
|
||||
|
||||
const STUFE: Record<ProtokollEintrag["stufe"], { punkt: string; text: string; wort: string }> = {
|
||||
@@ -47,7 +49,9 @@ function tagUeberschrift(zeit: number): string {
|
||||
function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) {
|
||||
const [offen, setOffen] = useState(false)
|
||||
const s = STUFE[e.stufe] ?? STUFE.info
|
||||
const Symbol = SYMBOL[e.art] ?? Cog
|
||||
// Nicht „Symbol“ nennen: Im Entwicklungsmodus setzt der React Compiler seinen Zwischenspeicher oben in der Funktion
|
||||
// mit Symbol.for(…) zurück — eine gleichnamige Konstante darunter ließ die Seite dort abstürzen.
|
||||
const Zeichen = SYMBOL[e.art] ?? Cog
|
||||
return (
|
||||
<li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-linie py-2.5 first:border-t-0">
|
||||
<span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span>
|
||||
@@ -56,7 +60,7 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex flex-wrap items-center gap-x-2 text-[15px]">
|
||||
<Symbol className="size-3.5 shrink-0 text-text-3" aria-hidden />
|
||||
<Zeichen className="size-3.5 shrink-0 text-text-3" aria-hidden />
|
||||
<span className={cn(e.stufe === "fehler" || e.stufe === "warn" ? s.text : "text-foreground")}>{e.titel}</span>
|
||||
{geraet && <span className="rounded-md bg-lab-grund px-1.5 text-xs text-lab-text">{geraet}</span>}
|
||||
<span className="sr-only">({s.wort})</span>
|
||||
@@ -106,8 +110,8 @@ export function HomelabProtokollSeite() {
|
||||
return (
|
||||
<Panel titel="Protokoll">
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Hinweise,
|
||||
gesendete Meldungen und die nächtliche Paketsuche.
|
||||
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots,
|
||||
Hinweise, gesendete Meldungen und die nächtliche Paketsuche.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter">
|
||||
{ARTEN.map((a) => (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { SnapshotListe } from "@/components/homelab/SnapshotListe"
|
||||
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
|
||||
import { summeText, zeitpunktText } from "@/lib/snapshots"
|
||||
|
||||
// Homelab · Snapshots (seit 25.09.2026, User-Wunsch): je Gerät seine Snapshots und die Sicherungen des Orchestrators,
|
||||
// mit Knöpfen zum Anlegen, Löschen und Zurücksetzen. Was erlaubt ist und die Rückfragen kommen vom Homelab-Teil
|
||||
// (backend/services/homelab/snapshots.py); die Aktionen laufen dort als Läufe wie die Updates.
|
||||
|
||||
export function HomelabSnapshotsSeite() {
|
||||
const instanz = useInstanz()
|
||||
const partner = usePartner()
|
||||
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
||||
const abfrage = useHomelabSnapshots(aktiv)
|
||||
const aktion = useSnapshotAktion()
|
||||
|
||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
|
||||
if (abfrage.isPending) return <Zustandsfeld text="Die Snapshots werden gelesen …" />
|
||||
if (!abfrage.data) {
|
||||
return <Zustandsfeld fehler text={`Die Snapshots lassen sich nicht lesen: ${abfrage.error?.message ?? "keine Daten"}`} />
|
||||
}
|
||||
|
||||
const d = abfrage.data
|
||||
// Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — derselbe Satz wie beim Knopf.
|
||||
const grund = d.sperre
|
||||
return (
|
||||
<Panel titel="Snapshots" rechts={d.bericht ? <Chip>{summeText(d.summe)}</Chip> : undefined}>
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Ein Snapshot hält den Stand eines Geräts fest. Vor jedem Update legt der Orchestrator einen an (beim Proxmox
|
||||
Backup Server eine Sicherung) und räumt nach einem grünen Update die älteren weg. Hier legst du selbst welche
|
||||
an — die bleiben, bis du sie löschst —, setzt ein Gerät zurück oder löschst, was weg kann. Von Hand in Proxmox
|
||||
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">
|
||||
{grund}
|
||||
</p>
|
||||
)}
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
{!d.bericht ? (
|
||||
<p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>
|
||||
) : (
|
||||
<>
|
||||
{!d.details && (
|
||||
<p className="text-sm text-text-3">
|
||||
Beschreibungen und die Größe der Sicherungen erscheinen, sobald der neue Ausführer auf dem Proxmox-Host
|
||||
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)} />
|
||||
<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
|
||||
Sicherungen“.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useUpdateVerlauf,
|
||||
} from "@/lib/abfragen"
|
||||
import { useBereichStand, type Verbindung } from "@/lib/navigation"
|
||||
import { istSnapshotLauf } from "@/lib/snapshots"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { Hinweis, Messpunkt } from "@/lib/typen"
|
||||
import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht"
|
||||
@@ -236,7 +237,8 @@ function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) {
|
||||
...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({
|
||||
zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau",
|
||||
})),
|
||||
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft").slice(0, 6).map((l) => ({
|
||||
// Die Aktionen der Seite Snapshots sind keine Updates.
|
||||
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({
|
||||
zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen",
|
||||
art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user