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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/server/agent/http";
|
||||
import { reportSchema } from "@/server/agent/schema";
|
||||
import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service";
|
||||
import { alertsAbgleichen } from "@/server/alerts/abgleich";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,6 +31,14 @@ export async function POST(request: Request) {
|
||||
const host = await authentifiziereHost(token);
|
||||
const ergebnis = await verarbeiteReport(host, eingabe.daten, clientIp(request.headers));
|
||||
|
||||
// Warnungen sofort neu bewerten – ein Report ist der häufigste Auslöser für Änderungen.
|
||||
// Schlägt das fehl, ist der Report trotzdem angenommen.
|
||||
try {
|
||||
await alertsAbgleichen();
|
||||
} catch (fehler) {
|
||||
console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
hostId: host.id,
|
||||
|
||||
Reference in New Issue
Block a user