feat(uebersicht): echte Zahlen statt Beispieldaten (#026)

Kennzahlen, Hosts mit Abweichung, bald ablaufende Schluessel und letzte
Aktionen kommen aus der Datenbank; die Beispieldaten des Prototyps sind
entfernt. Befunde werden je Host und Benutzer in fester Reihenfolge
zusammengefasst, abgelaufene und noch ausgerollte Schluessel stehen in
Koralle. Anmeldungen bleiben dem Audit-Log vorbehalten, sonst
verdraengten sie auf der Uebersicht alles andere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:33:11 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit ca61ca5b41
7 changed files with 383 additions and 156 deletions
+201
View File
@@ -0,0 +1,201 @@
import type { AbweichungsArt } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt –
* Kacheln und Listen können so nie einander widersprechen.
*/
const TAG_MS = 86_400_000;
const BEFUND_TEXT: Record<AbweichungsArt, [string, string]> = {
UNBEKANNTER_SCHLUESSEL: ["unbekannter Schlüssel", "unbekannte Schlüssel"],
FEHLENDER_SCHLUESSEL: ["Schlüssel fehlt", "Schlüssel fehlen"],
ZUSAETZLICH_IM_BLOCK: ["Block von Hand geändert", "Block von Hand geändert"],
DATEI_GEAENDERT: ["Block beschädigt", "Blöcke beschädigt"],
RECHTE: ["Rechte falsch", "Rechte falsch"],
HOSTKEY_GEAENDERT: ["Host-Key geändert", "Host-Key geändert"],
PRIVATE_KEY_FEHLT: ["Private Key fehlt", "Private Keys fehlen"],
};
/** „2 unbekannte Schlüssel · Rechte falsch“ aus den Befunden eines Host/Benutzer-Paars. */
export function befundZusammenfassen(arten: readonly AbweichungsArt[]): string {
const anzahl = new Map<AbweichungsArt, number>();
for (const art of arten) anzahl.set(art, (anzahl.get(art) ?? 0) + 1);
// Feste Reihenfolge (die von BEFUND_TEXT), damit die Zeile nicht je nach Datenbank springt.
return (Object.keys(BEFUND_TEXT) as AbweichungsArt[])
.filter((art) => anzahl.has(art))
.map((art) => [art, anzahl.get(art)!] as const)
.map(([art, n]) => {
const [einzahl, mehrzahl] = BEFUND_TEXT[art];
return n === 1 ? (art === "UNBEKANNTER_SCHLUESSEL" ? `1 ${einzahl}` : einzahl) : `${n} ${mehrzahl}`;
})
.join(" · ");
}
export type AktionsArt =
| "ausgerollt"
| "widerrufen"
| "erstellt"
| "abgleich"
| "rotiert"
| "anmeldung"
| "sonstiges"
| "verweigert";
/** Grobe Einordnung fuer die Farbe in „Letzte Aktionen“; die Ansicht im Audit-Log ist genauer (#027). */
export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
if (ergebnis !== "ERFOLG") return "verweigert";
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
return "ausgerollt";
if (aktion.includes("widerruf")) return "widerrufen";
if (aktion.includes("rotation")) return "rotiert";
if (aktion === "abgleich") return "abgleich";
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
if (
aktion.endsWith(".erzeugt") ||
aktion.endsWith(".angelegt") ||
aktion.endsWith(".importiert") ||
aktion.endsWith(".uebernommen")
) {
return "erstellt";
}
return "sonstiges";
}
export async function uebersicht(jetzt = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * TAG_MS);
const [hosts, schluesselGesamt, schluesselPrivat, offen, ablaufend, aktionen] = await Promise.all([
prisma.host.findMany({
where: { archiviertAm: null },
select: {
id: true,
name: true,
erreichbar: true,
driftZustand: true,
letzterAbgleichAm: true,
bootstrapAm: true,
},
}),
prisma.schluessel.count({ where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" } } }),
prisma.schluessel.count({
where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" }, masterKeyVersion: { not: null } },
}),
prisma.abweichung.findMany({
where: { behobenAm: null, host: { archiviertAm: null } },
select: { art: true, zielbenutzer: true, hostId: true, host: { select: { name: true } } },
}),
prisma.schluessel.findMany({
where: { status: "AKTIV", ablaufAm: { not: null, lte: in30Tagen } },
select: {
id: true,
name: true,
typ: true,
kategorie: true,
ablaufAm: true,
person: { select: { name: true } },
_count: { select: { publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } } } },
},
orderBy: { ablaufAm: "asc" },
}),
// Anmeldungen sind im Audit-Log zu sehen; auf der Uebersicht verdraengten sie sonst alles.
prisma.protokoll.findMany({
where: { NOT: [{ aktion: { startsWith: "anmeldung" } }, { aktion: { startsWith: "bestaetigung" } }] },
orderBy: { id: "desc" },
take: 8,
select: {
id: true,
zeitpunkt: true,
akteurName: true,
aktion: true,
ergebnis: true,
hostName: true,
zielbenutzer: true,
},
}),
]);
// Befunde je Host und Benutzer zusammenfassen; nicht erreichbare Hosts zaehlen mit.
const gruppen = new Map<
string,
{ hostId: string; host: string; benutzer: string; arten: AbweichungsArt[] }
>();
for (const a of offen) {
const schluessel = `${a.hostId}\u0000${a.zielbenutzer ?? ""}`;
const gruppe = gruppen.get(schluessel) ?? {
hostId: a.hostId,
host: a.host.name,
benutzer: a.zielbenutzer ?? "–",
arten: [],
};
gruppe.arten.push(a.art);
gruppen.set(schluessel, gruppe);
}
const mitAbweichung = [
...[...gruppen.values()].map((g) => ({
hostId: g.hostId,
host: g.host,
benutzer: g.benutzer,
status: "drift" as const,
befund: befundZusammenfassen(g.arten),
})),
...hosts
.filter((h) => h.erreichbar === false)
.map((h) => ({
hostId: h.id,
host: h.name,
benutzer: "–",
status: "unerreichbar" as const,
befund: "nicht erreichbar",
})),
].sort((a, b) => a.host.localeCompare(b.host));
const letzterAbgleich = hosts.reduce<Date | null>(
(spaetester, h) =>
h.letzterAbgleichAm && (!spaetester || h.letzterAbgleichAm > spaetester)
? h.letzterAbgleichAm
: spaetester,
null,
);
const abgelaufen = ablaufend.filter((s) => s.ablaufAm! < jetzt).length;
return {
letzterAbgleich,
kennzahlen: {
hosts: {
wert: hosts.length,
hinweis: `${hosts.filter((h) => h.driftZustand === "SYNCHRON").length} synchron · ${hosts.filter((h) => h.erreichbar === false).length} nicht erreichbar`,
},
schluessel: { wert: schluesselGesamt, hinweis: `davon ${schluesselPrivat} mit Private Key` },
abweichungen: {
wert: offen.length,
hinweis: `auf ${new Set(offen.map((a) => a.hostId)).size} Host(s)`,
},
ablaufend: {
wert: ablaufend.length - abgelaufen,
hinweis: abgelaufen ? `${abgelaufen} bereits abgelaufen` : "in den nächsten 30 Tagen",
},
},
mitAbweichung: mitAbweichung.slice(0, 8),
weitereAbweichungen: Math.max(0, mitAbweichung.length - 8),
baldAblaufend: ablaufend.slice(0, 6).map((s) => ({
id: s.id,
zweck: s.name,
person: s.person?.name ?? (s.kategorie === "DIENST" ? "Dienst" : "Person"),
typ: s.typ === "RSA4096" ? "rsa-4096" : s.typ === "ED25519" ? "ed25519" : "anderer",
ablauf: s.ablaufAm!,
ausgerollt: s._count.publicVerwendungen > 0,
})),
letzteAktionen: aktionen.map((a) => ({
id: a.id.toString(),
zeit: a.zeitpunkt,
art: aktionEinordnen(a.aktion, a.ergebnis),
aktion: a.aktion,
beschreibung: [a.aktion, a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName]
.filter(Boolean)
.join(" · "),
benutzer: a.akteurName,
})),
};
}
export type Uebersicht = Awaited<ReturnType<typeof uebersicht>>;