diff --git a/CHANGELOG.md b/CHANGELOG.md index b88a379..85db83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.11.0] – 2026-09-17 + +### Geändert +- Auswertung der Warnregeln um den Faktor 13 beschleunigt: neue Warnungen entstehen sammelweise (#015) +- Aufgaben-Karte auf dem Dashboard zeigt höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste (#015) +- Große Tabellen blenden auf schmalen Bildschirmen gestaffelt Spalten aus, statt quer zu scrollen (#015) + +### Behoben +- Das Logo war im dunklen Design praktisch unsichtbar; seine Farben folgen jetzt dem Farbschema (#015) + ## [0.10.0] – 2026-09-17 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 6b9bdbc..6c05316 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 6 h 03 min +**Gesamt:** 6 h 38 min | Issue | Zeit | |---|---| @@ -21,6 +21,7 @@ | #012 Benachrichtigungen | 0 h 25 min | | #013 Authentifizierung | 0 h 30 min | | #014 Deployment | 0 h 40 min | +| #015 Feinschliff | 0 h 35 min | | Meilenstein | Zeit | |---|---| @@ -36,12 +37,13 @@ | v0.8.0 | 0 h 25 min | **abgeschlossen** | v0.9.0 | 0 h 30 min | **abgeschlossen** | v0.10.0 | 0 h 40 min | **abgeschlossen** +| v0.11.0 | 0 h 35 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.10.0 +**Version:** 0.11.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -80,8 +82,12 @@ - #014 abgeschlossen: Dockerfile (Standalone, Non-root), Compose mit web/worker/postgres/migrate, LXC-Installer, Reverse-Proxy-Beispiele, Sicherungsskript, README mit Schnellstart und Betriebshinweisen - Container-Stapel wirklich gebaut und geprüft: Agent registriert sich, Report kommt an, Warnung entsteht automatisch - Meilenstein v0.10.0 abgeschlossen +- #015 abgeschlossen: Dark-Mode geprüft (Logo war unsichtbar – behoben), Mobilansicht ohne Querscrollen, Lasttest mit 200 Hosts und 365.000 Reports, Auswertung der Warnregeln von 3813 auf 293 ms beschleunigt, Sicherheitsdurchsicht +- Als v0.11.0 veröffentlicht statt 1.0.0: Feldtest, Handbuch, Favicons, CI und Verschlüsselung der Zugangsdaten stehen noch aus ### Entscheidungen +- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s. +- **Keine 1.0.0 ohne Feldtest:** Solange der Agent auf keinem echten Proxmox-Host lief, wäre die Zahl ein falsches Versprechen. - **Kein Auth.js (Version 5 ist Beta):** stattdessen `jose` für Sitzungs-Cookies und das stabile `openid-client` für OIDC – dieselbe Überlegung wie bei Prisma. - **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation. - **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig. @@ -111,4 +117,5 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #015 Feinschliff: Dark-Mode prüfen, Mobilansicht, Barrierefreiheit, Sicherheits-Durchsicht +- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline +- Für 1.0.0 fehlt vor allem der Feldtest auf echten Proxmox-Hosts (#018) diff --git a/docs/anleitung/bilder/dashboard-dunkel.png b/docs/anleitung/bilder/dashboard-dunkel.png new file mode 100644 index 0000000..610fbf6 Binary files /dev/null and b/docs/anleitung/bilder/dashboard-dunkel.png differ diff --git a/docs/anleitung/bilder/hosts-dunkel.png b/docs/anleitung/bilder/hosts-dunkel.png new file mode 100644 index 0000000..260ab48 Binary files /dev/null and b/docs/anleitung/bilder/hosts-dunkel.png differ diff --git a/docs/anleitung/bilder/login-dunkel.png b/docs/anleitung/bilder/login-dunkel.png new file mode 100644 index 0000000..627770e Binary files /dev/null and b/docs/anleitung/bilder/login-dunkel.png differ diff --git a/docs/anleitung/bilder/mobil-dashboard.png b/docs/anleitung/bilder/mobil-dashboard.png index d3dcdae..9e5e1e6 100644 Binary files a/docs/anleitung/bilder/mobil-dashboard.png and b/docs/anleitung/bilder/mobil-dashboard.png differ diff --git a/docs/anleitung/bilder/mobil-hosts.png b/docs/anleitung/bilder/mobil-hosts.png new file mode 100644 index 0000000..aeca5fc Binary files /dev/null and b/docs/anleitung/bilder/mobil-hosts.png differ diff --git a/docs/anleitung/bilder/mobil-rechnungen.png b/docs/anleitung/bilder/mobil-rechnungen.png new file mode 100644 index 0000000..ef57cb9 Binary files /dev/null and b/docs/anleitung/bilder/mobil-rechnungen.png differ diff --git a/docs/anleitung/bilder/pruefung-hell.png b/docs/anleitung/bilder/pruefung-hell.png new file mode 100644 index 0000000..4379c4f Binary files /dev/null and b/docs/anleitung/bilder/pruefung-hell.png differ diff --git a/docs/anleitung/bilder/rechnungen-dunkel.png b/docs/anleitung/bilder/rechnungen-dunkel.png new file mode 100644 index 0000000..02db255 Binary files /dev/null and b/docs/anleitung/bilder/rechnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/warnungen-dunkel.png b/docs/anleitung/bilder/warnungen-dunkel.png new file mode 100644 index 0000000..fc6c1c8 Binary files /dev/null and b/docs/anleitung/bilder/warnungen-dunkel.png differ diff --git a/docs/issues/015-feinschliff-v1.md b/docs/issues/015-feinschliff-v1.md index bf309aa..2bad06c 100644 --- a/docs/issues/015-feinschliff-v1.md +++ b/docs/issues/015-feinschliff-v1.md @@ -1,24 +1,59 @@ # #015 Feinschliff für 1.0 -**Status:** offen -**Meilenstein:** v1.0.0 +**Status:** erledigt +**Meilenstein:** v0.11.0 **Labels:** ui, sicherheit -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min ## Ziel Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung. ## Aufgaben -- [ ] Dark-Mode vollständig (alle Seiten, PDF bleibt hell) -- [ ] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px -- [ ] Kontrast- und Tastaturprüfung -- [ ] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens) -- [ ] Performance-Check mit 200 Hosts / 5 Jahren Reports +- [x] Dark-Mode vollständig (alle Seiten, PDF bleibt hell) +- [x] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px +- [x] Kontrast- und Tastaturprüfung +- [x] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens) +- [x] Performance-Check mit 200 Hosts / 5 Jahren Reports ## Akzeptanzkriterien -- [ ] Alle Seiten in Hell und Dunkel lesbar -- [ ] Keine bekannten kritischen Schwachstellen in Abhängigkeiten +- [x] Alle Seiten in Hell und Dunkel lesbar +- [x] Keine bekannten kritischen Schwachstellen in Abhängigkeiten ## Abhängigkeiten #014 ## Notizen / Entscheidungen +### Ergebnisse + +**Dark-Mode:** Das Logo war auf dunklem Grund praktisch unsichtbar (dunkles P auf dunklem Hintergrund). +Die Farben kommen jetzt aus dem Theme (`--pl-logo-p`), die Sidebar-Variante bleibt fest weiß, die +einfarbige Variante für PDF unverändert. Übrige Seiten in Dunkel geprüft: Kacheln, Tabellen, Karten und +Badges bleiben lesbar. + +**Mobil:** Bei 390 px musste man vorher quer scrollen. Jetzt blenden die großen Tabellen Spalten gestaffelt +aus (Key ab `md`, Produkt und Sockets ab `lg`, letzte Meldung und Beträge ab `xl`), sodass Host, Kunde, +Status und Ablauf ohne Scrollen sichtbar sind. + +**Lasttest** (200 Hosts, 365.000 Reports, 1.000 Laufzeiten, 25 Kunden): + +| Abfrage | vorher | nachher | +|---|---|---| +| Dashboard-Kennzahlen | 65 ms | 65 ms | +| Host-Tabelle, erste Seite | 7 ms | 6 ms | +| Suche nach Key | 20 ms | 22 ms | +| Rechnungsliste | 54 ms | 42 ms | +| Warnregeln auswerten | 3813 ms | **293 ms** | + +Die Auswertung legte 737 Warnungen einzeln an. Jetzt entstehen neue Warnungen mit einem `createMany`; +nur wiedereröffnete und geänderte Fälle werden einzeln angefasst. Außerdem zeigt die Aufgaben-Karte auf +dem Dashboard höchstens zwölf Punkte und verweist für den Rest auf die Rechnungsliste – vorher wären es +600 Einträge auf einer Karte gewesen. + +**Sicherheit:** `npm audit` meldet vier Funde mit hohem Rang, alle über `mysql2` und `deepmerge-ts` +in der **Prisma-CLI** (Entwicklungsabhängigkeit). Wir betreiben PostgreSQL, der MySQL-Treiber wird nie +geladen, und das Laufzeit-Abbild enthält die CLI gar nicht – sie steckt nur im Migrationsdienst. Behoben +wäre es erst mit einem Prisma-Downgrade auf 6.x; das wäre der schlechtere Tausch. Beobachten und mit dem +nächsten Prisma-Update erneut prüfen (#019). + +**Noch offen für 1.0.0:** Feldtest des Agents (#018), Benutzerhandbuch (#016), Favicon-Dateien (#017), +CI-Pipeline (#019) und die Verschlüsselung der Zugangsdaten (#021). Deshalb wird diese Phase als v0.11.0 +veröffentlicht, nicht als 1.0.0. diff --git a/docs/issues/README.md b/docs/issues/README.md index 3012d6a..6391bc3 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -18,11 +18,11 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min | | [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min | | [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min | -| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | in Arbeit | v1.0.0 | 6 h | – | +| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – | -| | **Summe** | | | **116 h** | **6 h 03 min** | +| | **Summe** | | | **116 h** | **6 h 38 min** | diff --git a/package.json b/package.json index e7a6b16..792feb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.10.0", + "version": "0.11.0", "private": true, "scripts": { "dev": "next dev", @@ -22,7 +22,8 @@ "worker": "tsx src/worker/index.ts", "alerts:check": "tsx scripts/alerts-pruefen.ts", "build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native", - "build:all": "npm run build && npm run build:worker" + "build:all": "npm run build && npm run build:worker", + "lasttest": "tsx scripts/lasttest.ts" }, "dependencies": { "@node-rs/argon2": "^2.2.1", diff --git a/scripts/lasttest.ts b/scripts/lasttest.ts new file mode 100644 index 0000000..90741c0 --- /dev/null +++ b/scripts/lasttest.ts @@ -0,0 +1,166 @@ +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@/generated/prisma/client"; +import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich"; +import { hostsNachAblauf, offeneAufgaben } from "@/server/queries/dashboard"; +import { hostListe } from "@/server/queries/hostliste"; +import { kennzahlen, suchIndex } from "@/server/queries/hosts"; +import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden"; +import { rechnungsListe } from "@/server/queries/rechnungen"; + +/** + * Lasttest: legt eine große Datenmenge an und misst die Abfragen der wichtigsten Seiten. + * Läuft gegen die Testdatenbank (`TEST_DATABASE_URL`), damit die Entwicklungsdaten unberührt bleiben. + * + * npm run lasttest + */ + +const HOSTS = Number(process.env.LAST_HOSTS ?? 200); +const KUNDEN = Number(process.env.LAST_KUNDEN ?? 25); +const JAHRE = Number(process.env.LAST_JAHRE ?? 5); +const REPORTS_PRO_HOST = JAHRE * 365; + +const url = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL; +if (!url) throw new Error("TEST_DATABASE_URL oder DATABASE_URL setzen."); +process.env.DATABASE_URL = url; + +const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); + +async function messen(name: string, arbeit: () => Promise): Promise { + const start = performance.now(); + const ergebnis = await arbeit(); + const dauer = performance.now() - start; + const anzahl = Array.isArray(ergebnis) ? ` (${ergebnis.length} Einträge)` : ""; + console.log(` ${name.padEnd(38)} ${dauer.toFixed(0).padStart(6)} ms${anzahl}`); + return ergebnis; +} + +async function datenAnlegen() { + console.log(`Lege Testdaten an: ${KUNDEN} Kunden, ${HOSTS} Hosts, je ${REPORTS_PRO_HOST} Reports …`); + const start = performance.now(); + + await prisma.$executeRawUnsafe(` + TRUNCATE TABLE "Alert","Benachrichtigung","Report","Laufzeit","ProxmoxRechnungPosition", + "ProxmoxRechnung","KundenrechnungPosition","Kundenrechnung","ServerIdHistorie", + "SubscriptionKeyHistorie","AgentToken","Host","Standort","Kunde" RESTART IDENTITY CASCADE + `); + + for (let k = 0; k < KUNDEN; k++) { + await prisma.kunde.create({ + data: { id: `k${k}`, name: `Kunde ${k} GmbH`, kundennummer: `K-${9000 + k}` }, + }); + } + + const heute = new Date(); + for (let h = 0; h < HOSTS; h++) { + const ablauf = new Date(heute.getTime() + ((h % 400) - 30) * 86_400_000); + await prisma.host.create({ + data: { + id: `h${h}`, + agentId: `last-${h}`, + hostname: `pve-last-${String(h).padStart(3, "0")}`, + produkt: h % 7 === 0 ? "PBS" : "PVE", + kundeId: h % 20 === 0 ? null : `k${h % KUNDEN}`, + subKey: `pve1c-${String(h).padStart(10, "0")}`, + subStatus: h % 11 === 0 ? "EXPIRED" : "ACTIVE", + subNextduedate: ablauf, + subLizenzierteSockets: 1, + sockets: h % 13 === 0 ? 2 : 1, + ips: [`10.0.${Math.floor(h / 254)}.${h % 254}`], + letzterReportAm: new Date(heute.getTime() - (h % 5) * 86_400_000), + }, + }); + + // Reports in Blöcken, sonst dauert es unnötig lange. + const reports = Array.from({ length: REPORTS_PRO_HOST }, (_, i) => ({ + reportId: `last-${h}-${i}`, + hostId: `h${h}`, + erhobenAm: new Date(heute.getTime() - i * 86_400_000), + subStatus: "ACTIVE" as const, + subKey: `pve1c-${String(h).padStart(10, "0")}`, + rohdaten: {}, + })); + for (let i = 0; i < reports.length; i += 2000) { + await prisma.report.createMany({ data: reports.slice(i, i + 2000) }); + } + + // Fünf Laufzeiten je Host, jede zweite mit Rechnungen. + for (let j = 0; j < JAHRE; j++) { + const beginn = new Date(Date.UTC(heute.getUTCFullYear() - j, h % 12, ((h + j) % 27) + 1)); + const laufzeit = await prisma.laufzeit.create({ + data: { + hostId: `h${h}`, + key: `pve1c-${String(h).padStart(10, "0")}`, + start: beginn, + ende: new Date(Date.UTC(beginn.getUTCFullYear() + 1, beginn.getUTCMonth(), beginn.getUTCDate() - 1)), + status: "BESTAETIGT", + }, + }); + + if ((h + j) % 2 === 0) { + const rechnung = await prisma.proxmoxRechnung.create({ + data: { + nummer: `PX-LAST-${h}-${j}`, + datum: beginn, + faelligAm: new Date(beginn.getTime() + 14 * 86_400_000), + nettoBetrag: 115, + bezahlt: j > 0, + positionen: { create: [{ bezeichnung: "Laufzeit", nettoBetrag: 115 }] }, + }, + include: { positionen: true }, + }); + await prisma.laufzeit.update({ + where: { id: laufzeit.id }, + data: { proxmoxPositionId: rechnung.positionen[0].id }, + }); + } + } + } + + console.log(`Daten angelegt in ${((performance.now() - start) / 1000).toFixed(1)} s\n`); +} + +async function main() { + await datenAnlegen(); + await warnregelnSicherstellen(); + + const [hosts, reports, laufzeiten] = await Promise.all([ + prisma.host.count(), + prisma.report.count(), + prisma.laufzeit.count(), + ]); + console.log(`Bestand: ${hosts} Hosts, ${reports} Reports, ${laufzeiten} Laufzeiten\n`); + + console.log("Dashboard:"); + await messen("Kennzahlen", () => kennzahlen()); + await messen("Hosts nach Ablauf", () => hostsNachAblauf()); + await messen("Offene Aufgaben", () => offeneAufgaben()); + await messen("Kundenliste", () => kundenListe()); + await messen("Rechnungspunkte je Kunde", () => offeneRechnungspunkteProKunde()); + + console.log("\nHost-Tabelle:"); + await messen("Erste Seite", () => hostListe({})); + await messen("Suche nach Key", () => hostListe({ q: "pve1c-0000000100" })); + await messen("Filter: bald ablaufend", () => hostListe({ ablauf: "30" })); + await messen("Filter: offene Rechnungen", () => hostListe({ rechnung: "offen" })); + await messen("Sortierung nach Ablauf", () => hostListe({ sortierung: "ablauf" })); + + console.log("\nWeitere Seiten:"); + await messen("Rechnungsliste (offen)", () => rechnungsListe({ status: "offen" })); + await messen("Suchindex (Strg+K)", () => suchIndex()); + await messen("Warnregeln auswerten", () => alertsAbgleichen()); + + const offeneAlerts = await prisma.alert.count({ where: { erledigtAm: null } }); + console.log(`\nOffene Warnungen nach der Auswertung: ${offeneAlerts}`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + process.exit(0); + }) + .catch(async (fehler) => { + console.error(fehler); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/app/(dashboard)/hosts/page.tsx b/src/app/(dashboard)/hosts/page.tsx index 50635c2..b87eb97 100644 --- a/src/app/(dashboard)/hosts/page.tsx +++ b/src/app/(dashboard)/hosts/page.tsx @@ -125,11 +125,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP - Produkt + + Produkt + - Key + + Key + - Sockets + + Sockets + {host.standort.name} ) : null} - {PRODUKT_TEXT[host.produkt]} + {PRODUKT_TEXT[host.produkt]} - + - + {host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"} {host.socketAbweichung ? ( ) : null} - {formatDatumZeit(host.letzterReportAm)} + + {formatDatumZeit(host.letzterReportAm)} + ))} {hosts.length === 0 ? ( diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index ff79ef5..02cd1d6 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -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() { {aufgaben.length}} + werkzeuge={ + + {aufgabenDaten.gesamt + ohneKunde.length} + + } > + {aufgabenDaten.gesamt > aufgabenDaten.aufgaben.length ? ( +

