Welle 1 · Zertifikats-Wache: NPMplus, Proxmox-Host und PBS mit Grenzen nach Laufzeit

Der Ausführer liest alle 30 Minuten die https-Zertifikate (NPMplus samt dem, was es für den Namen ausliefert,
Proxmox-Host, PBS). Weil NPMplus das kurzlebige Profil von Let's Encrypt nutzt (6,7 Tage), gilt keine feste
30-Tage-Grenze: gelb „wird nicht erneuert“, sobald weniger als ein Drittel der Laufzeit übrig ist (langlebige: 30 Tage
vorher), rot unter einem Sechstel (1–7 Tage vor Ablauf), gelb auch, wenn NPMplus noch das alte ausliefert. Je Quelle
ein Hinweis; das Homelab-Cockpit zeigt die Liste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:45:57 +02:00
co-authored by Claude Opus 5.5
parent afa3a476b5
commit 53ea0cd9b6
11 changed files with 610 additions and 6 deletions
@@ -0,0 +1,40 @@
import { render, screen, within } from "@testing-library/react"
import type { Zertifikat } from "@/lib/typen"
import { Zertifikate } from "./Zertifikate"
const jetzt = Math.floor(Date.now() / 1000)
const npm: Zertifikat = {
quelle: "npm", wo: "NPMplus", gast: 101, name: "git.tobisniceshomelab.ddnsfree.com",
namen: ["git.tobisniceshomelab.ddnsfree.com"], datei: "npm-11", aussteller: "Let's Encrypt", von: jetzt - 2 * 86_400,
bis: jetzt + 4 * 86_400 + 3600, rest_s: 4 * 86_400 + 3600, laufzeit_s: 575_999, stufe: "ok", art: null, problem: null,
}
const pve: Zertifikat = {
...npm, quelle: "pve", wo: "Proxmox-Host", gast: null, name: "pve.fritz.box", namen: [], datei: "pve-ssl.pem",
aussteller: "PVE Cluster Manager CA", bis: jetzt + 680 * 86_400, rest_s: 680 * 86_400, laufzeit_s: 730 * 86_400,
}
describe("Zertifikate", () => {
it("bleibt zu, solange alles gilt, und erklärt die kurze Laufzeit von NPMplus", () => {
const { container } = render(<Zertifikate daten={{ gelesen: true, zertifikate: [npm, pve] }} />)
expect(container.querySelector("details")!.open).toBe(false)
expect(screen.getByText("2 Zertifikate")).toBeInTheDocument()
expect(screen.getByText("alle gültig")).toBeInTheDocument()
expect(within(screen.getByRole("row", { name: /git\.tobis/ })).getByText("noch 4 Tage")).toBeInTheDocument()
expect(within(screen.getByRole("row", { name: /pve\.fritz\.box/ })).getByText("noch 23 Monate")).toBeInTheDocument()
expect(screen.getByText(/gelten nur knapp eine Woche/)).toBeInTheDocument()
})
it("klappt auf, wenn eine Erneuerung hängt", () => {
const haengt = { ...npm, stufe: "gelb" as const, art: "haengt" as const, rest_s: 2 * 86_400,
problem: "wird nicht erneuert, noch 2 Tage gültig (bis 29.09. 22:45)" }
const { container } = render(<Zertifikate daten={{ gelesen: true, zertifikate: [haengt, pve] }} />)
expect(container.querySelector("details")!.open).toBe(true)
expect(screen.getByText("1 mit Problem")).toHaveClass("text-bernstein")
expect(screen.getByText("wird nicht erneuert, noch 2 Tage gültig (bis 29.09. 22:45)")).toBeInTheDocument()
})
it("zeigt nichts ohne Zertifikate vom Ausführer", () => {
const { container } = render(<Zertifikate daten={{ gelesen: false, zertifikate: [] }} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,80 @@
import { ChevronRight } from "lucide-react"
import { cn } from "cn"
import type { HomelabZertifikate, Zertifikat } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// Zertifikats-Wache (Ausbauplan Welle 1, seit 25.09.2026): die https-Zertifikate von NPMplus, Proxmox-Host und PBS mit
// Ablauf. Die Grenzen richten sich nach der Laufzeit (NPMplus: 6,7 Tage, Let's Encrypt kurzlebig) — die Regeln stehen
// in backend/services/homelab/zertifikate.py.
const SPALTEN = "md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1.4fr)]"
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 bisText = (s: number) => flugplanZeit(new Date(s * 1000).toISOString()).toLowerCase()
/** „noch 4 Tage“, „noch 20 Stunden“, „noch 3 Monate“, „noch 999 Jahre“. */
function nochText(sekunden: number): string {
if (sekunden <= 0) return "abgelaufen"
if (sekunden < 86_400) return `noch ${Math.max(1, Math.round(sekunden / 3600))} Std.`
const tage = Math.round(sekunden / 86_400)
if (tage < 60) return `noch ${tage} ${tage === 1 ? "Tag" : "Tage"}`
if (tage < 730) return `noch ${Math.round(tage / 30)} Monate`
return `noch ${Math.round(tage / 365)} Jahre`
}
function Zeile({ z }: { z: Zertifikat }) {
const weitere = z.namen.length > 1 ? ` und ${z.namen.length - 1} weitere` : ""
return (
<div role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-1 border-t border-white/[0.06] py-3 md:items-center", SPALTEN)}>
<span role="rowheader" className="col-span-2 flex min-w-0 items-start gap-2.5 md:col-span-1">
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", PUNKT[z.stufe])} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-all">{z.name}{weitere}</span>
<span className="text-[13px] text-text-3">{[z.wo, z.aussteller].filter(Boolean).join(" · ")}</span>
</span>
</span>
<span role="cell" className="text-sm text-text-2">
<span className="text-xs text-text-3 md:hidden">Gültig bis </span>
<span className="ziffern">{bisText(z.bis)}</span>
<span className="block text-xs text-text-3">{nochText(z.rest_s)}</span>
</span>
<span role="cell" className={cn("text-sm", TEXT[z.stufe])}>{z.problem ?? "in Ordnung"}</span>
</div>
)
}
/** Die Zertifikate im Homelab-Cockpit: zugeklappt, solange alles stimmt; die Zeile darüber sagt, was als Nächstes
* abläuft. */
export function Zertifikate({ daten }: { daten: HomelabZertifikate }) {
const liste = daten.zertifikate
if (!daten.gelesen || liste.length === 0) return null
const probleme = liste.filter((z) => z.stufe !== "ok")
const kurzlebig = liste.some((z) => z.quelle === "npm" && z.laufzeit_s !== null && z.laufzeit_s < 30 * 86_400)
return (
<details open={probleme.length > 0} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
{liste.length} Zertifikate
</span>
<span className={cn("text-sm", probleme.length ? TEXT[probleme.some((z) => z.stufe === "rot") ? "rot" : "gelb"] : "text-text-2")}>
{probleme.length ? `${probleme.length} mit Problem` : "alle gültig"}
</span>
</summary>
<div role="table" aria-label="Zertifikate" className="flex flex-col">
<div role="row" className={cn("etikett hidden gap-x-4 pt-1 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Für</span>
<span role="columnheader">Gültig bis</span>
<span role="columnheader">Zustand</span>
</div>
{liste.map((z) => <Zeile key={`${z.quelle}-${z.datei}-${z.name}`} z={z} />)}
</div>
{kurzlebig && (
<p className="m-0 border-t border-white/[0.06] pt-3 text-sm text-text-3">
Die Zertifikate von NPMplus gelten nur knapp eine Woche und werden laufend erneuert — gemeldet wird erst,
wenn eine Erneuerung ausbleibt.
</p>
)}
</details>
)
}
+11 -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, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -492,6 +492,16 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
retry: false,
})
/** Die https-Zertifikate (Zertifikats-Wache, seit 25.09.2026) — der Ausführer liest sie alle 30 Minuten. */
export const useHomelabZertifikate = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-zertifikate"],
queryFn: () => api<HomelabZertifikate>("/api/homelab/zertifikate"),
enabled: aktiv,
refetchInterval: takt(300_000),
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.
+27
View File
@@ -623,6 +623,33 @@ export interface HomelabSpeicher {
empfehlungen: SpeicherEmpfehlung[]
}
// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) -----------------------------------
export interface Zertifikat {
/** npm = NPMplus, pve = Proxmox-Host, pbs = Proxmox Backup Server */
quelle: string
wo: string
gast: number | null
/** Der erste Name im Zertifikat (sonst der Inhaber). */
name: string
namen: string[]
datei: string | null
aussteller: string | null
von: number | null
bis: number
rest_s: number
laufzeit_s: number | null
stufe: "ok" | "gelb" | "rot"
art: "abgelaufen" | "ablauf" | "haengt" | "alt" | null
problem: string | null
}
export interface HomelabZertifikate {
/** Meldet der Ausführer schon Zertifikate? (Ein älterer tut es nicht.) */
gelesen: boolean
zertifikate: Zertifikat[]
}
// --- 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. */
+9 -4
View File
@@ -7,11 +7,10 @@ import { Lagebild } from "@/components/homelab/Lagebild"
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 {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
useSammellauf, useSammellaufQuittieren,
useSicherungsProbe,
useZielAktion,
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate,
useInstanz, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, useZielAktion,
} from "@/lib/abfragen"
import { lage, offeneUpdates } from "@/lib/homelab"
import { istSnapshotLauf } from "@/lib/snapshots"
@@ -198,6 +197,7 @@ export function HomelabSeite() {
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
const zertifikate = useHomelabZertifikate(!!stand)
const probe = useSicherungsProbe()
if (ersatz || !stand) return ersatz
@@ -256,6 +256,11 @@ export function HomelabSeite() {
/>
</Panel>
)}
{zertifikate.data?.gelesen && zertifikate.data.zertifikate.length > 0 && (
<Panel titel="Zertifikate" id="zertifikate">
<Zertifikate daten={zertifikate.data} />
</Panel>
)}
</>
)
}