Compare commits

..
8 Commits
Author SHA1 Message Date
florian.wilde 02845ed717 Merge branch 'phase/041-doppelte-hosts'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:57:18 +02:00
florian.wildeandClaude Opus 5 3657b60d16 feat(hosts): Doppelte Hosts erkennen und stilllegen (#041)
Zwei Eintraege desselben Namens beim selben Kunden entstehen, wenn sich die
Kennung aus /etc/machine-id aendert - Neuinstallation, geklonter Container,
zurueckgespielte Sicherung. Dieselbe Maschine meldet sich dann als neuer Host,
der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen
sind aber genauso moeglich.

Neue dreizehnte Warnregel meldet den Fall und nennt die IP-Adressen zum
Unterscheiden; gleiche Namen bei verschiedenen Kunden bleiben still.

Dazu die bis dahin fehlende Moeglichkeit, einen Host loszuwerden: Stilllegen
blendet ihn aus Listen, Zahlen, Suche und Warnungen aus und schliesst seine
offenen Warnungen, laesst Laufzeiten und Rechnungen aber unberuehrt.
Endgueltig loeschen geht nur ohne Laufzeiten - Buchhaltung wirft man nicht weg.

Kein automatisches Zusammenfuehren: Es muesste raten, welcher Eintrag welche
Rechnung erbt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:57:18 +02:00
florian.wilde f86a328b3c Merge branch 'phase/040-tags-sichtbar'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:43:55 +02:00
florian.wildeandClaude Opus 5 b2664de62f feat(hosts): Schlagworte und Notizen sichtbar machen (#040)
Gleichnamige Hosts in verschiedenen Kundennetzen liessen sich in der Liste
nicht auseinanderhalten. Schlagworte und Notizen gab es zwar schon, aber nur
im Reiter "Notizen und Tags" - und damit nutzlos zum Unterscheiden.

Jetzt stehen die Schlagworte als anklickbare Abzeichen in der Liste und oben
im Host-Detail, es gibt einen Filter dafuer, eine Notiz zeigt ein
Zettel-Symbol mit dem Text beim Ueberfahren, und beides wird mitdurchsucht -
auch von der globalen Suche.

Bewusst kein zweites Feld "Kommentar": Das haette dieselbe Angabe an zwei
Orten gehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:43:55 +02:00
florian.wilde dfb2118bac Merge branch 'phase/039-laufzeit-zugang'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:08:54 +02:00
florian.wildeandClaude Opus 5 335fc47496 fix(ui): Zugang zur Laufzeit auf dem Telefon (#039)
Im Reiter Rechnungen der Host-Detailansicht war die Laufzeit nie verlinkt,
und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar. Auf dem
Telefon fuehrte damit kein Weg zu den Rechnungsformularen.

Die Laufzeit ist jetzt ein Link und zugleich Ueberschrift der Karte; in der
Liste bleibt sie auf dem Telefon sichtbar und entfaellt nur zwischen 768 und
992 px. Dafuer braucht es eine eigene Klasse: Bootstraps d-*-Hilfsklassen
setzen display mit !important und ueberschreiben das Kartenlayout.

Nebenbefund: Auch dieser Reiter rechnete den Einkauf ohne Rabatt - dritte
Stelle nach #037 und #038.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:08:54 +02:00
florian.wilde e570d30019 Merge branch 'phase/038-klapplisten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:01:43 +02:00
florian.wildeandClaude Opus 5 c5293f5915 fix(ui): Ueberbreite auf dem Handy beseitigen (#038)
Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser
die ganze Seite heraus. Ursache war jeweils ein Element breiter als das
Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive
und eine 32-stellige Server-ID ohne Umbruch.

Klapplisten richten sich auf dem Telefon jetzt am Fenster aus statt am Knopf,
die Tabellen bekommen ihre Huelle, lange IDs duerfen umbrechen. Geprueft mit
einer neuen Aufnahme in Telefonbreite (--mobil).

Dabei ein eigener Fehler aus #037 aufgefallen: Die Laufzeit-Detailseite
rechnete weiter mit dem Listenpreis statt mit dem Betrag nach Rabatt - Marge
und Vorschlag fuer die Kundenrechnung waren um den Rabatt zu hoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:01:43 +02:00
45 changed files with 945 additions and 136 deletions
+34
View File
@@ -17,6 +17,40 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.24.0] – 2026-10-03
### Hinzugefügt
- Dreizehnte Warnregel: Mehrere Hosts desselben Kunden tragen denselben Namen – mit IP-Adressen zum Unterscheiden (#041)
- Hosts lassen sich **stilllegen**: Sie verschwinden aus Listen, Zahlen, Suche und Warnungen, ihre Laufzeiten und Rechnungen bleiben erhalten (#041)
- Endgültiges Löschen für Hosts, an denen keine Laufzeit hängt (#041)
- Schalter „stillgelegte Hosts zeigen“ in der Host-Liste (#041)
## [0.23.0] – 2026-10-03
### Hinzugefügt
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
## [0.22.2] – 2026-10-03
### Behoben
- **Auf dem Telefon führte kein Weg zu den Rechnungsformularen**: Im Reiter *Rechnungen* der Host-Detailansicht war die Laufzeit nicht verlinkt, und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar (#039)
- Der Reiter *Rechnungen* rechnete den Einkauf noch ohne Rabatt (#039)
### Geändert
- Der Reiter *Rechnungen* nutzt auf dem Telefon das Kartenlayout (#039)
## [0.22.1] – 2026-10-03
### Behoben
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
### Hinzugefügt
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
## [0.22.0] – 2026-10-03 ## [0.22.0] – 2026-10-03
### Geändert ### Geändert
+20 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 19 h 53 min **Gesamt:** 22 h 33 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -43,6 +43,10 @@
| #035 Tabellen als Karten | 0 h 50 min | | #035 Tabellen als Karten | 0 h 50 min |
| #036 Standort am Host | 0 h 30 min | | #036 Standort am Host | 0 h 30 min |
| #037 Rabatt je Position | 0 h 55 min | | #037 Rabatt je Position | 0 h 55 min |
| #038 Überbreite auf dem Handy | 0 h 40 min |
| #039 Zugang zur Laufzeit | 0 h 30 min |
| #040 Schlagworte sichtbar | 0 h 40 min |
| #041 Doppelte Hosts | 0 h 50 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -65,7 +69,7 @@
## 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.22.0 **Version:** 0.24.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -194,6 +198,20 @@
- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel - Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel
- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen - Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
- #039: Auf dem Telefon führte kein Weg zu den Rechnungsformularen – die Laufzeit war nicht verlinkt und die Spalte erst ab 992 px sichtbar
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden
- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+28
View File
@@ -192,6 +192,34 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst. deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
### Gleichnamige Hosts auseinanderhalten
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
Host-Detail steht die Notiz oben bei den Stammdaten.
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
### Wenn ein Host doppelt erscheint
Steht derselbe Name zweimal beim selben Kunden, meldet ProxLedger das als Hinweis und nennt die
IP-Adressen beider Einträge. Zwei Fälle sind möglich:
- **Zwei verschiedene Maschinen** heißen gleich. Dann helfen Schlagworte beim Auseinanderhalten.
- **Dieselbe Maschine hat sich neu registriert** – etwa nach einer Neuinstallation oder weil ein
Container geklont wurde. Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am
Namen; ändert sie sich, entsteht ein zweiter Eintrag.
Den alten Eintrag legst du im Host-Detail still. Er verschwindet dann aus Listen, Zahlen und
Warnungen, seine Laufzeiten und Rechnungen bleiben aber erhalten – mit dem Schalter *stillgelegte
Hosts zeigen* findest du ihn wieder. **Endgültig löschen** geht nur, solange keine Laufzeit daran
hängt.
--- ---
## 5. Kunden und Standorte pflegen ## 5. Kunden und Standorte pflegen
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 194 KiB

+31
View File
@@ -0,0 +1,31 @@
# #038 Seite wird auf dem Handy herausgezoomt
**Status:** erledigt
**Meilenstein:** v0.22.1
**Labels:** bug, ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
Host-Detailansicht saß verschoben im Bild.
## Ursache
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
`table-responsive` – anders als die übrigen.
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
## Behebung
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
- Die drei Tabellen bekommen ihre Hülle
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
wurde die Host-Detailansicht gegengeprüft
## Nachtrag zu #037
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
+32
View File
@@ -0,0 +1,32 @@
# #039 Auf dem Telefon führte kein Weg zur Laufzeit
**Status:** erledigt
**Meilenstein:** v0.22.2
**Labels:** bug, ui
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
## Auslöser
„Kann ich am Handy nicht rein.“ Gemeint war der Reiter *Rechnungen* in der Host-Detailansicht: Die
Zeile zeigt Laufzeit, Rechnung und Beträge – aber nichts davon war anklickbar.
## Ursache
Zwei Stellen:
1. In der Host-Detailansicht war die Laufzeit nie verlinkt, auch am Rechner nicht. Man kam nur über
die Rechnungsliste dorthin.
2. In der Rechnungsliste ist die Laufzeit-Spalte erst ab 992 px eingeblendet. Auf dem Telefon fehlte
damit der einzige Verweis auf die Formulare – die Karte verwies nur auf den Host.
## Behebung
- Die Laufzeit in der Host-Detailansicht ist ein Link und zugleich die Überschrift der Karte
- In der Rechnungsliste bleibt die Laufzeit auf dem Telefon sichtbar; ausgeblendet wird sie nur
zwischen 768 und 992 px, wo die Tabelle am engsten ist
- Der Reiter *Rechnungen* nutzt ebenfalls das Kartenlayout
## Nebenbefund
Auch dieser Reiter rechnete den Einkauf noch ohne Rabatt – 1.100 € statt 990 €. Dritte Stelle nach
#037 und #038; alle gefunden, weil Florian die Zahl auf dem Bildschirm mit seiner Rechnung verglichen hat.
## Gelernt
Bootstraps `d-*`-Hilfsklassen setzen `display` mit `!important` und überschreiben damit das
Kartenlayout. Für Spalten, die in der Kartenansicht sichtbar bleiben sollen, braucht es eine eigene
Klasse (`pl-spalte-laufzeit`).
+26
View File
@@ -0,0 +1,26 @@
# #040 Schlagworte und Notizen sichtbar machen
**Status:** erledigt
**Meilenstein:** v0.23.0
**Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
## Ausgangslage
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
## Behebung
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
Notiztext; die Trefferzeile nennt die Schlagworte.
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
## Entscheidung
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
+35
View File
@@ -0,0 +1,35 @@
# #041 Gleichnamige Hosts erkennen und stilllegen
**Status:** erledigt
**Meilenstein:** v0.24.0
**Labels:** backend, ui, alerts
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
## Auslöser
Florian markierte in der Host-Liste zwei Paare: `hv-pve-01` und `hv-pve-02` standen jeweils **zweimal
beim selben Kunden** – einmal „Aktiv“ mit einer IP, einmal „Keine Subscription“ mit drei anderen.
## Was dahintersteckt
Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am Namen. Ändert sich diese Kennung
– Neuinstallation, geklonter Container, zurückgespielte Sicherung –, meldet sich dieselbe Maschine als
**neuer** Host, und der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind
aber genauso möglich. Welcher Fall vorliegt, weiß nur der Betreiber.
## Was fehlte
1. Kein Hinweis auf den Doppeleintrag.
2. **Keine Möglichkeit, einen Host loszuwerden.** Ein Eintrag, der nicht mehr existiert, blieb für
immer in Listen, Zahlen und Warnungen stehen.
## Behebung
- **Dreizehnte Warnregel `hostname-doppelt`:** meldet mehrere Hosts desselben Kunden mit gleichem
Namen und nennt die IP-Adressen zum Unterscheiden. Gleiche Namen bei **verschiedenen** Kunden sind
der Normalfall und bleiben still.
- **Stilllegen** (`archiviertAm`): Der Host verschwindet aus Listen, Zahlen, Suche und Warnungen;
seine offenen Warnungen werden geschlossen. Laufzeiten und Rechnungen bleiben erhalten.
- **Wieder aufnehmen** mit demselben Knopf.
- **Endgültig löschen** nur, solange keine Laufzeit am Host hängt – Buchhaltung wirft man nicht weg.
- In der Liste ein Schalter „stillgelegte Hosts zeigen“ und ein Abzeichen an der Zeile.
## Entscheidung
Stilllegen statt automatisch zusammenführen. Ein Zusammenführen müsste raten, welcher Eintrag welche
Rechnung erbt – im Zweifel zulasten der Buchhaltung. Die Warnung nennt beide Hosts mit IPs; die
Entscheidung trifft der Mensch.
+5 -1
View File
@@ -41,4 +41,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min | | [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min | | [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min | | [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
| | **Summe** | | | **160 h** | **19 h 53 min** | | [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min |
| | **Summe** | | | **168 h** | **22 h 33 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.22.0", "version": "0.24.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,5 @@
-- Hosts stilllegen, statt sie zu löschen (#041).
-- Ein stillgelegter Host verschwindet aus Listen, Zahlen und Warnungen; seine Laufzeiten und
-- Rechnungen bleiben erhalten, weil sie zur Buchhaltung gehören.
ALTER TABLE "Host" ADD COLUMN "archiviertAm" TIMESTAMP(3);
CREATE INDEX "Host_archiviertAm_idx" ON "Host"("archiviertAm");
+4
View File
@@ -134,6 +134,10 @@ model Host {
subNextduedate DateTime? @db.Date subNextduedate DateTime? @db.Date
subChecktime DateTime? subChecktime DateTime?
/// Stillgelegt: Der Host erscheint nicht mehr in Listen, Zahlen und Warnungen – seine
/// Geschichte samt Laufzeiten und Rechnungen bleibt aber erhalten (#041).
archiviertAm DateTime?
erstmalsGesehenAm DateTime @default(now()) erstmalsGesehenAm DateTime @default(now())
letzterReportAm DateTime? letzterReportAm DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
+13
View File
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle"); const ALLE = process.argv.includes("--alle");
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
const MOBIL_ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--mobil")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "Handy" };
});
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */ /** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite") .filter((wert, i) => process.argv[i - 1] === "--seite")
@@ -187,6 +195,11 @@ async function main() {
console.log(` ${seite.name}.png – ${seite.titel}`); console.log(` ${seite.name}.png – ${seite.titel}`);
} }
for (const seite of MOBIL_ZUSATZ) {
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
if (ALLE) { if (ALLE) {
const dunkel = profilVorbereiten("dunkel", true); const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
+170 -104
View File
@@ -17,6 +17,7 @@ import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular"; import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts";
import { hostTokenWiderrufen } from "@/server/actions/tokens"; import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste"; import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts"; import { meldungsGrenze } from "@/server/queries/hosts";
@@ -108,6 +109,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2> <h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div> <div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null} {meldetNicht ? <KeineMeldungBadge /> : null}
{host.archiviertAm ? (
<div className="mt-2">
<span className="badge pl-bg-neutral">Stillgelegt</span>
</div>
) : null}
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
Hosts ist das die Stelle, an der man sie braucht (#040). */}
{host.tags.length > 0 ? (
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
{host.notizen ? (
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
{host.notizen}
</p>
) : null}
</div> </div>
<Zeile bezeichnung="Kunde"> <Zeile bezeichnung="Kunde">
@@ -177,6 +204,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
kundeId={host.kundeId} kundeId={host.kundeId}
standortId={host.standortId} standortId={host.standortId}
/> />
{/* Stilllegen statt löschen: Laufzeiten und Rechnungen gehören zur Buchhaltung (#041). */}
<div className="mt-3 pt-3 border-top d-flex flex-wrap gap-2">
<form action={hostArchivieren}>
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
</button>
</form>
{host.laufzeiten.length === 0 ? (
<form action={hostLoeschen}>
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Endgültig löschen
</button>
</form>
) : null}
</div>
<div className="form-text">
{host.archiviertAm
? `Stillgelegt am ${formatDatum(host.archiviertAm)} – erscheint nicht in Listen, Zahlen und Warnungen.`
: host.laufzeiten.length === 0
? "Stilllegen blendet den Host aus; löschen geht nur, solange keine Laufzeit daran hängt."
: "An diesem Host hängen Laufzeiten – er lässt sich nur stilllegen, nicht löschen."}
</div>
</Card> </Card>
</div> </div>
@@ -225,62 +278,66 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
inhalt: ( inhalt: (
<> <>
<h3 className="h6">Keys</h3> <h3 className="h6">Keys</h3>
<table className="table table-striped align-middle mb-4"> <div className="table-responsive mb-4">
<thead> <table className="table table-striped align-middle mb-0">
<tr> <thead>
<th scope="col">Key</th>
<th scope="col">Erstmals gemeldet</th>
<th scope="col">Zuletzt gemeldet</th>
</tr>
</thead>
<tbody>
{host.keyHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.key} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.keyHistorie.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="text-body-secondary"> <th scope="col">Key</th>
Noch kein Key gemeldet. <th scope="col">Erstmals gemeldet</th>
</td> <th scope="col">Zuletzt gemeldet</th>
</tr> </tr>
) : null} </thead>
</tbody> <tbody>
</table> {host.keyHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.key} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.keyHistorie.length === 0 ? (
<tr>
<td colSpan={3} className="text-body-secondary">
Noch kein Key gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<h3 className="h6">Server-IDs</h3> <h3 className="h6">Server-IDs</h3>
<table className="table table-striped align-middle mb-0"> <div className="table-responsive">
<thead> <table className="table table-striped align-middle mb-0">
<tr> <thead>
<th scope="col">Server-ID</th>
<th scope="col">Erstmals gemeldet</th>
<th scope="col">Zuletzt gemeldet</th>
</tr>
</thead>
<tbody>
{host.serverIdHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.serverId} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.serverIdHistorie.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="text-body-secondary"> <th scope="col">Server-ID</th>
Noch keine Server-ID gemeldet. <th scope="col">Erstmals gemeldet</th>
</td> <th scope="col">Zuletzt gemeldet</th>
</tr> </tr>
) : null} </thead>
</tbody> <tbody>
</table> {host.serverIdHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.serverId} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.serverIdHistorie.length === 0 ? (
<tr>
<td colSpan={3} className="text-body-secondary">
Noch keine Server-ID gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{host.serverIdHistorie.length > 1 ? ( {host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0"> <p className="small pl-text-warning mt-2 mb-0">
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
@@ -342,7 +399,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
icon: "bi-receipt", icon: "bi-receipt",
inhalt: ( inhalt: (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0 pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Laufzeit</th> <th scope="col">Laufzeit</th>
@@ -355,21 +412,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
</thead> </thead>
<tbody> <tbody>
{host.laufzeiten.map((laufzeit) => { {host.laufzeiten.map((laufzeit) => {
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkauf = laufzeit.proxmoxPosition const einkauf = laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag) ? Number(laufzeit.proxmoxPosition.nettoBetrag) -
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null; : null;
const verkauf = laufzeit.kundenPosition const verkauf = laufzeit.kundenPosition
? Number(laufzeit.kundenPosition.nettoBetrag) ? Number(laufzeit.kundenPosition.nettoBetrag)
: null; : null;
return ( return (
<tr key={laufzeit.id}> <tr key={laufzeit.id}>
<td> <td className="pl-karten-kopf">
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} <Link
href={`/rechnungen/${laufzeit.id}`}
className="fw-semibold text-decoration-none"
>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</Link>
{laufzeit.status === "VORSCHLAG" ? ( {laufzeit.status === "VORSCHLAG" ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span> <span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null} ) : null}
</td> </td>
<td> <td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} /> <CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
<div> <div>
<EinkaufBadge <EinkaufBadge
@@ -381,8 +445,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/> />
</div> </div>
</td> </td>
<td>{formatEuro(einkauf)}</td> <td data-label="Einkauf">{formatEuro(einkauf)}</td>
<td> <td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} /> <CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
<div> <div>
<VerkaufBadge <VerkaufBadge
@@ -391,8 +455,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/> />
</div> </div>
</td> </td>
<td>{formatEuro(verkauf)}</td> <td data-label="Verkauf">{formatEuro(verkauf)}</td>
<td> <td data-label="Marge">
{einkauf !== null && verkauf !== null {einkauf !== null && verkauf !== null
? formatEuro(verkauf - einkauf) ? formatEuro(verkauf - einkauf)
: "–"} : "–"}
@@ -429,53 +493,55 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
titel: "Tokens", titel: "Tokens",
icon: "bi-key-fill", icon: "bi-key-fill",
inhalt: ( inhalt: (
<table className="table table-striped align-middle mb-0"> <div className="table-responsive">
<thead> <table className="table table-striped align-middle mb-0">
<tr> <thead>
<th scope="col">Anfang</th>
<th scope="col">Erstellt</th>
<th scope="col">Zuletzt benutzt</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{host.tokens.map((token) => (
<tr key={token.id}>
<td className="pl-mono">{token.praefix}…</td>
<td>{formatDatum(token.erstelltAm)}</td>
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
<td>
{token.widerrufenAm ? (
<Badge farbe="neutral">Widerrufen</Badge>
) : (
<Badge farbe="success">Aktiv</Badge>
)}
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={hostTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
))}
{host.tokens.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="text-body-secondary"> <th scope="col">Anfang</th>
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten. <th scope="col">Erstellt</th>
</td> <th scope="col">Zuletzt benutzt</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr> </tr>
) : null} </thead>
</tbody> <tbody>
</table> {host.tokens.map((token) => (
<tr key={token.id}>
<td className="pl-mono">{token.praefix}…</td>
<td>{formatDatum(token.erstelltAm)}</td>
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
<td>
{token.widerrufenAm ? (
<Badge farbe="neutral">Widerrufen</Badge>
) : (
<Badge farbe="success">Aktiv</Badge>
)}
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={hostTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
))}
{host.tokens.length === 0 ? (
<tr>
<td colSpan={5} className="text-body-secondary">
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
), ),
}, },
]} ]}
+29 -1
View File
@@ -68,6 +68,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const filter: HostFilter = { const filter: HostFilter = {
q: einzeln(roh.q), q: einzeln(roh.q),
kunde: einzeln(roh.kunde), kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag),
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
status: einzeln(roh.status), status: einzeln(roh.status),
produkt: einzeln(roh.produkt), produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"], ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
@@ -85,6 +87,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const basis: Record<string, string | undefined> = { const basis: Record<string, string | undefined> = {
q: filter.q, q: filter.q,
kunde: filter.kunde, kunde: filter.kunde,
tag: filter.tag,
archiviert: filter.archiviert,
status: filter.status, status: filter.status,
produkt: filter.produkt, produkt: filter.produkt,
ablauf: filter.ablauf, ablauf: filter.ablauf,
@@ -117,7 +121,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
</> </>
} }
> >
<HostFilterLeiste kunden={optionen.kunden} werte={basis} /> <HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
@@ -152,7 +156,31 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {host.meldetNicht ? <KeineMeldungBadge /> : null}
{host.archiviertAm ? (
<span className="badge pl-bg-neutral ms-2">Stillgelegt</span>
) : null}
{host.notizen ? (
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
<i
className="bi bi-sticky ms-2 text-body-secondary"
title={host.notizen}
aria-label={`Notiz: ${host.notizen}`}
/>
) : null}
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div> <div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
{host.tags.length > 0 ? (
<div className="mt-1 d-flex flex-wrap gap-1">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
</td> </td>
<td> <td>
{host.kunde ? ( {host.kunde ? (
+13 -3
View File
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null; const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.rechnung ?? null; const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null; // Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<hr /> <hr />
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt> <dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd> <dd className="col-7">
{formatEuro(einkaufBetrag)}
{einkaufRabatt > 0 ? (
<span className="small text-body-secondary d-block">
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
</span>
) : null}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt> <dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd> <dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt> <dt className="col-5 fw-normal text-body-secondary">Marge</dt>
@@ -164,7 +174,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
nummer: einkauf?.nummer ?? null, nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null, datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag, faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag, nettoBetrag: einkaufListenpreis,
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz), ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt: rabatt:
laufzeit.proxmoxPosition?.rabatt === null || laufzeit.proxmoxPosition?.rabatt === null ||
+2 -2
View File
@@ -204,7 +204,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<th scope="col" className="d-none d-md-table-cell"> <th scope="col" className="d-none d-md-table-cell">
Host Host
</th> </th>
<th scope="col" className="d-none d-lg-table-cell"> <th scope="col" className="pl-spalte-laufzeit">
Laufzeit Laufzeit
</th> </th>
<th scope="col">Proxmox-Rechnung</th> <th scope="col">Proxmox-Rechnung</th>
@@ -254,7 +254,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
{zeile.hostname} {zeile.hostname}
</Link> </Link>
</td> </td>
<td className="d-none d-lg-table-cell" data-label="Laufzeit"> <td className="pl-spalte-laufzeit" data-label="Laufzeit">
<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>
+42
View File
@@ -395,6 +395,24 @@ body {
width: 13rem; width: 13rem;
} }
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
.pl-klappliste {
width: 22rem;
max-width: calc(100vw - 1rem);
}
/*
* Die Laufzeit-Spalte ist auf dem Telefon der einzige Weg zu den Rechnungsformularen, deshalb
* bleibt sie dort sichtbar. Zwischen 768 und 992 px ist die Tabelle am engsten – dort entfällt sie.
* Bewusst eine eigene Klasse statt der Bootstrap-Hilfsklassen: deren `display: … !important`
* überschreibt das Kartenlayout (#039).
*/
@media (min-width: 768px) and (max-width: 991.98px) {
.pl-spalte-laufzeit {
display: none;
}
}
/* ---------- Mobil ---------- */ /* ---------- Mobil ---------- */
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
@@ -425,6 +443,30 @@ body {
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen. * Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
*/ */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
/*
* Auf dem Telefon ist die Klappliste breiter als das Fenster – der Browser zoomt dann die
* **ganze Seite** heraus, damit sie hineinpasst. Deshalb hier fest am Fenster ausrichten
* statt am Knopf (#038).
*/
.pl-klappliste {
position: fixed;
top: 3.5rem;
right: 0.5rem;
left: 0.5rem;
width: auto;
max-width: none;
max-height: 70vh;
overflow-y: auto;
}
/*
* Lange Schlüssel und Server-IDs dürfen umbrechen. Sonst machen sie die Seite breiter als das
* Fenster – und der Browser zoomt die ganze Seite heraus, damit sie hineinpasst (#038).
*/
.pl-mono {
overflow-wrap: anywhere;
}
.pl-karten-tabelle, .pl-karten-tabelle,
.pl-karten-tabelle tbody, .pl-karten-tabelle tbody,
.pl-karten-tabelle tr, .pl-karten-tabelle tr,
+38 -1
View File
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
*/ */
export function HostFilterLeiste({ export function HostFilterLeiste({
kunden, kunden,
tags,
werte, werte,
}: { }: {
kunden: { id: string; name: string }[]; kunden: { id: string; name: string }[];
tags: string[];
werte: Record<string, string | undefined>; werte: Record<string, string | undefined>;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
router.push(`${pathname}?${neu.toString()}`); router.push(`${pathname}?${neu.toString()}`);
} }
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter( const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "archiviert", "q"].filter(
(schluessel) => werte[schluessel], (schluessel) => werte[schluessel],
).length; ).length;
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
</select> </select>
</div> </div>
{tags.length > 0 ? (
<div className="col-6 col-md-2">
<label className="visually-hidden" htmlFor="tag">
Schlagwort
</label>
<select
className="form-select"
id="tag"
value={werte.tag ?? ""}
onChange={(e) => setzen("tag", e.target.value)}
>
<option value="">Alle Schlagworte</option>
{tags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
) : null}
<div className="col-6 col-md-2"> <div className="col-6 col-md-2">
<select <select
className="form-select" className="form-select"
@@ -135,6 +158,20 @@ export function HostFilterLeiste({
nur mit offenen Rechnungspunkten nur mit offenen Rechnungspunkten
</label> </label>
</div> </div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="archiviert"
checked={werte.archiviert === "ja"}
onChange={(e) => setzen("archiviert", e.target.checked ? "ja" : "")}
/>
<label className="form-check-label small" htmlFor="archiviert">
stillgelegte Hosts zeigen
</label>
</div>
{aktiveFilter > 0 ? ( {aktiveFilter > 0 ? (
<Link href="/hosts" className="small"> <Link href="/hosts" className="small">
Filter zurücksetzen ({aktiveFilter}) Filter zurücksetzen ({aktiveFilter})
+4 -3
View File
@@ -140,8 +140,7 @@ export function Header({
) : null} ) : null}
</button> </button>
<div <div
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`} className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
style={{ width: "22rem" }}
> >
<span className="dropdown-header">{alerts.length} offene Warnungen</span> <span className="dropdown-header">{alerts.length} offene Warnungen</span>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
@@ -206,7 +205,9 @@ export function Header({
<i className="bi bi-person-circle" aria-hidden="true" /> <i className="bi bi-person-circle" aria-hidden="true" />
<span className="d-none d-sm-inline">{benutzer}</span> <span className="d-none d-sm-inline">{benutzer}</span>
</button> </button>
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}> <div
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
>
<span className="dropdown-header"> <span className="dropdown-header">
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]} Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
</span> </span>
+8 -2
View File
@@ -55,8 +55,14 @@ export function eigeneAdresse(request: Request, pfad: string, eingetragen?: stri
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt. * Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
*/ */
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> { export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
const { headers } = await import("next/headers"); let kopfzeilen: Headers;
const kopfzeilen = await headers(); try {
const { headers } = await import("next/headers");
kopfzeilen = await headers();
} catch {
// Außerhalb einer Anfrage (Tests, Skripte) gibt es keine Kopfzeilen – dann bleibt der Pfad relativ.
return pfad;
}
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt. // Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen); const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
+60
View File
@@ -1,6 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eigenerPfad } from "@/lib/adresse";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
revalidatePath("/kunden"); revalidatePath("/kunden");
revalidatePath("/"); revalidatePath("/");
} }
/**
* Legt einen Host still: Er verschwindet aus Listen, Zahlen und Warnungen, bleibt aber mit seiner
* Geschichte erhalten (#041). Gedacht für Maschinen, die es nicht mehr gibt – oder für einen
* doppelten Eintrag, der entstanden ist, weil sich die Maschinenkennung geändert hat.
*/
export async function hostArchivieren(formular: FormData) {
await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
if (!hostId) return;
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { archiviertAm: true } });
if (!host) return;
const stilllegen = host.archiviertAm === null;
await prisma.host.update({
where: { id: hostId },
data: { archiviertAm: stilllegen ? new Date() : null },
});
// Offene Warnungen eines stillgelegten Hosts schließen – sonst bleiben sie für immer stehen.
if (stilllegen) {
await prisma.alert.updateMany({
where: { hostId, erledigtAm: null },
data: { erledigtAm: new Date() },
});
}
await protokolliere(stilllegen ? "host.stillgelegt" : "host.reaktiviert", "Host", hostId, {});
revalidatePath(`/hosts/${hostId}`);
revalidatePath("/hosts");
revalidatePath("/");
}
/**
* Löscht einen Host endgültig – nur, solange keine Laufzeit daran hängt. Sobald Rechnungen im
* Spiel sind, bleibt nur das Stilllegen: Buchhaltung wirft man nicht weg.
*/
export async function hostLoeschen(formular: FormData): Promise<void> {
await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
if (!hostId) return;
const laufzeiten = await prisma.laufzeit.count({ where: { hostId } });
if (laufzeiten > 0) return;
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { hostname: true } });
if (!host) return;
await prisma.host.delete({ where: { id: hostId } });
await protokolliere("host.geloescht", "Host", hostId, { hostname: host.hostname });
revalidatePath("/hosts");
revalidatePath("/");
redirect(await eigenerPfad("/hosts"));
}
+4 -1
View File
@@ -9,13 +9,15 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> { export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
const [hosts, laufzeiten, einstellungen] = await Promise.all([ const [hosts, laufzeiten, einstellungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
where: { archiviertAm: null },
include: { include: {
kunde: { select: { name: true } }, kunde: { select: { name: true } },
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } }, serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
}, },
}), }),
prisma.laufzeit.findMany({ prisma.laufzeit.findMany({
where: { status: "BESTAETIGT" }, where: { status: "BESTAETIGT", host: { archiviertAm: null } },
include: { include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } }, host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
proxmoxPosition: { include: { rechnung: true } }, proxmoxPosition: { include: { rechnung: true } },
@@ -40,6 +42,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
subLizenzierteSockets: host.subLizenzierteSockets, subLizenzierteSockets: host.subLizenzierteSockets,
sockets: host.sockets, sockets: host.sockets,
letzterReportAm: host.letzterReportAm, letzterReportAm: host.letzterReportAm,
ips: host.ips,
serverIds: host.serverIdHistorie, serverIds: host.serverIdHistorie,
})), })),
laufzeiten: laufzeiten.map((laufzeit) => ({ laufzeiten: laufzeiten.map((laufzeit) => ({
+47
View File
@@ -32,6 +32,7 @@ export interface HostDaten {
subLizenzierteSockets: number | null; subLizenzierteSockets: number | null;
sockets: number | null; sockets: number | null;
letzterReportAm: Date | null; letzterReportAm: Date | null;
ips: string[];
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[]; serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
} }
@@ -405,6 +406,51 @@ const kundenrechnungUnbezahlt: Regel = {
}, },
}; };
/**
* Zwei Hosts desselben Kunden mit demselben Namen (#041).
*
* Das ist entweder gewollt – zwei Maschinen in getrennten Netzen heißen eben gleich – oder ein
* Doppeleintrag: Hat eine Maschine ihre Kennung aus `/etc/machine-id` verloren (Neuinstallation,
* geklonter Container), registriert sie sich als neuer Host, und der alte Eintrag bleibt stehen.
* Welcher Fall vorliegt, weiß nur der Betreiber – deshalb ein Hinweis mit den IP-Adressen zum
* Unterscheiden und nicht mehr.
*/
const hostnameDoppelt: Regel = {
name: "hostname-doppelt",
beschreibung: "Mehrere Hosts desselben Kunden tragen denselben Namen",
standardSchwellwerte: {},
pruefe(kontext) {
const gruppen = new Map<string, HostDaten[]>();
for (const host of kontext.hosts) {
// Ohne Kunde ist der Vergleich wenig wert: Gleiche Namen sind dort der Normalfall.
if (!host.kundeId) continue;
const schluessel = `${host.kundeId}:${host.hostname.toLowerCase()}`;
gruppen.set(schluessel, [...(gruppen.get(schluessel) ?? []), host]);
}
return [...gruppen.values()]
.filter((gruppe) => gruppe.length > 1)
.map((gruppe) => {
const beschreibungen = gruppe.map(
(host) => `${host.ips.join(", ") || "keine IP gemeldet"}`,
);
return {
// Der Fingerabdruck hängt an Kunde und Name, nicht an den einzelnen Hosts – sonst gäbe
// es für jede Maschine eine eigene Warnung über denselben Sachverhalt.
fingerprint: `hostname-doppelt:${gruppe[0].kundeId}:${gruppe[0].hostname.toLowerCase()}`,
regel: "hostname-doppelt",
schwere: "INFO" as AlertSchwere,
titel: `${gruppe.length} Hosts heißen „${gruppe[0].hostname}“`,
beschreibung: `${kundenHinweis(gruppe[0].kundeName)} · ${beschreibungen.join(" | ")} · Entweder getrennte Maschinen – dann helfen Schlagworte – oder ein Doppeleintrag nach Neuinstallation; den alten Host dann stilllegen`,
hostId: gruppe[0].id,
kundeId: gruppe[0].kundeId,
laufzeitId: null,
};
});
},
};
/** /**
* Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden * Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde * seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
@@ -450,6 +496,7 @@ export const REGELN: Regel[] = [
kundenrechnungFehlt, kundenrechnungFehlt,
kundenrechnungUnbezahlt, kundenrechnungUnbezahlt,
kundeBezahltEinkaufOffen, kundeBezahltEinkaufOffen,
hostnameDoppelt,
]; ];
/** Wendet alle aktiven Regeln an. */ /** Wendet alle aktiven Regeln an. */
+1 -1
View File
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
const einkaufSichtbar = await darfEinkaufSehen(); const einkaufSichtbar = await darfEinkaufSehen();
const hosts = await prisma.host.findMany({ const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) }, where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: { subNextduedate: "asc" }, orderBy: { subNextduedate: "asc" },
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
+22 -1
View File
@@ -6,6 +6,10 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter { export interface HostFilter {
q?: string; q?: string;
kunde?: string; kunde?: string;
/** Schlagwort, nach dem gefiltert wird (#040). */
tag?: string;
/** Stillgelegte Hosts mitzeigen (#041). */
archiviert?: "ja";
status?: string; status?: string;
produkt?: string; produkt?: string;
ablauf?: "30" | "14" | "abgelaufen"; ablauf?: "30" | "14" | "abgelaufen";
@@ -20,6 +24,13 @@ export const SEITENGROESSE = 25;
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput { function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
const und: Prisma.HostWhereInput[] = []; const und: Prisma.HostWhereInput[] = [];
// Stillgelegte Hosts erscheinen nur, wenn ausdrücklich danach gefragt wird.
if (filter.archiviert !== "ja") und.push({ archiviertAm: null });
if (filter.tag) {
und.push({ tags: { some: { name: filter.tag } } });
}
if (filter.q && filter.q.trim().length > 0) { if (filter.q && filter.q.trim().length > 0) {
const suche = filter.q.trim(); const suche = filter.q.trim();
und.push({ und.push({
@@ -30,6 +41,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
{ subKey: { contains: suche, mode: "insensitive" } }, { subKey: { contains: suche, mode: "insensitive" } },
{ ips: { has: suche } }, { ips: { has: suche } },
{ kunde: { name: { contains: suche, mode: "insensitive" } } }, { kunde: { name: { contains: suche, mode: "insensitive" } } },
{ notizen: { contains: suche, mode: "insensitive" } },
{ tags: { some: { name: { contains: suche, mode: "insensitive" } } } },
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host. // Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
{ {
laufzeiten: { laufzeiten: {
@@ -113,6 +126,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
standort: { select: { name: true } }, standort: { select: { name: true } },
tags: { orderBy: { name: "asc" }, select: { id: true, name: true } },
}, },
}), }),
]); ]);
@@ -181,5 +195,12 @@ export async function filterOptionen() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true }, select: { id: true, name: true },
}); });
return { kunden };
// Nur Schlagworte, die auch an einem sichtbaren Host hängen – sonst stünden leere zur Auswahl.
const tags = await prisma.tag.findMany({
where: { hosts: { some: kundeId ? { kundeId } : {} } },
orderBy: { name: "asc" },
select: { name: true },
});
return { kunden, tags: tags.map((tag) => tag.name) };
} }
+26 -8
View File
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000); return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
} }
/** Stillgelegte Hosts zählen nirgends mit (#041). */
const NICHT_ARCHIVIERT = { archiviertAm: null } as const;
export async function hostsOhneKunde() { export async function hostsOhneKunde() {
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition // Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
// noch niemandem, dürfen also auch niemandem gezeigt werden. // noch niemandem, dürfen also auch niemandem gezeigt werden.
if (await sichtbarerKunde()) return []; if (await sichtbarerKunde()) return [];
return prisma.host.findMany({ return prisma.host.findMany({
where: { kundeId: null }, where: { kundeId: null, ...NICHT_ARCHIVIERT },
orderBy: { erstmalsGesehenAm: "desc" }, orderBy: { erstmalsGesehenAm: "desc" },
}); });
} }
export async function hostAnzahl() { export async function hostAnzahl() {
const kundeId = await sichtbarerKunde(); const kundeId = await sichtbarerKunde();
return prisma.host.count({ where: kundeId ? { kundeId } : {} }); return prisma.host.count({ where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } });
} }
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */ /** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
export async function kennzahlen(jetzt: Date = new Date()) { export async function kennzahlen(jetzt: Date = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000); const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const kundeId = await sichtbarerKunde(); const kundeId = await sichtbarerKunde();
const hostSicht = kundeId ? { kundeId } : {}; const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
const laufzeitSicht = kundeId ? { host: { kundeId } } : {}; const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([ const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
prisma.host.count({ where: hostSicht }), prisma.host.count({ where: hostSicht }),
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }), prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }), kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null, ...NICHT_ARCHIVIERT } }),
prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }), prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
prisma.host.count({ prisma.host.count({
where: { where: {
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }], OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
}, },
}), }),
prisma.alert.count({ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) } }), prisma.alert.count({
where: {
erledigtAm: null,
OR: [{ hostId: null }, { host: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } }],
},
}),
]); ]);
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([ const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
@@ -94,7 +102,7 @@ export async function suchIndex() {
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([ const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
where: kundeId ? { kundeId } : {}, where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
select: { select: {
id: true, id: true,
hostname: true, hostname: true,
@@ -103,7 +111,9 @@ export async function suchIndex() {
subKey: true, subKey: true,
ips: true, ips: true,
produkt: true, produkt: true,
notizen: true,
kunde: { select: { name: true } }, kunde: { select: { name: true } },
tags: { select: { name: true } },
}, },
}), }),
prisma.kunde.findMany({ prisma.kunde.findMany({
@@ -128,9 +138,17 @@ export async function suchIndex() {
titel: host.hostname, titel: host.hostname,
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${ untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
host.subKey ? ` · ${host.subKey}` : "" host.subKey ? ` · ${host.subKey}` : ""
}`, }${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
href: `/hosts/${host.id}`, href: `/hosts/${host.id}`,
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips] begriffe: [
host.hostname,
host.fqdn,
host.serverId,
host.subKey,
host.notizen,
...host.tags.map((tag) => tag.name),
...host.ips,
]
.filter((w): w is string => Boolean(w)) .filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()), .map((w) => w.toLowerCase()),
})), })),
+1 -1
View File
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
const nurKunde = await sichtbarerKunde(); const nurKunde = await sichtbarerKunde();
const hosts = await prisma.host.findMany({ const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) }, where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: [{ subNextduedate: "asc" }], orderBy: [{ subNextduedate: "asc" }],
select: { select: {
id: true, id: true,
+1 -1
View File
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
it("legt die Standard-Einstellungen für alle Regeln an", async () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany(); const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(12); expect(einstellungen).toHaveLength(13);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
}); });
+97
View File
@@ -0,0 +1,97 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Hosts stilllegen statt löschen – und löschen nur, wenn nichts daran hängt (#041). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
vi.mock("next/cache", () => ({ revalidatePath: () => {} }));
vi.mock("next/navigation", () => ({ redirect: () => {} }));
const { hostArchivieren, hostLoeschen } = await import("@/server/actions/hosts");
const { hostListe } = await import("@/server/queries/hostliste");
function formular(werte: Record<string, string>) {
const daten = new FormData();
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
return daten;
}
async function host(hostname = "hv-pve-01") {
return testPrisma.host.create({ data: { agentId: `a-${hostname}`, hostname, produkt: "PVE" } });
}
describe.runIf(datenbankVerfuegbar)("Hosts stilllegen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("nimmt einen stillgelegten Host aus der Liste", async () => {
const h = await host();
expect((await hostListe({})).gesamt).toBe(1);
await hostArchivieren(formular({ hostId: h.id }));
expect((await hostListe({})).gesamt).toBe(0);
expect((await hostListe({ archiviert: "ja" })).gesamt).toBe(1);
});
it("schließt beim Stilllegen die offenen Warnungen", async () => {
const h = await host();
await testPrisma.alert.create({
data: {
regel: "status-nicht-aktiv",
fingerprint: `status-nicht-aktiv:${h.id}`,
schwere: "WARNUNG",
titel: "Keine Subscription gefunden",
beschreibung: "…",
hostId: h.id,
},
});
await hostArchivieren(formular({ hostId: h.id }));
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: h.id } });
expect(alert.erledigtAm).not.toBeNull();
});
it("nimmt einen Host auf Wunsch wieder auf", async () => {
const h = await host();
await hostArchivieren(formular({ hostId: h.id }));
await hostArchivieren(formular({ hostId: h.id }));
expect((await hostListe({})).gesamt).toBe(1);
});
it("löscht einen Host ohne Laufzeiten", async () => {
const h = await host();
await hostLoeschen(formular({ hostId: h.id }));
expect(await testPrisma.host.count()).toBe(0);
});
it("löscht keinen Host, an dem eine Laufzeit hängt", async () => {
const h = await host();
await testPrisma.laufzeit.create({
data: {
hostId: h.id,
key: "pve1c-aaaa",
start: new Date("2026-01-01"),
ende: new Date("2026-12-31"),
status: "BESTAETIGT",
},
});
await hostLoeschen(formular({ hostId: h.id }));
// Buchhaltung bleibt: Der Host ist noch da und lässt sich nur stilllegen.
expect(await testPrisma.host.count()).toBe(1);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Schlagworte und Notizen in Liste, Filter und Suche (#040). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
sitzungErforderlich: async () => {
throw new Error("in diesem Test nicht vorgesehen");
},
}));
const { hostListe, filterOptionen } = await import("@/server/queries/hostliste");
const { suchIndex } = await import("@/server/queries/hosts");
async function hosts() {
// Zwei Hosts mit demselben Namen bei verschiedenen Kunden – genau der Fall aus der Praxis.
const [kundeA, kundeB] = await Promise.all([
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
]);
await testPrisma.host.create({
data: {
agentId: "a-1",
hostname: "hv-pve-01",
produkt: "PVE",
kundeId: kundeA.id,
notizen: "Steht im Serverraum links",
tags: { connectOrCreate: [{ where: { name: "rechenzentrum" }, create: { name: "rechenzentrum" } }] },
},
});
await testPrisma.host.create({
data: {
agentId: "a-2",
hostname: "hv-pve-01",
produkt: "PVE",
kundeId: kundeB.id,
tags: { connectOrCreate: [{ where: { name: "aussenstelle" }, create: { name: "aussenstelle" } }] },
},
});
}
describe.runIf(datenbankVerfuegbar)("Schlagworte und Notizen", () => {
beforeEach(async () => {
await datenbankLeeren();
await hosts();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("liefert die Schlagworte zu jedem Host mit", async () => {
const liste = await hostListe({});
const namen = liste.hosts.flatMap((host) => host.tags.map((tag) => tag.name)).sort();
expect(namen).toEqual(["aussenstelle", "rechenzentrum"]);
});
it("filtert nach Schlagwort", async () => {
const liste = await hostListe({ tag: "aussenstelle" });
expect(liste.gesamt).toBe(1);
expect(liste.hosts[0].kunde?.name).toBe("Kunde B");
});
it("findet einen Host über sein Schlagwort", async () => {
const liste = await hostListe({ q: "rechenzentrum" });
expect(liste.gesamt).toBe(1);
expect(liste.hosts[0].kunde?.name).toBe("Kunde A");
});
it("findet einen Host über seine Notiz", async () => {
const liste = await hostListe({ q: "serverraum" });
expect(liste.gesamt).toBe(1);
});
it("bietet nur vergebene Schlagworte zur Auswahl an", async () => {
const { tags } = await filterOptionen();
expect(tags).toEqual(["aussenstelle", "rechenzentrum"]);
});
it("nimmt Schlagworte und Notizen in die globale Suche auf", async () => {
const eintraege = await suchIndex();
const treffer = eintraege.filter((eintrag) => eintrag.begriffe.includes("rechenzentrum"));
expect(treffer).toHaveLength(1);
expect(treffer[0].untertitel).toContain("rechenzentrum");
});
});
+57 -3
View File
@@ -28,6 +28,7 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
subLizenzierteSockets: 1, subLizenzierteSockets: 1,
sockets: 1, sockets: 1,
letzterReportAm: new Date(JETZT.getTime() - 3_600_000), letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
ips: ["10.0.0.10"],
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }], serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
...überschreibungen, ...überschreibungen,
}; };
@@ -72,9 +73,9 @@ function ausRegel(k: RegelKontext, name: string) {
} }
describe("Warnregeln", () => { describe("Warnregeln", () => {
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => { it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(12); expect(REGELN).toHaveLength(13);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12); expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
}); });
describe("Subscription läuft ab", () => { describe("Subscription läuft ab", () => {
@@ -414,4 +415,57 @@ describe("Warnregeln", () => {
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0); expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
}); });
}); });
describe("Gleicher Hostname beim selben Kunden", () => {
it("meldet zwei gleichnamige Hosts desselben Kunden – mit IPs zum Unterscheiden", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01", ips: ["10.10.5.32"] }),
host({ id: "h2", hostname: "hv-pve-01", ips: ["10.10.12.33", "10.10.5.11"] }),
],
});
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt");
expect(treffer).toHaveLength(1);
expect(treffer[0].titel).toBe("2 Hosts heißen „hv-pve-01“");
expect(treffer[0].beschreibung).toContain("10.10.5.32");
expect(treffer[0].beschreibung).toContain("10.10.12.33");
});
it("schweigt bei gleichem Namen in verschiedenen Kundennetzen", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01", kundeId: "k1" }),
host({ id: "h2", hostname: "hv-pve-01", kundeId: "k2" }),
],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0);
});
it("schweigt bei Hosts ohne Kunde", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01", kundeId: null, kundeName: null }),
host({ id: "h2", hostname: "hv-pve-01", kundeId: null, kundeName: null }),
],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0);
});
it("erzeugt für drei gleichnamige Hosts nur eine Warnung", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01" }),
host({ id: "h2", hostname: "hv-pve-01" }),
host({ id: "h3", hostname: "HV-PVE-01" }),
],
});
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt");
expect(treffer).toHaveLength(1);
expect(treffer[0].titel).toContain("3 Hosts");
});
});
}); });