Später · 18 Plattengesundheit: Proxmox-Host, NAS über SNMP, NVMe der KI-Box

- kern/platten.py: SMART aus pvesh, smartctl und SNMP in einer Form; rot bei SMART FAILED, kritischer Warnung,
  aufgebrauchter Reserve, ≥ 90 % verbraucht; gelb bei ≥ 80 %, Hitze, gewachsenen Fehlerzählern (Ausgangslage je
  Platte, „Zur Kenntnis genommen“ setzt sie neu)
- Ausführer: stündlich disks/list + disks/smart im Bericht (nur lesend)
- services/homelab/nas_snmp.py: kleiner SNMPv2c-GetBulk-Leser ohne neue Abhängigkeit, QNAP hdTable/diskTable,
  Zugang 0600, Einstellungen „Verbindung testen und speichern“
- services/platten.py: NVMe der KI-Box über sudo smartctl (smartmontools am 26.09. nachinstalliert)
- Wächter beider Rollen, Plattenfeld in beiden Cockpits, Doku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-26 09:54:38 +02:00
co-authored by Claude Opus 5.5
parent 5325544d89
commit 904598031a
46 changed files with 1212 additions and 41 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-B_hvuxH0.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-CuG_RlEw.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -9,15 +9,15 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-B_hvuxH0.js"></script>
<script type="module" crossorigin src="/assets/index-CuG_RlEw.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
<link rel="modulepreload" crossorigin href="/assets/abfragen-pD_qBEtc.js">
<link rel="modulepreload" crossorigin href="/assets/abfragen-CDCgpAto.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
<link rel="stylesheet" crossorigin href="/assets/index-Cz-7Ak8T.css">
<link rel="stylesheet" crossorigin href="/assets/index-CyAQuGX0.css">
</head>
<body>
<div id="root"></div>␍
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react"
import type { Platte } from "@/lib/typen"
import { Plattenliste } from "./Plattenliste"
// Plattengesundheit (Ausbauplan „Später“, Punkt 18, 26.09.2026): die echte Host-NVMe und eine kränkelnde NAS-Platte.
const platte = (mehr: Partial<Platte>): Platte => ({
id: "pve:1", name: "PELADN 512GB", wo: "Proxmox-Host /dev/nvme0n1", typ: "nvme", groesse: 512110190592,
gesund: true, verbraucht: 2, temperatur: 41, stunden: 9992, fehler: { medien: 0 }, stufe: "ok", saetze: [], ...mehr,
})
describe("Plattenliste", () => {
it("gesunde Platte: Zustand, Verbrauch, Wärme, Laufzeit", () => {
render(<Plattenliste platten={[platte({})]} />)
expect(screen.getByText("Proxmox-Host /dev/nvme0n1 · NVMe · 512 GB")).toBeInTheDocument()
expect(screen.getByText(/meldet sich gesund/)).toBeInTheDocument()
expect(screen.getByText(/2 % verbraucht · 41 °C · läuft seit 1,1 Jahren/)).toBeInTheDocument()
})
it("was schlechter wird, steht als Satz in der Zeile", () => {
render(<Plattenliste platten={[platte({ id: "nas:2", name: "ST4000VN008", wo: "NAS HDD2", typ: "hdd", groesse: null,
gesund: false, verbraucht: null, stunden: null, stufe: "rot", saetze: ["meldet sich selbst als defekt (SMART)"] })]} />)
expect(screen.getByText(/meldet sich selbst als defekt \(SMART\)/)).toHaveClass("text-rot-text")
expect(screen.getByText("NAS HDD2 · Festplatte")).toBeInTheDocument()
})
it("ohne Werte ein Satz", () => {
render(<Plattenliste platten={[]} />)
expect(screen.getByText("Noch keine Werte.")).toBeInTheDocument()
})
})
@@ -0,0 +1,54 @@
import { cn } from "cn"
import type { Platte } from "@/lib/typen"
// Plattengesundheit (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): je Platte, ob sie sich gesund meldet, wie viel
// verbraucht ist, wie warm sie ist und seit wann sie läuft. Gemeldet wird nur, was schlecht ist oder schlechter wird
// (backend/kern/platten.py) — dann steht der Satz dazu in der Zeile.
const PUNKT = { ok: "bg-gruen", gelb: "bg-bernstein", rot: "bg-rot shadow-[0_0_8px_var(--rot)]" } as const
const TEXT = { ok: "text-text-3", gelb: "text-bernstein", rot: "text-rot-text" } as const
const TYP: Record<string, string> = { nvme: "NVMe", ssd: "SSD", hdd: "Festplatte" }
function groesse(bytes: number | null): string | null {
if (!bytes) return null
const tb = bytes / 1e12
return tb >= 1 ? `${tb.toLocaleString("de-DE", { maximumFractionDigits: 1 })} TB` : `${Math.round(bytes / 1e9)} GB`
}
function laufzeit(stunden: number | null): string | null {
if (!stunden) return null
const jahre = stunden / 8766
return jahre >= 1 ? `läuft seit ${jahre.toLocaleString("de-DE", { maximumFractionDigits: 1 })} Jahren`
: `läuft seit ${Math.round(stunden / 24)} Tagen`
}
export function Plattenliste({ platten }: { platten: Platte[] }) {
if (platten.length === 0) return <p className="text-sm text-text-3">Noch keine Werte.</p>
return (
<ul className="m-0 flex list-none flex-col p-0">
{platten.map((p) => {
const gesund = p.gesund === true ? "meldet sich gesund" : p.gesund === false ? "meldet sich krank" : "Zustand unbekannt"
const werte = [
p.verbraucht != null ? `${p.verbraucht} % verbraucht` : null,
p.temperatur != null ? `${p.temperatur} °C` : null,
laufzeit(p.stunden),
].filter(Boolean)
return (
<li key={p.id} className="flex items-start gap-3 border-t border-white/[0.06] py-3 first:border-t-0">
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", PUNKT[p.stufe])} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-[15px] font-semibold break-words">{p.name}</span>
<span className="text-[13px] text-text-3">
{[p.wo, TYP[p.typ] ?? null, groesse(p.groesse)].filter(Boolean).join(" · ")}
</span>
<span className={cn("text-sm", p.stufe === "ok" ? "text-text-2" : TEXT[p.stufe])}>
{p.saetze.length ? p.saetze.join("; ") : gesund}
{werte.length ? <span className="text-text-3"> · {werte.join(" · ")}</span> : null}
</span>
</span>
</li>
)
})}
</ul>
)
}
+27 -1
View File
@@ -11,7 +11,7 @@ import { stromVerbunden } from "./strom"
import type {
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Platte, HomelabPlatten, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -512,6 +512,32 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
retry: false,
})
/** Plattengesundheit (seit 26.09.2026): die KI-Box selbst und das Homelab (Proxmox-Host, NAS über SNMP). */
export const useBoxPlatten = () =>
useQuery({ queryKey: ["platten"], queryFn: () => api<{ platten: Platte[] }>("/api/platten"), refetchInterval: 600_000, retry: false })
export const useHomelabPlatten = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-platten"],
queryFn: () => api<HomelabPlatten>("/api/homelab/platten"),
enabled: aktiv,
refetchInterval: 600_000,
retry: false,
})
/** SNMP-Zugang des NAS: speichern (erst prüft der Homelab-Teil das NAS damit) oder mit null entfernen. */
export const useNasSnmp = () =>
useAktion(
(zugang: { community: string; adresse: string } | null) =>
zugang === null
? api<AktionsErgebnis>("/api/homelab/einstellungen/nas-snmp", { method: "DELETE" })
: post<AktionsErgebnis>("/api/homelab/einstellungen/nas-snmp", zugang),
{
erfolg: (v, e) => (e as AktionsErgebnis).text ?? (v === null ? "SNMP-Zugang entfernt." : "SNMP-Zugang gespeichert."),
neuLaden: ["homelab-einstellungen", "homelab-platten"],
},
)
/** Lucys Freiheiten je Aktionsart (seit 25.09.2026, Einstellungen → Lucy). */
export const useLucyAutonomie = (aktiv = true) =>
useQuery({
+24
View File
@@ -555,6 +555,8 @@ export interface HomelabEinstellungen {
}
/** Zugang für die Statistik von AdGuard („% geblockt“ auf der Kachel, seit 25.09.2026). */
adguard?: { url: string; zugang: "hinterlegt" | "fehlt"; benutzer: string | null }
/** Seit 26.09.2026: SNMP-Zugang des NAS (nie der Zugangsname selbst). */
nas_snmp?: { zugang: "hinterlegt" | "fehlt"; adresse: string | null }
karenz_h: number
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
}
@@ -891,3 +893,25 @@ export interface Wochenbericht {
zuletzt: { zeit: number; text: string } | null
naechster: number
}
// --- Plattengesundheit (GET /api/platten, /api/homelab/platten; seit 26.09.2026) -----------------------------------
export interface Platte {
id: string
name: string
wo: string
typ: string
groesse: number | null
gesund: boolean | null
verbraucht: number | null
temperatur: number | null
stunden: number | null
fehler: Record<string, number>
stufe: "ok" | "gelb" | "rot"
saetze: string[]
}
export interface HomelabPlatten {
platten: Platte[]
nas: { eingerichtet: boolean; ok: boolean; detail: string | null; system: string | null }
}
+61 -1
View File
@@ -9,7 +9,7 @@ import { Freiheiten, WochenberichtAbschnitt } from "@/components/lucy/LucyEinste
import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen"
import { Button } from "@/components/ui/button"
import {
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
useAdguardZugang, useArcaneEcht, useNasSnmp, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
} from "@/lib/abfragen"
import { api, post } from "@/lib/api"
@@ -584,6 +584,65 @@ function AdguardZugang({ h }: { h: HomelabEinstellungen }) {
)
}
/** NAS-Platten über SNMP (seit 26.09.2026): der Zugangsname („Community“), vorher fragt der Homelab-Teil das NAS. */
function NasSnmp({ h }: { h: HomelabEinstellungen }) {
const speichern = useNasSnmp()
const [community, setCommunity] = useState("")
const [adresse, setAdresse] = useState("")
const [loeschFrage, setLoeschFrage] = useState(false)
const n = h.nas_snmp
if (!n) return null
const feld = "h-11 w-full rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] outline-none focus:border-lab/60 focus:bg-white/[0.06]"
return (
<Abschnitt titel="NAS-Platten (SNMP)">
<div className="flex flex-wrap items-center gap-2 text-sm text-text-2">
Zugang {n.zugang === "hinterlegt" ? <Chip art="gruen">hinterlegt</Chip> : <Chip art="grau">keiner</Chip>}
{n.adresse && <span className="text-text-3">NAS {n.adresse}</span>}
</div>
<p className="text-sm text-text-2">
Damit meldet der Orchestrator, wenn eine Platte im NAS kränkelt. Am QNAP: Systemsteuerung → Netzwerk- und
Dateidienste → SNMP → „SNMP-Dienst aktivieren“, Version SNMP v1/v2, einen Zugangsnamen („Community“) wählen, der
nicht „public“ heißt — und ihn hier eintragen.
</p>
<form
className="flex flex-wrap gap-2"
onSubmit={(e) => {
e.preventDefault()
if (community.trim()) {
speichern.mutate({ community: community.trim(), adresse: adresse.trim() },
{ onSuccess: () => { setCommunity(""); setAdresse("") } })
}
}}
>
<label htmlFor="nas-community" className="sr-only">Zugangsname (Community)</label>
<input id="nas-community" type="password" autoComplete="new-password" value={community}
onChange={(e) => setCommunity(e.target.value)} placeholder="Zugangsname (Community)" className={cn(feld, "min-w-0 flex-1 basis-48")} />
<label htmlFor="nas-adresse" className="sr-only">Adresse des NAS</label>
<input id="nas-adresse" autoComplete="off" value={adresse} onChange={(e) => setAdresse(e.target.value)}
placeholder={n.adresse ? `Adresse (${n.adresse})` : "Adresse des NAS"} className={cn(feld, "min-w-0 flex-1 basis-40")} />
<Button type="submit" disabled={!community.trim() || speichern.isPending}>
{speichern.isPending ? "Prüft …" : "Verbindung testen und speichern"}
</Button>
</form>
<p className="text-xs text-text-3">
Vor dem Speichern fragt der Homelab-Teil das NAS damit. Antwortet es nicht, wird nichts gespeichert. Der
Zugangsname wird nie wieder angezeigt; die Adresse nur ändern, wenn sie von der angezeigten abweicht.
</p>
{n.zugang === "hinterlegt" && (
loeschFrage ? (
<span className="flex flex-wrap gap-2">
<Button variant="gefahr" size="sm" disabled={speichern.isPending}
onClick={() => { speichern.mutate(null); setLoeschFrage(false) }}>Wirklich entfernen?</Button>
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
</span>
) : (
<Button variant="ghost" size="sm" className="self-start" onClick={() => setLoeschFrage(true)}>Zugang entfernen</Button>
)
)}
</Abschnitt>
)
}
function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) {
const f = h.ausfuehrer
return (
@@ -620,6 +679,7 @@ function HomelabKarte() {
<>
<ArcaneSchluessel h={h.data} />
<AdguardZugang h={h.data} />
<NasSnmp h={h.data} />
<Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt>
<HomelabBetrieb h={h.data} />
</>
+17 -1
View File
@@ -11,9 +11,10 @@ import { Speicher } from "@/components/homelab/Speicher"
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import { Zertifikate } from "@/components/homelab/Zertifikate"
import { Plattenliste } from "@/components/cockpit/Plattenliste"
import {
useHomelab, useHomelabDocker, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
useHomelabZertifikate,
useHomelabPlatten, useHomelabZertifikate,
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
useZielAktion,
} from "@/lib/abfragen"
@@ -198,6 +199,7 @@ export function HomelabSeite() {
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
const zertifikate = useHomelabZertifikate(!!stand)
const platten = useHomelabPlatten(!!stand)
const probe = useSicherungsProbe()
const neu = useNeuEntdeckt(!!stand)
const docker = useHomelabDocker(!!stand)
@@ -267,6 +269,20 @@ export function HomelabSeite() {
/>
</Panel>
)}
{platten.data && (platten.data.platten.length > 0 || platten.data.nas.eingerichtet) && (
<Panel titel="Plattengesundheit" id="platten">
<Plattenliste platten={platten.data.platten} />
{platten.data.nas.eingerichtet && !platten.data.nas.ok && platten.data.nas.detail && (
<p className="text-sm text-bernstein">{platten.data.nas.detail}</p>
)}
{!platten.data.nas.eingerichtet && (
<p className="text-xs text-text-3">
Die Platten des NAS erscheinen, sobald am QNAP SNMP an ist und der Zugangsname unter Einstellungen → Homelab
steht.
</p>
)}
</Panel>
)}
{zertifikate.data?.gelesen && zertifikate.data.zertifikate.length > 0 && (
<Panel titel="Zertifikate" id="zertifikate">
<Zertifikate daten={zertifikate.data} />
+8 -1
View File
@@ -5,10 +5,11 @@ import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Flugplan } from "@/components/cockpit/Flugplan"
import { Instrument } from "@/components/cockpit/Instrument"
import { Panel } from "@/components/cockpit/Panel"
import { Plattenliste } from "@/components/cockpit/Plattenliste"
import { Warnpanel } from "@/components/cockpit/Warnpanel"
import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk"
import { Button } from "@/components/ui/button"
import { useRadar, useStart } from "@/lib/abfragen"
import { useBoxPlatten, useRadar, useStart } from "@/lib/abfragen"
import { RADAR_STATUS_TEXT, temperaturFarbe } from "@/lib/anzeige"
import { knappeVorhersage, STUFE_FARBE } from "@/lib/prognose"
import { useLetzteMetrik } from "@/lib/strom"
@@ -94,6 +95,7 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) {
export function StartSeite() {
const { data: start, error, isPending } = useStart()
const radar = useRadar()
const platten = useBoxPlatten()
if (isPending) return <Zustandsfeld text="Die Box meldet sich gleich …" />
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen — die Verbindungsanzeige
@@ -122,6 +124,11 @@ export function StartSeite() {
<RadarKasten radar={radar.data} />
</Panel>
</div>
{platten.data && platten.data.platten.length > 0 && (
<Panel titel="Plattengesundheit" id="platten">
<Plattenliste platten={platten.data.platten} />
</Panel>
)}
</>
)
}