Welle 3 · Wartungsfenster und Update-Regel je Gerät (So 03:00–04:15, Start: Sicherheitsupdates automatisch)

Der Steward des Homelab-Teils prüft jede Minute, ob das Fenster offen ist, und arbeitet dann einmal je Tag den Plan ab:
je Gerät nach seiner Regel — nur melden, Sicherheitsupdates automatisch (Standard, User-Entscheid) oder alles im
Fenster —, mit den bekannten Update-Läufen (Rückweg vorher, Prüfung danach, bei Rot zurück). Nach dem Fenster beginnt
kein Schritt mehr, Erfolge kommen als eine Meldung am Ende, Fehler sofort; der Proxmox-Host startet nie von selbst neu.
Der Ausführer meldet dafür die Namen der Sicherheitspakete und spielt auf Wunsch nur diese ein (Gäste und Host);
neuer Baustein „sicherheit“. Dazu „Version überspringen“ je App, die Einstellungen unter Einstellungen → Homelab und
„Wartung“ statt „Störung“ im Cockpit, solange das Fenster läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 18:33:38 +02:00
co-authored by Claude Opus 5.5
parent 363bc211e1
commit 9da666d04e
18 changed files with 717 additions and 26 deletions
@@ -5,6 +5,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Neuerungen } from "@/components/homelab/Neuerungen"
import { useUeberspringen } from "@/lib/abfragen"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import {
@@ -17,6 +18,27 @@ type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
// Feste Breite für die Knöpfe — sonst verschiebt eine Zeile ohne Knopf alle Spalten.
const SPALTEN = "md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,0.9fr)_11.5rem]"
/** „Version überspringen“ / „wieder anbieten“ (seit 25.09.2026). */
function Ueberspringen({ ziel, b }: { ziel: ZielStand; b: BausteinStand }) {
const u = useUeberspringen()
if (b.uebersprungen) {
return (
<span className="text-xs text-text-3">
{b.uebersprungen} übersprungen ·{" "}
<button type="button" className="text-cyan hover:underline" disabled={u.isPending}
onClick={() => u.mutate({ zid: ziel.id, version: null })}>wieder anbieten</button>
</span>
)
}
if (b.zustand !== "neu" || !b.verfuegbar) return null
return (
<button type="button" className="self-start text-xs text-text-3 hover:text-foreground hover:underline" disabled={u.isPending}
onClick={() => u.mutate({ zid: ziel.id, version: b.verfuegbar })}>
Version überspringen
</button>
)
}
/** Die App eines Geräts: was läuft, was neu ist — und warum es keinen Knopf gibt, wenn es keinen gibt. */
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
if (!b) {
@@ -33,6 +55,7 @@ function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
{b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span>
{b.zustand === "neu" && !b.aktion && b.grund && <span className="text-xs text-text-3">{b.grund}</span>}
{ziel.art !== "proxmox-host" && <Ueberspringen ziel={ziel} b={b} />}
</span>
)
}
@@ -0,0 +1,89 @@
import { useState } from "react"
import { useHomelab, useWartung, useWartungSetzen } from "@/lib/abfragen"
import type { WartungsRegel } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, seit 25.09.2026). Der Steward des
// Homelab-Teils spielt im Fenster ein, was die Regel erlaubt; der Proxmox-Host startet nie von selbst neu.
const TAGE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
const REGEL: Record<WartungsRegel, string> = {
melden: "Nur melden",
sicherheit: "Sicherheitsupdates automatisch",
alles: "Alles automatisch im Fenster",
}
const feld = "h-10 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] text-foreground outline-none focus:border-lab/60"
function RegelWahl({ wert, onWahl, label, mitStandard }: {
wert: WartungsRegel | null
onWahl: (r: WartungsRegel | null) => void
label: string
mitStandard?: string
}) {
return (
<select aria-label={label} value={wert ?? ""} onChange={(e) => onWahl((e.target.value || null) as WartungsRegel | null)}
className={`${feld} min-w-0`}>
{mitStandard && <option value="">Wie Standard ({mitStandard})</option>}
{(Object.keys(REGEL) as WartungsRegel[]).map((r) => <option key={r} value={r}>{REGEL[r]}</option>)}
</select>
)
}
export function Wartungsfenster({ aktiv }: { aktiv: boolean }) {
const w = useWartung(aktiv)
const ziele = useHomelab(aktiv).data?.ziele ?? []
const setzen = useWartungSetzen()
const [fenster, setFenster] = useState<{ tag: number; von: string; bis: string } | null>(null)
if (!w.data) return <p className="text-sm text-text-3">{w.isPending ? "Wird gelesen …" : "Das Wartungsfenster lässt sich gerade nicht lesen."}</p>
const f = fenster ?? w.data.fenster
const geaendert = !!fenster && JSON.stringify(fenster) !== JSON.stringify(w.data.fenster)
const s = w.data.stand
return (
<div className="flex flex-col gap-4">
<p className="m-0 text-sm text-text-2">
Im Fenster spielt der Orchestrator ein, was die Regel des Geräts erlaubt — mit Snapshot vorher und Rückweg bei Rot.
Einzelne Erfolge kommen gesammelt als eine Meldung, Fehler sofort. Den Proxmox-Host startet er nie von selbst neu.
</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-sm text-text-2">
Tag
<select className={feld} value={f.tag} onChange={(e) => setFenster({ ...f, tag: Number(e.target.value) })}>
{TAGE.map((t, i) => <option key={t} value={i}>{t}</option>)}
</select>
</label>
<label className="flex flex-col gap-1 text-sm text-text-2">
von
<input type="time" className={feld} value={f.von} onChange={(e) => setFenster({ ...f, von: e.target.value })} />
</label>
<label className="flex flex-col gap-1 text-sm text-text-2">
bis
<input type="time" className={feld} value={f.bis} onChange={(e) => setFenster({ ...f, bis: e.target.value })} />
</label>
<button type="button" disabled={!geaendert || setzen.isPending}
onClick={() => setzen.mutate({ fenster: f }, { onSuccess: () => setFenster(null) })}
className="h-10 rounded-xl bg-lab/20 px-4 text-sm font-medium text-lab-text ring-1 ring-lab/40 ring-inset disabled:opacity-40">
Fenster speichern
</button>
</div>
<p className="m-0 text-xs text-text-3">
Nächstes Fenster: {flugplanZeit(new Date(w.data.naechstes * 1000).toISOString()).toLowerCase()}
{s ? ` · zuletzt ${s.datum.split("-").reverse().join(".")}: ${s.status === "laeuft" ? "läuft gerade" : `${s.schritte.length} Schritte`}` : ""}
</p>
<label className="flex flex-col gap-1 text-sm text-text-2">
Standard für alle Geräte (auch neue)
<RegelWahl label="Standard-Regel" wert={w.data.standard} onWahl={(r) => r && setzen.mutate({ standard: r })} />
</label>
<div role="table" aria-label="Regel je Gerät" className="flex flex-col">
{ziele.map((z) => (
<div role="row" key={z.id} className="flex flex-wrap items-center justify-between gap-2 border-t border-white/[0.06] py-2">
<span role="rowheader" className="text-[15px]">{z.name}</span>
<span role="cell">
<RegelWahl label={`Regel für ${z.name}`} wert={w.data.regeln[z.id] ?? null} mitStandard={REGEL[w.data.standard]}
onWahl={(r) => setzen.mutate({ regeln: { [z.id]: r } })} />
</span>
</div>
))}
</div>
</div>
)
}
+27 -1
View File
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -511,6 +511,32 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
retry: false,
})
/** Wartungsfenster und Regel je Gerät (seit 25.09.2026). */
export const useWartung = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-wartung"],
queryFn: () => api<HomelabWartung>("/api/homelab/wartung"),
enabled: aktiv,
retry: false,
})
export const useWartungSetzen = () =>
useAktion((daten: { fenster?: HomelabWartung["fenster"]; standard?: WartungsRegel; regeln?: Record<string, WartungsRegel | null> }) =>
post<AktionsErgebnis>("/api/homelab/wartung", daten), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.",
neuLaden: ["homelab-wartung"],
})
/** „Version überspringen“ bzw. mit version null „wieder anbieten“ (seit 25.09.2026). */
export const useUeberspringen = () =>
useAktion(({ zid, version }: { zid: string; version: string | null }) =>
version === null
? api<AktionsErgebnis>(`/api/homelab/ziele/${zid}/ueberspringen`, { method: "DELETE" })
: post<AktionsErgebnis>(`/api/homelab/ziele/${zid}/ueberspringen`, { baustein: "app", version }), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Erledigt.",
neuLaden: ["homelab", "homelab-wartung"],
})
/** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */
export const useNeuEntdeckt = (aktiv: boolean) =>
useQuery({
+11
View File
@@ -119,3 +119,14 @@ describe("Sicherheits-Markierung (seit 25.09.2026)", () => {
expect(sicherheitText(baustein({ id: "os", zustand: "neu", kurz: "2 Pakete", sicherheit: 0 }))).toBe("")
})
})
describe("Wartungsfenster (seit 25.09.2026)", () => {
it("zeigt Wartung statt Störung, solange das Fenster läuft", () => {
const stand: HomelabStand = {
ziele: [ziel({ id: "ct-104", name: "Gitea", bausteine: [], erreichbar: false })],
bericht: null, ausfuehrer: { verbunden: true, zuletzt: null }, wartung: { laeuft: true, fenster: "So 03:00–04:15" },
}
expect(lage(stand, [], 1, null)).toMatchObject({ art: "info", oben: "Wartung", unten: "So 03:00–04:15 · Meldung am Ende" })
expect(lage({ ...stand, wartung: { laeuft: false, fenster: "So 03:00–04:15" } }, [], 0, null).art).toBe("rot")
})
})
+4
View File
@@ -145,6 +145,10 @@ const hinweisZahl = (n: number) => `${n} Hinweis${n === 1 ? "" : "e"}`
export function lage(stand: HomelabStand, alleHinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
// Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen die Leuchte aber nicht aus.
const hinweise = alleHinweise.filter((h) => h.zustand !== "stumm")
// Im Wartungsfenster (seit 25.09.2026) ist ein Gerät, das kurz neu startet, geplante Wartung — keine Störung.
if (stand.wartung?.laeuft) {
return { art: "info", oben: "Wartung", mitte: "Fenster läuft", unten: `${stand.wartung.fenster} · Meldung am Ende`, ziel: "updates" }
}
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`
+25
View File
@@ -308,6 +308,8 @@ export interface BausteinStand {
aktion: ZielAktion | null
/** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */
sicherheit?: number | null
/** Diese Version überspringt der User (seit 25.09.2026). */
uebersprungen?: string | null
}
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
@@ -342,6 +344,29 @@ export interface HomelabStand {
ziele: ZielStand[]
bericht: { empfangen: number; veraltet: boolean } | null
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
/** Seit 25.09.2026: Läuft das Wartungsfenster? (Cockpit zeigt dann „Wartung“ statt „Störung“.) */
wartung?: { laeuft: boolean; fenster: string }
}
// --- Wartungsfenster (GET /api/homelab/wartung, seit 25.09.2026) -------------------------------------------------
export type WartungsRegel = "melden" | "sicherheit" | "alles"
export interface HomelabWartung {
fenster: { tag: number; von: string; bis: string }
fenster_text: string
standard: WartungsRegel
regeln: Record<string, WartungsRegel>
uebersprungen: Record<string, string>
naechstes: number
laeuft: boolean
stand: {
datum: string
status: "laeuft" | "fertig"
start: number
ende: number | null
schritte: { ziel: string; name: string; baustein: string; ergebnis?: string; text?: string | null }[]
} | null
}
/** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite
+11 -1
View File
@@ -4,6 +4,7 @@ import { cn } from "cn"
import { ExternalLink, KeyRound } from "lucide-react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Wartungsfenster } from "@/components/homelab/Wartungsfenster"
import { Button } from "@/components/ui/button"
import {
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
@@ -607,7 +608,16 @@ function HomelabKarte() {
let inhalt: ReactNode
if (!aktiv) inhalt = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p>
else if (!h.data) inhalt = <Unlesbar laedt={h.isPending} fehler={h.error?.message} />
else inhalt = <><ArcaneSchluessel h={h.data} /><AdguardZugang h={h.data} /><HomelabBetrieb h={h.data} /></>
else {
inhalt = (
<>
<ArcaneSchluessel h={h.data} />
<AdguardZugang h={h.data} />
<Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt>
<HomelabBetrieb h={h.data} />
</>
)
}
return (
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, AdGuard, der Ausführer auf dem Proxmox-Host, Rückwege.">
{inhalt}