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:
co-authored by
Claude Opus 5.5
parent
afa3a476b5
commit
53ea0cd9b6
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user