perf(alerts): Warnungen sammelweise anlegen, Dark-Mode und Mobilansicht verbessern (#015)

Lasttest mit 200 Hosts und 365.000 Reports: Auswertung der Warnregeln von 3813
auf 293 ms. Logo im dunklen Design sichtbar gemacht, Tabellen blenden auf
schmalen Bildschirmen Spalten aus, Aufgaben-Karte begrenzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 23:27:23 +02:00
co-authored by Claude Opus 5
parent 9647c19136
commit 4c65c9ddab
22 changed files with 354 additions and 63 deletions
+15 -7
View File
@@ -125,11 +125,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<tr>
<SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} />
<SortierKopf titel="Kunde" feld="kunde" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Produkt</th>
<th scope="col" className="d-none d-lg-table-cell">
Produkt
</th>
<SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Key</th>
<th scope="col" className="d-none d-md-table-cell">
Key
</th>
<SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Sockets</th>
<th scope="col" className="d-none d-lg-table-cell">
Sockets
</th>
<SortierKopf
titel="Letzte Meldung"
feld="meldung"
@@ -165,17 +171,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<div className="small text-body-secondary">{host.standort.name}</div>
) : null}
</td>
<td>{PRODUKT_TEXT[host.produkt]}</td>
<td className="d-none d-lg-table-cell">{PRODUKT_TEXT[host.produkt]}</td>
<td>
<StatusBadge status={host.subStatus} />
</td>
<td>
<td className="d-none d-md-table-cell">
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</td>
<td>
<td className="d-none d-lg-table-cell">
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.socketAbweichung ? (
<i
@@ -184,7 +190,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
/>
) : null}
</td>
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td>
<td className="small d-none d-xl-table-cell">
{formatDatumZeit(host.letzterReportAm)}
</td>
</tr>
))}
{hosts.length === 0 ? (
+14 -4
View File
@@ -15,7 +15,7 @@ export const metadata = { title: "Dashboard" };
export default async function DashboardSeite() {
const heute = new Date();
const [zahlen, ohneKunde, ablaufListe, aufgabenRoh, kunden, rechnungspunkte] = await Promise.all([
const [zahlen, ohneKunde, ablaufListe, aufgabenDaten, kunden, rechnungspunkte] = await Promise.all([
kennzahlen(heute),
hostsOhneKunde(),
hostsNachAblauf(heute),
@@ -25,7 +25,7 @@ export default async function DashboardSeite() {
]);
const aufgaben: Aufgabe[] = [
...aufgabenRoh.map((aufgabe) => ({
...aufgabenDaten.aufgaben.map((aufgabe) => ({
id: aufgabe.id,
titel: aufgabe.titel,
detail: [
@@ -41,7 +41,7 @@ export default async function DashboardSeite() {
href: aufgabe.href,
aktion: aufgabe.aktion,
})),
...ohneKunde.map((host) => ({
...ohneKunde.slice(0, 5).map((host) => ({
id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
@@ -183,9 +183,19 @@ export default async function DashboardSeite() {
<Card
titel="Aufgaben"
rahmen="warning"
werkzeuge={<span className="badge pl-bg-warning">{aufgaben.length}</span>}
werkzeuge={
<span className="badge pl-bg-warning">
{aufgabenDaten.gesamt + ohneKunde.length}
</span>
}
>
<TodoList aufgaben={aufgaben} />
{aufgabenDaten.gesamt > aufgabenDaten.aufgaben.length ? (
<p className="small text-body-secondary mt-3 mb-0">
{aufgabenDaten.gesamt - aufgabenDaten.aufgaben.length} weitere Punkte unter{" "}
<Link href="/rechnungen">Rechnungen</Link>.
</p>
) : null}
</Card>
</div>
</div>
+22 -10
View File
@@ -201,22 +201,32 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<thead>
<tr>
<th scope="col">Kunde</th>
<th scope="col">Host</th>
<th scope="col">Laufzeit</th>
<th scope="col" className="d-none d-md-table-cell">
Host
</th>
<th scope="col" className="d-none d-lg-table-cell">
Laufzeit
</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center">
Erstellt
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Marge</th>
<th scope="col" className="d-none d-xl-table-cell">
Marge
</th>
</tr>
</thead>
<tbody>
@@ -233,12 +243,12 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<td className="d-none d-md-table-cell">
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td>
<td className="d-none d-lg-table-cell">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
@@ -252,7 +262,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div>
) : null}
</td>
<td>{formatEuro(zeile.einkaufBetrag)}</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
@@ -266,7 +276,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<td>
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td>{formatEuro(zeile.verkaufBetrag)}</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
@@ -286,7 +296,9 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung vom Kunden bezahlt"
/>
</td>
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}>
<td
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td>
</tr>
+12
View File
@@ -19,6 +19,8 @@
--pl-sidebar-text: #c2c7d0;
--pl-sidebar-divider: #4b545c;
--pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
--pl-logo-p: #1e2a44;
--pl-logo-linien: #17a2b8;
--bs-primary: var(--pl-primary);
--bs-primary-rgb: 0, 123, 255;
@@ -33,6 +35,16 @@
:root[data-bs-theme="dark"] {
--pl-content-bg: #1b1f24;
--pl-sidebar-bg: #22272d;
/* Auf dunklem Grund wäre das dunkle P unsichtbar. */
--pl-logo-p: #e9ecef;
}
.pl-logo-p {
fill: var(--pl-logo-p);
}
.pl-logo-linien {
stroke: var(--pl-logo-linien);
}
body {
+17 -8
View File
@@ -1,6 +1,7 @@
/**
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien.
* Varianten: "standard" (heller Grund), "sidebar" (dunkler Grund), "mono" (einfarbig für PDF/Rechnungen).
* Varianten: "standard" folgt dem Farbschema (im Dunkelmodus hell), "sidebar" ist immer weiß,
* "mono" einfarbig für PDF und Rechnungen.
*/
export type LogoVariante = "standard" | "sidebar" | "mono";
@@ -9,18 +10,19 @@ interface LogoProps {
variante?: LogoVariante;
groesse?: number;
className?: string;
/** Für dekorative Verwendung neben dem Schriftzug. */
/** Für dekorative Verwendung neben dem Schriftzug leer lassen. */
titel?: string;
}
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = {
standard: { p: "#1E2A44", linien: "#17A2B8" },
const FESTE_FARBEN: Record<Exclude<LogoVariante, "standard">, { p: string; linien: string }> = {
sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
mono: { p: "#1E2A44", linien: "#1E2A44" },
};
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) {
const farben = FARBEN[variante];
// Die Standardvariante nimmt ihre Farben aus dem Theme, damit sie auch auf dunklem Grund lesbar bleibt.
const farben = variante === "standard" ? null : FESTE_FARBEN[variante];
return (
<svg
viewBox="0 0 48 48"
@@ -36,14 +38,21 @@ export function Logo({ variante = "standard", groesse = 40, className, titel }:
<path
fillRule="evenodd"
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
fill={farben.p}
className={farben ? undefined : "pl-logo-p"}
fill={farben?.p}
/>
<path
d="M22 36h20M22 43h14"
className={farben ? undefined : "pl-logo-linien"}
stroke={farben?.linien}
strokeWidth="3.5"
strokeLinecap="round"
/>
<path d="M22 36h20M22 43h14" stroke={farben.linien} strokeWidth="3.5" strokeLinecap="round" />
</svg>
);
}
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */
/** Logo mit Schriftzug, wie in Sidebar und Anmeldung verwendet. */
export function LogoMitSchriftzug({
variante = "standard",
groesse = 36,
+31 -14
View File
@@ -82,26 +82,27 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
const kontext = await kontextLaden(jetzt);
const kandidaten = alleRegelnPruefen(kontext);
const offene = await prisma.alert.findMany({ where: { erledigtAm: null } });
// Alle betroffenen Warnungen in einem Zug laden: die offenen und die, deren Fall wieder auftritt.
const fingerprints = kandidaten.map((k) => k.fingerprint);
const [offene, bekannte] = await Promise.all([
prisma.alert.findMany({ where: { erledigtAm: null } }),
fingerprints.length > 0
? prisma.alert.findMany({ where: { fingerprint: { in: fingerprints } } })
: Promise.resolve([]),
]);
const offeneNachFingerprint = new Map(offene.map((alert) => [alert.fingerprint, alert]));
const bekannteNachFingerprint = new Map(bekannte.map((alert) => [alert.fingerprint, alert]));
const kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k]));
const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint));
const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint));
for (const kandidat of neu) {
// Ein früher erledigter Fall taucht wieder auf: denselben Eintrag erneut öffnen.
await prisma.alert.upsert({
where: { fingerprint: kandidat.fingerprint },
update: {
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
},
create: {
// Fälle, die es noch nie gab: in einem Rutsch anlegen.
const wirklichNeu = neu.filter((k) => !bekannteNachFingerprint.has(k.fingerprint));
if (wirklichNeu.length > 0) {
await prisma.alert.createMany({
data: wirklichNeu.map((kandidat) => ({
fingerprint: kandidat.fingerprint,
regel: kandidat.regel,
schwere: kandidat.schwere,
@@ -111,6 +112,22 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
kundeId: kandidat.kundeId,
laufzeitId: kandidat.laufzeitId,
geoeffnetAm: jetzt,
})),
skipDuplicates: true,
});
}
// Früher erledigte Fälle, die wieder auftreten: denselben Eintrag erneut öffnen.
for (const kandidat of neu.filter((k) => bekannteNachFingerprint.has(k.fingerprint))) {
await prisma.alert.update({
where: { fingerprint: kandidat.fingerprint },
data: {
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
},
});
}
+7 -3
View File
@@ -47,8 +47,12 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
});
}
/** Offene Rechnungspunkte als Aufgabenliste fürs Dashboard. */
export async function offeneAufgaben() {
/**
* Offene Rechnungspunkte als Aufgabenliste fürs Dashboard.
* Begrenzt, weil die Karte sonst bei vielen Hosts endlos lang wird – die vollständige
* Liste steht unter „Rechnungen“.
*/
export async function offeneAufgaben(grenze = 12) {
const laufzeiten = await prisma.laufzeit.findMany({
where: {
status: "BESTAETIGT",
@@ -116,5 +120,5 @@ export async function offeneAufgaben() {
}
}
return aufgaben;
return { aufgaben: aufgaben.slice(0, grenze), gesamt: aufgaben.length };
}