Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f838a44991 | ||
|
|
4c65c9ddab | ||
|
|
9647c19136 |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 278 KiB |
@@ -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.
|
||||||
|
|||||||
@@ -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** |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||