feat(alerts): zehn Warnregeln mit Worker, Liste und Einstellungen (#011)

Regeln als reine Funktionen, Abgleich oeffnet und schliesst Warnungen anhand
eines Fingerabdrucks. Worker wertet stuendlich aus, zusaetzlich nach jedem
Report. 123 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 22:25:33 +02:00
co-authored by Claude Opus 5
parent 5018589f08
commit 8a299f6c5e
25 changed files with 1638 additions and 21 deletions
@@ -0,0 +1,57 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { WarnregelFormular, type RegelAnzeige } from "@/components/alerts/WarnregelFormular";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln";
export const metadata = { title: "Warnregeln" };
const BESCHRIFTUNGEN: Record<string, string> = {
tage: "Tage (mehrere Stufen mit Komma)",
stunden: "Stunden",
};
export default async function WarnregelnSeite() {
await warnregelnSicherstellen();
const gespeichert = await prisma.warnregelEinstellung.findMany();
const nachName = new Map(gespeichert.map((eintrag) => [eintrag.regel, eintrag]));
const regeln: RegelAnzeige[] = REGELN.map((regel) => {
const eintrag = nachName.get(regel.name);
const werte = (eintrag?.schwellwerte ?? regel.standardSchwellwerte) as Record<string, unknown>;
return {
name: regel.name,
beschreibung: regel.beschreibung,
aktiv: eintrag?.aktiv ?? true,
schwellwerte: Object.keys(regel.standardSchwellwerte).map((schluessel) => ({
name: schluessel,
beschriftung: BESCHRIFTUNGEN[schluessel] ?? schluessel,
wert: Array.isArray(werte[schluessel])
? (werte[schluessel] as number[]).join(", ")
: String(werte[schluessel] ?? ""),
})),
};
});
return (
<>
<ContentHeader titel="Warnregeln" brotkrumen={[{ titel: "Einstellungen" }, { titel: "Warnregeln" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Regeln und Schwellwerte" rahmen="primary">
<p className="text-body-secondary">
Die Regeln laufen stündlich im Worker und zusätzlich nach jedem eingehenden Report. Eine Warnung
wird nur einmal geöffnet und schließt sich selbst, sobald die Ursache behoben ist. Offene
Warnungen stehen unter <Link href="/warnungen">Warnungen</Link>.
</p>
<WarnregelFormular regeln={regeln} />
</Card>
</div>
</div>
</>
);
}
+1
View File
@@ -30,6 +30,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<Sidebar
hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts}
benutzer={BENUTZER}
/>
<main className="app-main">{children}</main>
+200
View File
@@ -0,0 +1,200 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { PruefenKnopf } from "@/components/alerts/PruefenKnopf";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import { alertQuittieren, alertSchliessen } from "@/server/actions/alerts";
export const metadata = { title: "Warnungen" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
const SCHWERE_FARBE = { GEFAHR: "danger", WARNUNG: "warning", INFO: "info" } as const;
export default async function WarnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const status = (Array.isArray(roh.status) ? roh.status[0] : roh.status) ?? "offen";
const [alerts, zahlen] = await Promise.all([
prisma.alert.findMany({
where: status === "erledigt" ? { erledigtAm: { not: null } } : { erledigtAm: null },
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
take: 200,
include: {
host: { select: { id: true, hostname: true } },
kunde: { select: { id: true, name: true } },
laufzeit: { select: { id: true } },
},
}),
prisma.alert.groupBy({
by: ["schwere"],
where: { erledigtAm: null },
_count: { _all: true },
}),
]);
const anzahl = (schwere: string) => zahlen.find((z) => z.schwere === schwere)?._count._all ?? 0;
return (
<>
<ContentHeader titel="Warnungen" brotkrumen={[{ titel: "Warnungen" }]} aktion={<PruefenKnopf />} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Gefahr"
wert={String(anzahl("GEFAHR"))}
icon="bi-exclamation-octagon"
farbe="danger"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Warnung"
wert={String(anzahl("WARNUNG"))}
icon="bi-exclamation-triangle"
farbe="warning"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Hinweis" wert={String(anzahl("INFO"))} icon="bi-info-circle" farbe="info" />
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Offen gesamt"
wert={String(anzahl("GEFAHR") + anzahl("WARNUNG") + anzahl("INFO"))}
icon="bi-bell"
farbe="neutral"
/>
</div>
</div>
<Card
titel={status === "erledigt" ? "Erledigte Warnungen" : "Offene Warnungen"}
rahmen={status === "erledigt" ? "info" : "danger"}
tabelle
werkzeuge={
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
<Link
href="/warnungen"
className={`btn ${status !== "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Offen
</Link>
<Link
href="/warnungen?status=erledigt"
className={`btn ${status === "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Erledigt
</Link>
</div>
}
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Schwere</th>
<th scope="col">Warnung</th>
<th scope="col">Betrifft</th>
<th scope="col">Seit</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{alerts.map((alert) => (
<tr key={alert.id}>
<td>
<Badge farbe={SCHWERE_FARBE[alert.schwere]}>
{alert.schwere === "GEFAHR"
? "Gefahr"
: alert.schwere === "WARNUNG"
? "Warnung"
: "Hinweis"}
</Badge>
</td>
<td>
<span className="fw-semibold">{alert.titel}</span>
<div className="small text-body-secondary">{alert.beschreibung}</div>
<div className="small text-body-secondary pl-mono">{alert.regel}</div>
</td>
<td className="small">
{alert.host ? (
<div>
<Link href={`/hosts/${alert.host.id}`}>{alert.host.hostname}</Link>
</div>
) : null}
{alert.kunde ? (
<div>
<Link href={`/kunden/${alert.kunde.id}`}>{alert.kunde.name}</Link>
</div>
) : null}
{alert.laufzeit ? (
<div>
<Link href={`/rechnungen/${alert.laufzeit.id}`}>Laufzeit öffnen</Link>
</div>
) : null}
</td>
<td className="small">
{formatDatumZeit(alert.geoeffnetAm)}
{alert.quittiertAm ? <div className="text-body-secondary">quittiert</div> : null}
{alert.erledigtAm ? (
<div className="text-body-secondary">
erledigt {formatDatumZeit(alert.erledigtAm)}
</div>
) : null}
</td>
<td className="text-end">
{alert.erledigtAm ? null : (
<div className="d-flex gap-2 justify-content-end">
{alert.quittiertAm ? null : (
<form action={alertQuittieren}>
<input type="hidden" name="id" value={alert.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
Quittieren
</button>
</form>
)}
<form action={alertSchliessen}>
<input type="hidden" name="id" value={alert.id} />
<button
type="submit"
className="btn btn-sm btn-outline-danger"
title="Schließt die Warnung; besteht die Ursache weiter, kommt sie beim nächsten Durchlauf zurück"
>
Schließen
</button>
</form>
</div>
)}
</td>
</tr>
))}
{alerts.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
{status === "erledigt" ? "Noch nichts erledigt." : "Keine offenen Warnungen."}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Die Regeln laufen stündlich im Worker und nach jedem eingehenden Report.{" "}
<Link href="/einstellungen/warnregeln">Schwellwerte ändern</Link>
</p>
</div>
</div>
</>
);
}