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:
@@ -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>>;
|
||||
Reference in New Issue
Block a user