Merge branch 'phase/027-protokoll-ansicht'
This commit is contained in:
@@ -1,18 +1,202 @@
|
||||
import { Download, ScrollText, Search } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { KettenPruefung } from "@/components/protokoll/KettenPruefung";
|
||||
import { ProtokollListe } from "@/components/protokoll/ProtokollListe";
|
||||
import { Button, buttonKlassen } from "@/components/ui/Button";
|
||||
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen";
|
||||
import {
|
||||
cursorAus,
|
||||
ERGEBNIS_TEXT,
|
||||
protokollFilterAktiv,
|
||||
protokollFilterAus,
|
||||
protokollFilterQuery,
|
||||
} from "@/lib/protokoll-filter";
|
||||
import { syslogZielText } from "@/lib/syslog";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
|
||||
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit-Log" };
|
||||
|
||||
export default async function Protokoll() {
|
||||
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
/**
|
||||
* Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und
|
||||
* der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen
|
||||
* Eintrag aendert oder loescht.
|
||||
*/
|
||||
export default async function Protokoll({ searchParams }: { searchParams: Suchparameter }) {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("lesen", "/protokoll");
|
||||
const parameter = await searchParams;
|
||||
const filter = protokollFilterAus(parameter);
|
||||
const vor = cursorAus(parameter["vor"]);
|
||||
const [seite, zaehler, akteure] = await Promise.all([
|
||||
protokollSeite(filter, vor),
|
||||
protokollZaehlen(filter),
|
||||
protokollAkteure(),
|
||||
]);
|
||||
const gefiltert = protokollFilterAktiv(filter);
|
||||
const query = protokollFilterQuery(filter);
|
||||
const kette = ketteZwischengespeichert();
|
||||
const syslog = syslogKonfig();
|
||||
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
||||
phase={5}
|
||||
issue="#027"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||
aktionen={
|
||||
<a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
CSV exportieren
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte innen="ohne" className="divide-border divide-y">
|
||||
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
|
||||
<p className="text-[14px]">
|
||||
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
|
||||
{zaehler.gesamt.toLocaleString("de-DE")}
|
||||
</span>{" "}
|
||||
<span className="text-muted">Einträge insgesamt</span>
|
||||
</p>
|
||||
{gefiltert && (
|
||||
<p className="text-muted text-[14px]">
|
||||
<span className="text-text font-medium tabular-nums">
|
||||
{zaehler.treffer.toLocaleString("de-DE")}
|
||||
</span>{" "}
|
||||
passen zum Filter
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted text-[13px] sm:ml-auto">
|
||||
Syslog:{" "}
|
||||
{syslog ? (
|
||||
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
|
||||
) : (
|
||||
<span>nicht eingerichtet</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 sm:px-6">
|
||||
<KettenPruefung
|
||||
anfang={
|
||||
kette
|
||||
? {
|
||||
fehlerhafteId: kette.fehlerhafteId,
|
||||
eintraege: kette.eintraege,
|
||||
geprueftAm: kette.geprueftAm.toISOString(),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
|
||||
<Feld
|
||||
beschriftung="Suche"
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={filter.q}
|
||||
placeholder="Fingerprint, Host oder Benutzer"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="col-span-2"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Host"
|
||||
name="host"
|
||||
defaultValue={filter.host}
|
||||
placeholder="z. B. srv-pve01"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
mono
|
||||
className="col-span-2 sm:col-span-1"
|
||||
/>
|
||||
<Auswahlliste
|
||||
beschriftung="Akteur"
|
||||
name="akteur"
|
||||
defaultValue={filter.akteur}
|
||||
className="col-span-2 sm:col-span-1"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
|
||||
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
|
||||
<option value="">Alle Aktionen</option>
|
||||
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
|
||||
<option value="">Alle</option>
|
||||
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
|
||||
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
|
||||
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
|
||||
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||
Filtern
|
||||
</Button>
|
||||
{gefiltert && (
|
||||
<Button href="/protokoll" className="flex-1 sm:flex-none">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{vor !== undefined && (
|
||||
<p className="text-muted text-[14px]">
|
||||
Einträge älter als #{String(vor)}.{" "}
|
||||
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
|
||||
Zu den neuesten
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{seite.zeilen.length === 0 ? (
|
||||
gefiltert ? (
|
||||
<Leerzustand
|
||||
icon={<Search className="size-5" />}
|
||||
titel="Keine Treffer"
|
||||
text="Kein Eintrag passt zu Suche und Filter."
|
||||
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
icon={<ScrollText className="size-5" />}
|
||||
titel="Noch keine Einträge"
|
||||
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Karte>
|
||||
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
|
||||
<ProtokollListe
|
||||
key={`${query}|${String(vor ?? "")}`}
|
||||
seite={seite}
|
||||
query={query.replace(/^\?/, "")}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { CSV_BOM, csvZeile } from "@/lib/csv";
|
||||
import { PROTOKOLL_CSV_KOPF, protokollCsvWerte, protokollFilterAus } from "@/lib/protokoll-filter";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { protokollStapelweise } from "@/server/queries/protokoll";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Protokoll als CSV (#027) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; der
|
||||
* Handler prueft das selbst.
|
||||
*
|
||||
* Gestreamt: die Datenbank liefert Stapel zu 500 Zeilen, jeder Stapel geht sofort hinaus.
|
||||
* Ein Protokoll mit Hunderttausenden Eintraegen muss so nie ganz in den Speicher.
|
||||
*/
|
||||
export async function GET(anfrage: Request) {
|
||||
let benutzer;
|
||||
try {
|
||||
({ benutzer } = await rechtErforderlich("lesen"));
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||
throw fehler;
|
||||
}
|
||||
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||
|
||||
// Vor dem ersten Byte: wer das Protokoll mitnimmt, steht selbst darin – und scheitert das
|
||||
// Schreiben, gibt es auch keinen Export.
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "protokoll.exportiert",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { filter: Object.fromEntries(Object.entries(filter).filter(([, wert]) => wert)) },
|
||||
});
|
||||
|
||||
const kodierer = new TextEncoder();
|
||||
const stapel = protokollStapelweise(filter);
|
||||
const strom = new ReadableStream<Uint8Array>({
|
||||
start(steuerung) {
|
||||
steuerung.enqueue(kodierer.encode(CSV_BOM + csvZeile(PROTOKOLL_CSV_KOPF) + "\r\n"));
|
||||
},
|
||||
// pull statt Schleife in start: der naechste Stapel wird erst gelesen, wenn der Client
|
||||
// den vorigen abgenommen hat (Gegendruck).
|
||||
async pull(steuerung) {
|
||||
try {
|
||||
const { value, done } = await stapel.next();
|
||||
if (done) {
|
||||
steuerung.close();
|
||||
return;
|
||||
}
|
||||
steuerung.enqueue(
|
||||
kodierer.encode(value.map((z) => csvZeile(protokollCsvWerte(z)) + "\r\n").join("")),
|
||||
);
|
||||
} catch (fehler) {
|
||||
steuerung.error(fehler);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await stapel.return(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const tag = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(strom, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="sshkv-protokoll-${tag}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user