+ {aufgabenDaten.gesamt - aufgabenDaten.aufgaben.length} weitere Punkte unter{" "} + Rechnungen. +

+ ) : null}
diff --git a/src/app/(dashboard)/rechnungen/page.tsx b/src/app/(dashboard)/rechnungen/page.tsx index bb57f7b..14a3526 100644 --- a/src/app/(dashboard)/rechnungen/page.tsx +++ b/src/app/(dashboard)/rechnungen/page.tsx @@ -201,22 +201,32 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: Kunde - Host - Laufzeit + + Host + + + Laufzeit + Proxmox-Rechnung - Netto + + Netto + Bezahlt Kundenrechnung - Netto + + Netto + Erstellt Bezahlt - Marge + + Marge + @@ -233,12 +243,12 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: Nicht zugeordnet )} - + {zeile.hostname} - + {formatDatum(zeile.start)} – {formatDatum(zeile.ende)} @@ -252,7 +262,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: ) : null} - {formatEuro(zeile.einkaufBetrag)} + {formatEuro(zeile.einkaufBetrag)} - {formatEuro(zeile.verkaufBetrag)} + {formatEuro(zeile.verkaufBetrag)} - = 0 ? "pl-text-success" : "pl-text-danger"}> + = 0 ? "pl-text-success" : "pl-text-danger"}`} + > {zeile.marge === null ? "–" : formatEuro(zeile.marge)} diff --git a/src/app/globals.css b/src/app/globals.css index db89b87..27b6b80 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index b76169e..a1be078 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -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 = { - standard: { p: "#1E2A44", linien: "#17A2B8" }, +const FESTE_FARBEN: Record, { 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 ( + - ); } -/** 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, diff --git a/src/server/alerts/abgleich.ts b/src/server/alerts/abgleich.ts index b6103cb..9bdfeb1 100644 --- a/src/server/alerts/abgleich.ts +++ b/src/server/alerts/abgleich.ts @@ -82,26 +82,27 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise 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 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, }, }); } diff --git a/src/server/queries/dashboard.ts b/src/server/queries/dashboard.ts index 877dbc1..1276229 100644 --- a/src/server/queries/dashboard.ts +++ b/src/server/queries/dashboard.ts @@ -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 }; }