Merge branch 'phase/026-uebersicht'
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 99 KiB |
@@ -1,19 +1,24 @@
|
||||
# #026 Übersicht (Dashboard)
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 25 min
|
||||
|
||||
## Ziel
|
||||
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||
|
||||
## Abhängigkeiten
|
||||
#021
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Alles berechnet, nichts hinterlegt** (`src/server/queries/uebersicht.ts`): Kacheln und Listen können einander nicht widersprechen. Die Prototyp-Beispieldaten sind entfernt.
|
||||
- **Hosts mit Abweichung** fasst Befunde je Host und Benutzer zusammen („2 unbekannte Schlüssel · Rechte falsch“, feste Reihenfolge) und zeigt nicht erreichbare Hosts; Link direkt auf die Hostseite.
|
||||
- **Bald ablaufend** zeigt auch bereits abgelaufene, noch aktive Schlüssel („abgelaufen · noch ausgerollt“ in Koralle).
|
||||
- **Letzte Aktionen** ohne Anmeldungen – die stehen im Audit-Log.
|
||||
- Knöpfe nur für passende Rollen (Abgleich: Hosts verwalten; Assistent: Private Keys ausrollen).
|
||||
|
||||
+92
-63
@@ -1,15 +1,6 @@
|
||||
import { ArrowRight, Plus, Route } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
abweichungen,
|
||||
BEISPIEL_JETZT,
|
||||
baldAblaufend,
|
||||
kennzahlen,
|
||||
letzteAktionen,
|
||||
letzterAbgleich,
|
||||
type AktionsArt,
|
||||
} from "@/beispiel/uebersicht";
|
||||
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
@@ -19,7 +10,10 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { uebersicht, type AktionsArt } from "@/server/queries/uebersicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
@@ -29,36 +23,45 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
||||
erstellt: { text: "Erstellt", farbe: "text-text" },
|
||||
abgleich: { text: "Abgleich", farbe: "text-info" },
|
||||
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
||||
anmeldung: { text: "Anmeldung", farbe: "text-muted" },
|
||||
verweigert: { text: "Verweigert", farbe: "text-danger" },
|
||||
sonstiges: { text: "Aktion", farbe: "text-text" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
||||
*/
|
||||
/** Übersicht (#026): Aufbau aus dem Prototyp (#002), Zahlen aus der Datenbank. */
|
||||
export default async function Uebersicht() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
const { benutzer } = await sitzungErforderlich();
|
||||
const jetzt = new Date();
|
||||
const { letzterAbgleich, kennzahlen, mitAbweichung, weitereAbweichungen, baldAblaufend, letzteAktionen } =
|
||||
await uebersicht(jetzt);
|
||||
const verwalten = darf(benutzer.rolle, "hostsVerwalten");
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Übersicht"
|
||||
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
||||
untertitel={
|
||||
<>
|
||||
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
||||
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
||||
{datumZeit(letzterAbgleich)}
|
||||
</time>
|
||||
</>
|
||||
letzterAbgleich ? (
|
||||
<>
|
||||
Letzter Abgleich {relativ(letzterAbgleich, jetzt)} ·{" "}
|
||||
<time dateTime={letzterAbgleich.toISOString()} className="font-mono text-[13px]">
|
||||
{datumZeit(letzterAbgleich)}
|
||||
</time>
|
||||
</>
|
||||
) : (
|
||||
"Noch kein Abgleich gelaufen."
|
||||
)
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<AlleAbgleichenKnopf />
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
{verwalten && <AlleAbgleichenKnopf />}
|
||||
{darf(benutzer.rolle, "privateKeyAusrollen") && (
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
href="/schluessel"
|
||||
href="/schluessel/neu"
|
||||
variante="primaer"
|
||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||
>
|
||||
@@ -98,46 +101,60 @@ export default async function Uebersicht() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
||||
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
|
||||
<Tabelle
|
||||
beschriftung="Hosts mit Abweichung"
|
||||
spalten={[
|
||||
{ titel: "Host" },
|
||||
{ titel: "Benutzer" },
|
||||
{ titel: "Befund" },
|
||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||
]}
|
||||
>
|
||||
{abweichungen.map((a) => (
|
||||
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{a.host}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={a.status} text={a.befund} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<Link
|
||||
href="/hosts"
|
||||
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||
>
|
||||
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||
</Link>
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
<Karte
|
||||
titel="Hosts mit Abweichung"
|
||||
untertitel={
|
||||
weitereAbweichungen
|
||||
? `Ergebnis des letzten Abgleichs · ${weitereAbweichungen} weitere`
|
||||
: "Ergebnis des letzten Abgleichs"
|
||||
}
|
||||
>
|
||||
{mitAbweichung.length === 0 ? (
|
||||
<p className="text-ok text-[14px]">● Alle Hosts synchron – keine offenen Abweichungen.</p>
|
||||
) : (
|
||||
<Tabelle
|
||||
beschriftung="Hosts mit Abweichung"
|
||||
spalten={[
|
||||
{ titel: "Host" },
|
||||
{ titel: "Benutzer" },
|
||||
{ titel: "Befund" },
|
||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||
]}
|
||||
>
|
||||
{mitAbweichung.map((a) => (
|
||||
<TabellenZeile key={`${a.hostId}-${a.benutzer}-${a.status}`}>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{a.host}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={a.status} text={a.befund} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<Link
|
||||
href={`/hosts/${a.hostId}`}
|
||||
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||
>
|
||||
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||
</Link>
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
||||
{baldAblaufend.length === 0 && (
|
||||
<p className="text-muted text-[14px]">Kein Schlüssel läuft in den nächsten 30 Tagen ab.</p>
|
||||
)}
|
||||
<ul className="flex flex-col">
|
||||
{baldAblaufend.map((s) => {
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
const tage = tageBis(s.ablauf, jetzt);
|
||||
return (
|
||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||
<Link
|
||||
href="/schluessel"
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
@@ -146,8 +163,16 @@ export default async function Uebersicht() {
|
||||
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Badge variante="messing" className="shrink-0">
|
||||
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
||||
<Badge variante={tage < 0 ? "gefahr" : "messing"} className="shrink-0">
|
||||
<time dateTime={s.ablauf.toISOString()}>
|
||||
{tage < 0
|
||||
? `abgelaufen${s.ausgerollt ? " · noch ausgerollt" : ""}`
|
||||
: tage === 0
|
||||
? "heute"
|
||||
: tage === 1
|
||||
? "morgen"
|
||||
: `in ${tage} Tagen`}
|
||||
</time>
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -168,13 +193,17 @@ export default async function Uebersicht() {
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{letzteAktionen.length === 0 && <Leerzustand titel="Noch keine Aktionen" />}
|
||||
<ol className="flex flex-col">
|
||||
{letzteAktionen.map((a) => (
|
||||
<li
|
||||
key={a.zeit}
|
||||
key={a.id}
|
||||
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
||||
>
|
||||
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
|
||||
<time
|
||||
dateTime={a.zeit.toISOString()}
|
||||
className="text-muted font-mono text-[12.5px] whitespace-nowrap"
|
||||
>
|
||||
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
||||
</time>
|
||||
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* BEISPIELDATEN für den klickbaren Prototyp (#002) – keine echten Hosts oder Schlüssel.
|
||||
* Die echten Lesemodelle kommen in #021 (Abgleich) und #026 (Übersicht); dann entfällt
|
||||
* diese Datei. Alle Zeiten beziehen sich auf BEISPIEL_JETZT, damit der statisch gebaute
|
||||
* Prototyp nicht mit jedem Build andere Relativangaben zeigt.
|
||||
*/
|
||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
||||
|
||||
export const BEISPIEL_JETZT = "2026-10-12T07:00:00Z"; // 09:00 Berliner Zeit
|
||||
|
||||
export const letzterAbgleich = "2026-10-12T06:42:00Z";
|
||||
|
||||
export const kennzahlen = {
|
||||
hosts: { wert: 42, hinweis: "38 synchron · 1 nicht erreichbar" },
|
||||
schluessel: { wert: 117, hinweis: "davon 9 mit Private Key" },
|
||||
abweichungen: { wert: 3, hinweis: "auf 3 Hosts" },
|
||||
ablaufend: { wert: 5, hinweis: "in den nächsten 30 Tagen" },
|
||||
};
|
||||
|
||||
export type Abweichung = {
|
||||
host: string;
|
||||
benutzer: string;
|
||||
status: HostStatus;
|
||||
befund: string;
|
||||
};
|
||||
|
||||
export const abweichungen: Abweichung[] = [
|
||||
{ host: "srv-web03.intern", benutzer: "root", status: "drift", befund: "1 fremder Schlüssel" },
|
||||
{ host: "srv-db01.intern", benutzer: "deploy", status: "drift", befund: "Schlüssel fehlt" },
|
||||
{ host: "fw-edge01.intern", benutzer: "sshkv", status: "drift", befund: "Optionen geändert" },
|
||||
{ host: "srv-legacy02.intern", benutzer: "root", status: "unerreichbar", befund: "nicht erreichbar" },
|
||||
];
|
||||
|
||||
export type AblaufEintrag = { zweck: string; person: string; typ: string; ablauf: string };
|
||||
|
||||
export const baldAblaufend: AblaufEintrag[] = [
|
||||
{ zweck: "Laptop Florian", person: "Florian Wilde", typ: "ed25519", ablauf: "2026-10-16T10:00:00Z" },
|
||||
{ zweck: "CI-Deployment Shop", person: "Dienst", typ: "ed25519", ablauf: "2026-10-21T10:00:00Z" },
|
||||
{
|
||||
zweck: "Backup srv-backup01 → srv-pve01",
|
||||
person: "Dienst",
|
||||
typ: "ed25519",
|
||||
ablauf: "2026-10-30T10:00:00Z",
|
||||
},
|
||||
{ zweck: "Notebook Sandra", person: "Sandra Keller", typ: "rsa-4096", ablauf: "2026-11-04T10:00:00Z" },
|
||||
{ zweck: "Monitoring Zabbix", person: "Dienst", typ: "ed25519", ablauf: "2026-11-09T10:00:00Z" },
|
||||
];
|
||||
|
||||
export type AktionsArt = "ausgerollt" | "widerrufen" | "erstellt" | "abgleich" | "rotiert";
|
||||
|
||||
export type LetzteAktion = {
|
||||
zeit: string;
|
||||
art: AktionsArt;
|
||||
beschreibung: string;
|
||||
benutzer: string;
|
||||
};
|
||||
|
||||
export const letzteAktionen: LetzteAktion[] = [
|
||||
{
|
||||
zeit: "2026-10-12T06:42:00Z",
|
||||
art: "abgleich",
|
||||
beschreibung: "Abgleich aller Hosts: 3 Abweichungen",
|
||||
benutzer: "System",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-12T06:15:00Z",
|
||||
art: "ausgerollt",
|
||||
beschreibung: "Public Key „Backup srv-backup01 → srv-pve01“ auf backup@srv-pve01",
|
||||
benutzer: "Florian Wilde",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-11T15:30:00Z",
|
||||
art: "widerrufen",
|
||||
beschreibung: "Schlüssel „Laptop alt Thomas“ von 6 Hosts entfernt",
|
||||
benutzer: "Florian Wilde",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-11T12:04:00Z",
|
||||
art: "rotiert",
|
||||
beschreibung: "CI-Deployment Shop durch neuen ed25519-Schlüssel ersetzt",
|
||||
benutzer: "Sandra Keller",
|
||||
},
|
||||
{
|
||||
zeit: "2026-10-11T09:20:00Z",
|
||||
art: "erstellt",
|
||||
beschreibung: "Schlüssel „Monitoring Zabbix“ aus Vorlage Deployment-Zugang",
|
||||
benutzer: "Sandra Keller",
|
||||
},
|
||||
];
|
||||
@@ -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>>;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { aktionEinordnen, befundZusammenfassen, uebersicht } from "@/server/queries/uebersicht";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
|
||||
/** Übersicht (#026): Zahlen aus der Datenbank, nie hinterlegt. */
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
describe("Befunde und Aktionen", () => {
|
||||
it("fasst Befunde lesbar zusammen", () => {
|
||||
expect(befundZusammenfassen(["UNBEKANNTER_SCHLUESSEL"])).toBe("1 unbekannter Schlüssel");
|
||||
expect(befundZusammenfassen(["UNBEKANNTER_SCHLUESSEL", "UNBEKANNTER_SCHLUESSEL", "RECHTE"])).toBe(
|
||||
"2 unbekannte Schlüssel · Rechte falsch",
|
||||
);
|
||||
});
|
||||
|
||||
it("ordnet Aktionen fuer die Farbe ein", () => {
|
||||
expect(aktionEinordnen("authorized_keys.geschrieben", "ERFOLG")).toBe("ausgerollt");
|
||||
expect(aktionEinordnen("schluessel.widerruf.eingeleitet", "ERFOLG")).toBe("widerrufen");
|
||||
expect(aktionEinordnen("schluessel.erzeugt", "ERFOLG")).toBe("erstellt");
|
||||
expect(aktionEinordnen("anmeldung.notfall", "VERWEIGERT")).toBe("verweigert");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kennzahlen", () => {
|
||||
it("zaehlt Hosts, Schluessel, Abweichungen und Ablaeufe aus der Datenbank", async () => {
|
||||
const jetzt = new Date("2026-10-12T10:00:00Z");
|
||||
const tag = 86_400_000;
|
||||
const a = await prisma.host.create({
|
||||
data: { name: "srv-a", adresse: "10.0.0.1", driftZustand: "SYNCHRON", letzterAbgleichAm: jetzt },
|
||||
});
|
||||
const b = await prisma.host.create({
|
||||
data: { name: "srv-b", adresse: "10.0.0.2", driftZustand: "DRIFT" },
|
||||
});
|
||||
await prisma.host.create({ data: { name: "srv-c", adresse: "10.0.0.3", erreichbar: false } });
|
||||
await prisma.host.create({ data: { name: "srv-alt", adresse: "10.0.0.4", archiviertAm: jetzt } });
|
||||
await prisma.abweichung.createMany({
|
||||
data: [
|
||||
{ hostId: b.id, zielbenutzer: "root", art: "UNBEKANNTER_SCHLUESSEL", fingerprint: "SHA256:x" },
|
||||
{ hostId: b.id, zielbenutzer: "root", art: "RECHTE" },
|
||||
{ hostId: a.id, zielbenutzer: "root", art: "RECHTE", behobenAm: jetzt },
|
||||
],
|
||||
});
|
||||
let n = 0;
|
||||
const key = (daten: Record<string, unknown>) =>
|
||||
prisma.schluessel.create({
|
||||
data: {
|
||||
name: `k${++n}`,
|
||||
slug: `k${n}`,
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: "ssh-ed25519 AAAA",
|
||||
fingerprint: `SHA256:k${n}`,
|
||||
kommentar: "k",
|
||||
...daten,
|
||||
},
|
||||
});
|
||||
await key({ ablaufAm: new Date(jetzt.getTime() + 5 * tag) });
|
||||
await key({ ablaufAm: new Date(jetzt.getTime() - 2 * tag) });
|
||||
await key({ ablaufAm: new Date(jetzt.getTime() + 60 * tag) });
|
||||
await key({ status: "WIDERRUFEN", ablaufAm: new Date(jetzt.getTime() + 1 * tag) });
|
||||
await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG", hostName: "srv-a" });
|
||||
|
||||
const u = await uebersicht(jetzt);
|
||||
expect(u.kennzahlen.hosts).toEqual({ wert: 3, hinweis: "1 synchron · 1 nicht erreichbar" });
|
||||
expect(u.kennzahlen.schluessel.wert).toBe(3);
|
||||
expect(u.kennzahlen.abweichungen).toEqual({ wert: 2, hinweis: "auf 1 Host(s)" });
|
||||
expect(u.kennzahlen.ablaufend).toEqual({ wert: 1, hinweis: "1 bereits abgelaufen" });
|
||||
expect(u.mitAbweichung.map((m) => [m.host, m.befund])).toEqual([
|
||||
["srv-b", "1 unbekannter Schlüssel · Rechte falsch"],
|
||||
["srv-c", "nicht erreichbar"],
|
||||
]);
|
||||
expect(u.baldAblaufend.map((s) => s.zweck)).toEqual(["k2", "k1"]);
|
||||
expect(u.letzterAbgleich).toEqual(jetzt);
|
||||
expect(u.letzteAktionen[0]).toMatchObject({ art: "abgleich", benutzer: "System" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user