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:
co-authored by
Claude Opus 5.5
parent
363bc211e1
commit
9da666d04e
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user