Compare commits

...
3 Commits
Author SHA1 Message Date
florian.wilde f838a44991 Merge branch 'phase/015-feinschliff' 2026-09-17 23:27:23 +02:00
florian.wildeandClaude Opus 5 4c65c9ddab 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>
2026-09-17 23:27:23 +02:00
florian.wildeandClaude Opus 5 9647c19136 fix(lint): erzeugtes Worker-Buendel von der Pruefung ausnehmen (#014)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:13:28 +02:00
23 changed files with 365 additions and 64 deletions
+10
View File
@@ -17,6 +17,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### 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 ## [0.10.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+10 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 6 h 03 min **Gesamt:** 6 h 38 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -21,6 +21,7 @@
| #012 Benachrichtigungen | 0 h 25 min | | #012 Benachrichtigungen | 0 h 25 min |
| #013 Authentifizierung | 0 h 30 min | | #013 Authentifizierung | 0 h 30 min |
| #014 Deployment | 0 h 40 min | | #014 Deployment | 0 h 40 min |
| #015 Feinschliff | 0 h 35 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -36,12 +37,13 @@
| v0.8.0 | 0 h 25 min | **abgeschlossen** | v0.8.0 | 0 h 25 min | **abgeschlossen**
| v0.9.0 | 0 h 30 min | **abgeschlossen** | v0.9.0 | 0 h 30 min | **abgeschlossen**
| v0.10.0 | 0 h 40 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 ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.10.0 **Version:** 0.11.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - 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 - #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 - Container-Stapel wirklich gebaut und geprüft: Agent registriert sich, Report kommt an, Warnung entsteht automatisch
- Meilenstein v0.10.0 abgeschlossen - 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 ### 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. - **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. - **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. - **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) - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt ### 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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+45 -10
View File
@@ -1,24 +1,59 @@
# #015 Feinschliff für 1.0 # #015 Feinschliff für 1.0
**Status:** offen **Status:** erledigt
**Meilenstein:** v1.0.0 **Meilenstein:** v0.11.0
**Labels:** ui, sicherheit **Labels:** ui, sicherheit
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min
## Ziel ## Ziel
Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung. Produktionsreife: Dark-Mode, Mobilansicht, Barrierefreiheit und Sicherheitsprüfung.
## Aufgaben ## Aufgaben
- [ ] Dark-Mode vollständig (alle Seiten, PDF bleibt hell) - [x] Dark-Mode vollständig (alle Seiten, PDF bleibt hell)
- [ ] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px - [x] Mobil: Tabellen als Karten, Touch-Ziele ≥ 44 px
- [ ] Kontrast- und Tastaturprüfung - [x] Kontrast- und Tastaturprüfung
- [ ] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens) - [x] Sicherheits-Review (Abhängigkeiten, Header, Uploads, Tokens)
- [ ] Performance-Check mit 200 Hosts / 5 Jahren Reports - [x] Performance-Check mit 200 Hosts / 5 Jahren Reports
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Alle Seiten in Hell und Dunkel lesbar - [x] Alle Seiten in Hell und Dunkel lesbar
- [ ] Keine bekannten kritischen Schwachstellen in Abhängigkeiten - [x] Keine bekannten kritischen Schwachstellen in Abhängigkeiten
## Abhängigkeiten ## Abhängigkeiten
#014 #014
## Notizen / Entscheidungen ## 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.
+2 -2
View File
@@ -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 | | [#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 | | [#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 | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | | [#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 | – | | [#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** |
+11 -1
View File
@@ -7,7 +7,17 @@ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
prettier, prettier,
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts", "src/generated/**"]), globalIgnores([
".next/**",
"out/**",
"build/**",
"coverage/**",
"next-env.d.ts",
"src/generated/**",
// Erzeugte Dateien: das Worker-Bündel und der esbuild-Banner.
"dist/**",
"deploy/worker-banner.js",
]),
]); ]);
export default eslintConfig; export default eslintConfig;
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.10.0", "version": "0.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -22,7 +22,8 @@
"worker": "tsx src/worker/index.ts", "worker": "tsx src/worker/index.ts",
"alerts:check": "tsx scripts/alerts-pruefen.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: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": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
+166
View File
@@ -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<T>(name: string, arbeit: () => Promise<T>): Promise<T> {
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);
});
+15 -7
View File
@@ -125,11 +125,17 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
<tr> <tr>
<SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} /> <SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} />
<SortierKopf titel="Kunde" feld="kunde" 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} /> <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} /> <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 <SortierKopf
titel="Letzte Meldung" titel="Letzte Meldung"
feld="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> <div className="small text-body-secondary">{host.standort.name}</div>
) : null} ) : null}
</td> </td>
<td>{PRODUKT_TEXT[host.produkt]}</td> <td className="d-none d-lg-table-cell">{PRODUKT_TEXT[host.produkt]}</td>
<td> <td>
<StatusBadge status={host.subStatus} /> <StatusBadge status={host.subStatus} />
</td> </td>
<td> <td className="d-none d-md-table-cell">
<CopyableCode wert={host.subKey} /> <CopyableCode wert={host.subKey} />
</td> </td>
<td> <td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} /> <AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</td> </td>
<td> <td className="d-none d-lg-table-cell">
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"} {host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.socketAbweichung ? ( {host.socketAbweichung ? (
<i <i
@@ -184,7 +190,9 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
/> />
) : null} ) : null}
</td> </td>
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td> <td className="small d-none d-xl-table-cell">
{formatDatumZeit(host.letzterReportAm)}
</td>
</tr> </tr>
))} ))}
{hosts.length === 0 ? ( {hosts.length === 0 ? (
+14 -4
View File
@@ -15,7 +15,7 @@ export const metadata = { title: "Dashboard" };
export default async function DashboardSeite() { export default async function DashboardSeite() {
const heute = new Date(); 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), kennzahlen(heute),
hostsOhneKunde(), hostsOhneKunde(),
hostsNachAblauf(heute), hostsNachAblauf(heute),
@@ -25,7 +25,7 @@ export default async function DashboardSeite() {
]); ]);
const aufgaben: Aufgabe[] = [ const aufgaben: Aufgabe[] = [
...aufgabenRoh.map((aufgabe) => ({ ...aufgabenDaten.aufgaben.map((aufgabe) => ({
id: aufgabe.id, id: aufgabe.id,
titel: aufgabe.titel, titel: aufgabe.titel,
detail: [ detail: [
@@ -41,7 +41,7 @@ export default async function DashboardSeite() {
href: aufgabe.href, href: aufgabe.href,
aktion: aufgabe.aktion, aktion: aufgabe.aktion,
})), })),
...ohneKunde.map((host) => ({ ...ohneKunde.slice(0, 5).map((host) => ({
id: `zuweisen-${host.id}`, id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`, titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`, detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
@@ -183,9 +183,19 @@ export default async function DashboardSeite() {
<Card <Card
titel="Aufgaben" titel="Aufgaben"
rahmen="warning" 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} /> <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> </Card>
</div> </div>
</div> </div>
+22 -10
View File
@@ -201,22 +201,32 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<thead> <thead>
<tr> <tr>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
<th scope="col">Host</th> <th scope="col" className="d-none d-md-table-cell">
<th scope="col">Laufzeit</th> Host
</th>
<th scope="col" className="d-none d-lg-table-cell">
Laufzeit
</th>
<th scope="col">Proxmox-Rechnung</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"> <th scope="col" className="text-center">
Bezahlt Bezahlt
</th> </th>
<th scope="col">Kundenrechnung</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"> <th scope="col" className="text-center">
Erstellt Erstellt
</th> </th>
<th scope="col" className="text-center"> <th scope="col" className="text-center">
Bezahlt Bezahlt
</th> </th>
<th scope="col">Marge</th> <th scope="col" className="d-none d-xl-table-cell">
Marge
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -233,12 +243,12 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<span className="badge pl-bg-neutral">Nicht zugeordnet</span> <span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)} )}
</td> </td>
<td> <td className="d-none d-md-table-cell">
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none"> <Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname} {zeile.hostname}
</Link> </Link>
</td> </td>
<td> <td className="d-none d-lg-table-cell">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none"> <Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)} {formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link> </Link>
@@ -252,7 +262,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div> </div>
) : null} ) : null}
</td> </td>
<td>{formatEuro(zeile.einkaufBetrag)}</td> <td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center"> <td className="text-center">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
@@ -266,7 +276,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<td> <td>
<CopyableCode wert={zeile.verkaufNummer} /> <CopyableCode wert={zeile.verkaufNummer} />
</td> </td>
<td>{formatEuro(zeile.verkaufBetrag)}</td> <td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center"> <td className="text-center">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
@@ -286,7 +296,9 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung vom Kunden bezahlt" titel="Kundenrechnung vom Kunden bezahlt"
/> />
</td> </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)} {zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td> </td>
</tr> </tr>
+12
View File
@@ -19,6 +19,8 @@
--pl-sidebar-text: #c2c7d0; --pl-sidebar-text: #c2c7d0;
--pl-sidebar-divider: #4b545c; --pl-sidebar-divider: #4b545c;
--pl-font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace; --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: var(--pl-primary);
--bs-primary-rgb: 0, 123, 255; --bs-primary-rgb: 0, 123, 255;
@@ -33,6 +35,16 @@
:root[data-bs-theme="dark"] { :root[data-bs-theme="dark"] {
--pl-content-bg: #1b1f24; --pl-content-bg: #1b1f24;
--pl-sidebar-bg: #22272d; --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 { body {
+17 -8
View File
@@ -1,6 +1,7 @@
/** /**
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien. * 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"; export type LogoVariante = "standard" | "sidebar" | "mono";
@@ -9,18 +10,19 @@ interface LogoProps {
variante?: LogoVariante; variante?: LogoVariante;
groesse?: number; groesse?: number;
className?: string; className?: string;
/** Für dekorative Verwendung neben dem Schriftzug. */ /** Für dekorative Verwendung neben dem Schriftzug leer lassen. */
titel?: string; titel?: string;
} }
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = { const FESTE_FARBEN: Record<Exclude<LogoVariante, "standard">, { p: string; linien: string }> = {
standard: { p: "#1E2A44", linien: "#17A2B8" },
sidebar: { p: "#FFFFFF", linien: "#17A2B8" }, sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
mono: { p: "#1E2A44", linien: "#1E2A44" }, mono: { p: "#1E2A44", linien: "#1E2A44" },
}; };
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) { 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 ( return (
<svg <svg
viewBox="0 0 48 48" viewBox="0 0 48 48"
@@ -36,14 +38,21 @@ export function Logo({ variante = "standard", groesse = 40, className, titel }:
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z" 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> </svg>
); );
} }
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */ /** Logo mit Schriftzug, wie in Sidebar und Anmeldung verwendet. */
export function LogoMitSchriftzug({ export function LogoMitSchriftzug({
variante = "standard", variante = "standard",
groesse = 36, 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 kontext = await kontextLaden(jetzt);
const kandidaten = alleRegelnPruefen(kontext); 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 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 kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k]));
const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint)); const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint));
const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint)); const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint));
for (const kandidat of neu) { // Fälle, die es noch nie gab: in einem Rutsch anlegen.
// Ein früher erledigter Fall taucht wieder auf: denselben Eintrag erneut öffnen. const wirklichNeu = neu.filter((k) => !bekannteNachFingerprint.has(k.fingerprint));
await prisma.alert.upsert({ if (wirklichNeu.length > 0) {
where: { fingerprint: kandidat.fingerprint }, await prisma.alert.createMany({
update: { data: wirklichNeu.map((kandidat) => ({
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
},
create: {
fingerprint: kandidat.fingerprint, fingerprint: kandidat.fingerprint,
regel: kandidat.regel, regel: kandidat.regel,
schwere: kandidat.schwere, schwere: kandidat.schwere,
@@ -111,6 +112,22 @@ export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<Abglei
kundeId: kandidat.kundeId, kundeId: kandidat.kundeId,
laufzeitId: kandidat.laufzeitId, laufzeitId: kandidat.laufzeitId,
geoeffnetAm: jetzt, 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({ const laufzeiten = await prisma.laufzeit.findMany({
where: { where: {
status: "BESTAETIGT", status: "BESTAETIGT",
@@ -116,5 +120,5 @@ export async function offeneAufgaben() {
} }
} }
return aufgaben; return { aufgaben: aufgaben.slice(0, grenze), gesamt: aufgaben.length };
} }