feat(cluster): Cluster-Knoten ohne Agent erkennen (#022, #018)

Feldtest auf zwei echten Proxmox-VE-9.2-Hosts (#018): Installation, Timer,
Zwischenspeicher und Nachsenden laufen wie vorgesehen. `pvesubscription get`
kennt kein JSON, ohne Subscription fehlen fast alle Felder, `pveversion`
enthaelt den Kernel doppelt - alles im Agent beruecksichtigt.

Dabei zeigte sich: in Proxmox hat jeder Knoten eine eigene Subscription.
Der Agent meldet darum die Cluster-Mitglieder aus `pvecm nodes`, und eine
elfte Warnregel meldet Knoten, von denen sich kein Agent meldet (#022).

Ausserdem behoben: die Bilder des Handbuchs zeigten allesamt die
Anmeldeseite, weil Firefox eine selbst erzeugte Cookie-Datei stillschweigend
verwirft. Das Skript nutzt jetzt einen lokalen Vermittler und bricht ab,
wenn alle Aufnahmen gleich gross sind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:05:08 +02:00
co-authored by Claude Opus 5
parent dc685980d8
commit 7728fcb565
39 changed files with 484 additions and 82 deletions
+35
View File
@@ -46,6 +46,16 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
select: { id: true, name: true },
});
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
const geschwister =
host.clusterMitglieder.length > 0
? await prisma.host.findMany({
where: { hostname: { in: host.clusterMitglieder, mode: "insensitive" } },
select: { id: true, hostname: true, subStatus: true, letzterReportAm: true },
})
: [];
const meldende = new Map(geschwister.map((eintrag) => [eintrag.hostname.toLowerCase(), eintrag]));
const jetzt = new Date();
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
const socketAbweichung =
@@ -113,6 +123,31 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<Zeile bezeichnung="Cluster / Node">
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
</Zeile>
{host.clusterMitglieder.length > 0 ? (
<Zeile bezeichnung={`Cluster-Knoten (${host.clusterMitglieder.length})`}>
<span className="d-inline-block text-start">
{host.clusterMitglieder.map((mitglied) => {
const eintrag = meldende.get(mitglied.toLowerCase());
return (
<span key={mitglied} className="d-block small">
{eintrag ? (
<Link href={`/hosts/${eintrag.id}`} className="text-decoration-none">
{mitglied}
</Link>
) : (
mitglied
)}{" "}
{eintrag ? (
<Badge farbe="success">meldet sich</Badge>
) : (
<Badge farbe="warning">kein Agent</Badge>
)}
</span>
);
})}
</span>
</Zeile>
) : null}
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
<Zeile bezeichnung="Sockets (lizenziert / physisch)">
+1
View File
@@ -36,6 +36,7 @@ export const reportSchema = z.object({
ips: z.array(z.string().max(64)).max(32).nullish(),
clusterName: nullbarerText,
nodeName: nullbarerText,
clusterMitglieder: z.array(z.string().max(255)).max(64).nullish(),
kernel: nullbarerText,
uptimeSekunden: z.number().int().nonnegative().nullish(),
cpuModell: nullbarerText,
+1
View File
@@ -195,6 +195,7 @@ export async function verarbeiteReport(
serverId: serverId ?? host.serverId,
clusterName: eingabe.host.clusterName ?? host.clusterName,
nodeName: eingabe.host.nodeName ?? host.nodeName,
clusterMitglieder: eingabe.host.clusterMitglieder ?? host.clusterMitglieder,
ips: eingabe.host.ips ?? host.ips,
sockets: eingabe.host.sockets ?? host.sockets,
cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
+2
View File
@@ -30,6 +30,8 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
hosts: hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
clusterName: host.clusterName,
clusterMitglieder: host.clusterMitglieder,
kundeId: host.kundeId,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
+36
View File
@@ -22,6 +22,8 @@ export interface AlertKandidat {
export interface HostDaten {
id: string;
hostname: string;
clusterName: string | null;
clusterMitglieder: string[];
kundeId: string | null;
kundeName: string | null;
subKey: string | null;
@@ -287,6 +289,39 @@ const hostOhneKunde: Regel = {
},
};
const clusterKnotenOhneAgent: Regel = {
name: "cluster-knoten-ohne-agent",
beschreibung: "Ein Cluster-Knoten meldet sich nicht beim Dashboard",
standardSchwellwerte: {},
pruefe(kontext) {
// Alle gemeldeten Hostnamen – ein Knoten gilt als bekannt, wenn er selbst meldet.
const meldende = new Set(kontext.hosts.map((host) => host.hostname.toLowerCase()));
const fehlende = new Map<string, { cluster: string; melder: HostDaten }>();
for (const host of kontext.hosts) {
if (!host.clusterName) continue;
for (const mitglied of host.clusterMitglieder) {
const name = mitglied.trim();
if (!name || meldende.has(name.toLowerCase())) continue;
if (!fehlende.has(name.toLowerCase())) {
fehlende.set(name.toLowerCase(), { cluster: host.clusterName, melder: host });
}
}
}
return [...fehlende.entries()].map(([name, eintrag]) => ({
fingerprint: `cluster-knoten-ohne-agent:${eintrag.cluster}:${name}`,
regel: "cluster-knoten-ohne-agent",
schwere: "WARNUNG" as AlertSchwere,
titel: "Cluster-Knoten ohne Agent",
beschreibung: `${name} gehört zum Cluster ${eintrag.cluster} (gemeldet von ${eintrag.melder.hostname}), meldet sich aber nicht – dort fehlt der Agent`,
hostId: eintrag.melder.id,
kundeId: eintrag.melder.kundeId,
laufzeitId: null,
}));
},
};
const proxmoxRechnungFaellig: Regel = {
name: "proxmox-rechnung-faellig",
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
@@ -378,6 +413,7 @@ export const REGELN: Regel[] = [
serverIdGeaendert,
keyMehrfach,
hostOhneKunde,
clusterKnotenOhneAgent,
proxmoxRechnungFaellig,
kundenrechnungFehlt,
kundenrechnungUnbezahlt,