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:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user