Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f076f49631 | ||
|
|
8e5bf74a30 | ||
|
|
02845ed717 | ||
|
|
3657b60d16 | ||
|
|
f86a328b3c | ||
|
|
b2664de62f | ||
|
|
dfb2118bac | ||
|
|
335fc47496 | ||
|
|
e570d30019 | ||
|
|
c5293f5915 |
@@ -17,6 +17,46 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.24.1] – 2026-10-03
|
||||
|
||||
### Behoben
|
||||
- **Cluster mit QDevice meldeten falsche Knotennamen** (`A,V,NMW`, `NR`) und das QDevice als zusätzlichen Knoten: `pvecm nodes` hat dann eine Spalte mehr. Die Namensspalte wird jetzt aus der Kopfzeile bestimmt (#042)
|
||||
- Agent-Version 0.2.2
|
||||
|
||||
## [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
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 19 h 53 min
|
||||
**Gesamt:** 22 h 53 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -43,6 +43,11 @@
|
||||
| #035 Tabellen als Karten | 0 h 50 min |
|
||||
| #036 Standort am Host | 0 h 30 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 |
|
||||
| #042 QDevice im Cluster | 0 h 20 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -65,7 +70,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.22.0
|
||||
**Version:** 0.24.1
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -194,6 +199,23 @@
|
||||
- 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
|
||||
|
||||
- Ü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
|
||||
|
||||
- QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice
|
||||
- Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen
|
||||
|
||||
### Nächster Schritt
|
||||
- #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)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
AGENT_VERSION="0.2.1"
|
||||
AGENT_VERSION="0.2.2"
|
||||
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
||||
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
||||
SPOOL_MAXIMUM=30
|
||||
@@ -175,9 +175,24 @@ cluster_name() {
|
||||
|
||||
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
||||
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
||||
# Namen aller Cluster-Knoten aus `pvecm nodes`.
|
||||
#
|
||||
# Die Spaltenzahl ist nicht fest: Mit einem QDevice steht zwischen Votes und Name noch dessen
|
||||
# Zustand (A,V,NMW oder NR). Deshalb wird die Spalte "Name" aus der Kopfzeile bestimmt, statt
|
||||
# blind die dritte zu nehmen. Die Zeile des QDevice selbst traegt die Nodeid 0 und ist kein Knoten.
|
||||
cluster_mitglieder() {
|
||||
if command -v pvecm >/dev/null 2>&1; then
|
||||
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//'
|
||||
pvecm nodes 2>/dev/null | awk '
|
||||
/Nodeid/ {
|
||||
for (i = 1; i <= NF; i++) if ($i == "Name") spalte = i
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {
|
||||
if ($1 == 0) next
|
||||
name = (spalte ? $spalte : $3)
|
||||
sub(/\(.*/, "", name)
|
||||
if (name != "") print name
|
||||
}'
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 194 KiB |
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# #042 Cluster-Knoten falsch gelesen, wenn ein QDevice im Spiel ist
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.24.1
|
||||
**Labels:** bug, agent
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Auslöser
|
||||
Florians Cluster hat drei Knoten (`hv-pve-01`, `hv-pve-02`, `hv-pve-05`). ProxLedger zeigte vier
|
||||
„Cluster-Knoten“: `A,V,NMW`, `A,V,NMW`, `NR` und `Qdevice`.
|
||||
|
||||
## Ursache
|
||||
Der Agent nahm aus `pvecm nodes` blind die dritte Spalte als Namen. Ohne QDevice stimmt das:
|
||||
|
||||
```
|
||||
Nodeid Votes Name
|
||||
1 1 hv-rz5-pve-01 (local)
|
||||
```
|
||||
|
||||
Mit QDevice steht dort aber dessen Zustand, und der Name rutscht eine Spalte weiter:
|
||||
|
||||
```
|
||||
Nodeid Votes Qdevice Name
|
||||
1 1 A,V,NMW hv-pve-01
|
||||
0 1 Qdevice (votes 1)
|
||||
```
|
||||
|
||||
Gemeldet wurden damit die Zustandskürzel – und das QDevice selbst als vierter „Knoten“.
|
||||
|
||||
## Behebung
|
||||
Die Spalte „Name“ wird aus der Kopfzeile bestimmt, statt sie zu erraten. Die Zeile des QDevice trägt
|
||||
die Nodeid 0 und wird übersprungen. Beide Ausgabeformen sind als Testfall hinterlegt.
|
||||
|
||||
## Folgen
|
||||
Die Warnregel „Cluster-Knoten ohne Agent“ hatte dadurch Warnungen für Knoten erzeugt, die es nicht
|
||||
gibt. Sie schließen sich von selbst, sobald die Agents mit der neuen Fassung gemeldet haben.
|
||||
|
||||
## Lehre
|
||||
Dritter Fall in dieser Reihe: Eine Ausgabe, die beim Feldtest auf **einem** Cluster richtig gelesen
|
||||
wurde, ist nicht auf jedem Cluster gleich. Spalten gehören über ihre Überschrift angesprochen, nicht
|
||||
über ihre Position.
|
||||
@@ -41,4 +41,9 @@ 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 |
|
||||
| [#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 |
|
||||
| | **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 |
|
||||
| [#042](042-qdevice-cluster.md) | Cluster-Knoten falsch gelesen bei QDevice | erledigt | v0.24.1 | 1 h | 0 h 20 min |
|
||||
| | **Summe** | | | **169 h** | **22 h 53 min** |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.22.0",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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");
|
||||
@@ -134,6 +134,10 @@ model Host {
|
||||
subNextduedate DateTime? @db.Date
|
||||
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())
|
||||
letzterReportAm DateTime?
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||
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 */
|
||||
const ZUSATZ = process.argv
|
||||
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||
@@ -187,6 +195,11 @@ async function main() {
|
||||
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) {
|
||||
const dunkel = profilVorbereiten("dunkel", true);
|
||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
||||
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
|
||||
import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts";
|
||||
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
||||
import { hostMitDetails } from "@/server/queries/hostliste";
|
||||
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>
|
||||
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
||||
{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>
|
||||
|
||||
<Zeile bezeichnung="Kunde">
|
||||
@@ -177,6 +204,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
kundeId={host.kundeId}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -225,62 +278,66 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
inhalt: (
|
||||
<>
|
||||
<h3 className="h6">Keys</h3>
|
||||
<table className="table table-striped align-middle mb-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 ? (
|
||||
<div className="table-responsive mb-4">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-body-secondary">
|
||||
Noch kein Key gemeldet.
|
||||
</td>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Erstmals gemeldet</th>
|
||||
<th scope="col">Zuletzt gemeldet</th>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<td colSpan={3} className="text-body-secondary">
|
||||
Noch kein Key gemeldet.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="h6">Server-IDs</h3>
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-body-secondary">
|
||||
Noch keine Server-ID gemeldet.
|
||||
</td>
|
||||
<th scope="col">Server-ID</th>
|
||||
<th scope="col">Erstmals gemeldet</th>
|
||||
<th scope="col">Zuletzt gemeldet</th>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<td colSpan={3} className="text-body-secondary">
|
||||
Noch keine Server-ID gemeldet.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{host.serverIdHistorie.length > 1 ? (
|
||||
<p className="small pl-text-warning mt-2 mb-0">
|
||||
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",
|
||||
inhalt: (
|
||||
<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>
|
||||
<tr>
|
||||
<th scope="col">Laufzeit</th>
|
||||
@@ -355,21 +412,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
</thead>
|
||||
<tbody>
|
||||
{host.laufzeiten.map((laufzeit) => {
|
||||
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||
const einkauf = laufzeit.proxmoxPosition
|
||||
? Number(laufzeit.proxmoxPosition.nettoBetrag)
|
||||
? Number(laufzeit.proxmoxPosition.nettoBetrag) -
|
||||
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||
: null;
|
||||
const verkauf = laufzeit.kundenPosition
|
||||
? Number(laufzeit.kundenPosition.nettoBetrag)
|
||||
: null;
|
||||
return (
|
||||
<tr key={laufzeit.id}>
|
||||
<td>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
<td className="pl-karten-kopf">
|
||||
<Link
|
||||
href={`/rechnungen/${laufzeit.id}`}
|
||||
className="fw-semibold text-decoration-none"
|
||||
>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
</Link>
|
||||
{laufzeit.status === "VORSCHLAG" ? (
|
||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Proxmox-Rechnung">
|
||||
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
||||
<div>
|
||||
<EinkaufBadge
|
||||
@@ -381,8 +445,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatEuro(einkauf)}</td>
|
||||
<td>
|
||||
<td data-label="Einkauf">{formatEuro(einkauf)}</td>
|
||||
<td data-label="Kundenrechnung">
|
||||
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
||||
<div>
|
||||
<VerkaufBadge
|
||||
@@ -391,8 +455,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatEuro(verkauf)}</td>
|
||||
<td>
|
||||
<td data-label="Verkauf">{formatEuro(verkauf)}</td>
|
||||
<td data-label="Marge">
|
||||
{einkauf !== null && verkauf !== null
|
||||
? formatEuro(verkauf - einkauf)
|
||||
: "–"}
|
||||
@@ -429,53 +493,55 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
titel: "Tokens",
|
||||
icon: "bi-key-fill",
|
||||
inhalt: (
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={5} className="text-body-secondary">
|
||||
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
|
||||
</td>
|
||||
<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>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<td colSpan={5} className="text-body-secondary">
|
||||
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -68,6 +68,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
const filter: HostFilter = {
|
||||
q: einzeln(roh.q),
|
||||
kunde: einzeln(roh.kunde),
|
||||
tag: einzeln(roh.tag),
|
||||
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
|
||||
status: einzeln(roh.status),
|
||||
produkt: einzeln(roh.produkt),
|
||||
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> = {
|
||||
q: filter.q,
|
||||
kunde: filter.kunde,
|
||||
tag: filter.tag,
|
||||
archiviert: filter.archiviert,
|
||||
status: filter.status,
|
||||
produkt: filter.produkt,
|
||||
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">
|
||||
<table className="table table-striped align-middle">
|
||||
@@ -152,7 +156,31 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
{host.hostname}
|
||||
</Link>
|
||||
{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>
|
||||
{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>
|
||||
{host.kunde ? (
|
||||
|
||||
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
|
||||
const einkauf = laufzeit.proxmoxPosition?.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 marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||
|
||||
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
<hr />
|
||||
<dl className="row mb-0">
|
||||
<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>
|
||||
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
||||
<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,
|
||||
datum: einkauf?.datum.toISOString() ?? null,
|
||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||
nettoBetrag: einkaufBetrag,
|
||||
nettoBetrag: einkaufListenpreis,
|
||||
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||
rabatt:
|
||||
laufzeit.proxmoxPosition?.rabatt === null ||
|
||||
|
||||
@@ -204,7 +204,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
<th scope="col" className="d-none d-md-table-cell">
|
||||
Host
|
||||
</th>
|
||||
<th scope="col" className="d-none d-lg-table-cell">
|
||||
<th scope="col" className="pl-spalte-laufzeit">
|
||||
Laufzeit
|
||||
</th>
|
||||
<th scope="col">Proxmox-Rechnung</th>
|
||||
@@ -254,7 +254,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
{zeile.hostname}
|
||||
</Link>
|
||||
</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">
|
||||
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
||||
</Link>
|
||||
|
||||
@@ -395,6 +395,24 @@ body {
|
||||
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 ---------- */
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
@@ -425,6 +443,30 @@ body {
|
||||
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
||||
*/
|
||||
@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 tbody,
|
||||
.pl-karten-tabelle tr,
|
||||
|
||||
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
*/
|
||||
export function HostFilterLeiste({
|
||||
kunden,
|
||||
tags,
|
||||
werte,
|
||||
}: {
|
||||
kunden: { id: string; name: string }[];
|
||||
tags: string[];
|
||||
werte: Record<string, string | undefined>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
|
||||
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],
|
||||
).length;
|
||||
|
||||
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
|
||||
</select>
|
||||
</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">
|
||||
<select
|
||||
className="form-select"
|
||||
@@ -135,6 +158,20 @@ export function HostFilterLeiste({
|
||||
nur mit offenen Rechnungspunkten
|
||||
</label>
|
||||
</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 ? (
|
||||
<Link href="/hosts" className="small">
|
||||
Filter zurücksetzen ({aktiveFilter})
|
||||
|
||||
@@ -140,8 +140,7 @@ export function Header({
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
|
||||
style={{ width: "22rem" }}
|
||||
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
|
||||
>
|
||||
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
||||
<div className="dropdown-divider" />
|
||||
@@ -206,7 +205,9 @@ export function Header({
|
||||
<i className="bi bi-person-circle" aria-hidden="true" />
|
||||
<span className="d-none d-sm-inline">{benutzer}</span>
|
||||
</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">
|
||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||
</span>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
|
||||
const { headers } = await import("next/headers");
|
||||
const kopfzeilen = await headers();
|
||||
let kopfzeilen: 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.
|
||||
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { eigenerPfad } from "@/lib/adresse";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
|
||||
revalidatePath("/kunden");
|
||||
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,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
|
||||
import { AgentApiFehler } from "./service";
|
||||
|
||||
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
||||
export const AGENT_VERSION = "0.2.1";
|
||||
export const AGENT_VERSION = "0.2.2";
|
||||
|
||||
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
||||
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
||||
|
||||
@@ -9,13 +9,15 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
|
||||
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
|
||||
const [hosts, laufzeiten, einstellungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
|
||||
where: { archiviertAm: null },
|
||||
include: {
|
||||
kunde: { select: { name: true } },
|
||||
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
|
||||
},
|
||||
}),
|
||||
prisma.laufzeit.findMany({
|
||||
where: { status: "BESTAETIGT" },
|
||||
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
||||
include: {
|
||||
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
@@ -40,6 +42,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
||||
subLizenzierteSockets: host.subLizenzierteSockets,
|
||||
sockets: host.sockets,
|
||||
letzterReportAm: host.letzterReportAm,
|
||||
ips: host.ips,
|
||||
serverIds: host.serverIdHistorie,
|
||||
})),
|
||||
laufzeiten: laufzeiten.map((laufzeit) => ({
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface HostDaten {
|
||||
subLizenzierteSockets: number | null;
|
||||
sockets: number | null;
|
||||
letzterReportAm: Date | null;
|
||||
ips: string[];
|
||||
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
|
||||
* 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,
|
||||
kundenrechnungUnbezahlt,
|
||||
kundeBezahltEinkaufOffen,
|
||||
hostnameDoppelt,
|
||||
];
|
||||
|
||||
/** Wendet alle aktiven Regeln an. */
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
const einkaufSichtbar = await darfEinkaufSehen();
|
||||
|
||||
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" },
|
||||
include: {
|
||||
kunde: { select: { id: true, name: true } },
|
||||
|
||||
@@ -6,6 +6,10 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||||
export interface HostFilter {
|
||||
q?: string;
|
||||
kunde?: string;
|
||||
/** Schlagwort, nach dem gefiltert wird (#040). */
|
||||
tag?: string;
|
||||
/** Stillgelegte Hosts mitzeigen (#041). */
|
||||
archiviert?: "ja";
|
||||
status?: string;
|
||||
produkt?: string;
|
||||
ablauf?: "30" | "14" | "abgelaufen";
|
||||
@@ -20,6 +24,13 @@ export const SEITENGROESSE = 25;
|
||||
function bedingung(filter: HostFilter, jetzt: Date): 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) {
|
||||
const suche = filter.q.trim();
|
||||
und.push({
|
||||
@@ -30,6 +41,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
||||
{ subKey: { contains: suche, mode: "insensitive" } },
|
||||
{ ips: { has: suche } },
|
||||
{ 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.
|
||||
{
|
||||
laufzeiten: {
|
||||
@@ -113,6 +126,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
|
||||
include: {
|
||||
kunde: { select: { id: true, 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" },
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
|
||||
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() {
|
||||
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
|
||||
// noch niemandem, dürfen also auch niemandem gezeigt werden.
|
||||
if (await sichtbarerKunde()) return [];
|
||||
|
||||
return prisma.host.findMany({
|
||||
where: { kundeId: null },
|
||||
where: { kundeId: null, ...NICHT_ARCHIVIERT },
|
||||
orderBy: { erstmalsGesehenAm: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function hostAnzahl() {
|
||||
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. */
|
||||
export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||
const kundeId = await sichtbarerKunde();
|
||||
const hostSicht = kundeId ? { kundeId } : {};
|
||||
const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
|
||||
const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
|
||||
|
||||
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
||||
prisma.host.count({ where: hostSicht }),
|
||||
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: {
|
||||
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
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([
|
||||
@@ -94,7 +102,7 @@ export async function suchIndex() {
|
||||
|
||||
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: kundeId ? { kundeId } : {},
|
||||
where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
@@ -103,7 +111,9 @@ export async function suchIndex() {
|
||||
subKey: true,
|
||||
ips: true,
|
||||
produkt: true,
|
||||
notizen: true,
|
||||
kunde: { select: { name: true } },
|
||||
tags: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.kunde.findMany({
|
||||
@@ -128,9 +138,17 @@ export async function suchIndex() {
|
||||
titel: host.hostname,
|
||||
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
||||
host.subKey ? ` · ${host.subKey}` : ""
|
||||
}`,
|
||||
}${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
|
||||
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))
|
||||
.map((w) => w.toLowerCase()),
|
||||
})),
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
|
||||
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" }],
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -241,6 +241,25 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
|
||||
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
|
||||
});
|
||||
|
||||
it("liest die Knoten auch mit QDevice-Spalte richtig", () => {
|
||||
// Mit einem QDevice steht zwischen Votes und Name dessen Zustand. Vorher landeten
|
||||
// „A,V,NMW“ und „NR“ als Knotennamen im Report (#042).
|
||||
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||
befehlErsetzen(
|
||||
"pvecm",
|
||||
`Membership information
|
||||
----------------------
|
||||
Nodeid Votes Qdevice Name
|
||||
1 1 A,V,NMW hv-pve-01
|
||||
2 1 A,V,NMW hv-pve-02 (local)
|
||||
3 1 NR hv-pve-05
|
||||
0 1 Qdevice (votes 1)`,
|
||||
);
|
||||
|
||||
const report = reportErzeugen() as { host: { clusterMitglieder: string[] } };
|
||||
expect(report.host.clusterMitglieder).toEqual(["hv-pve-01", "hv-pve-02", "hv-pve-05"]);
|
||||
});
|
||||
|
||||
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
|
||||
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
|
||||
|
||||
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
||||
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
||||
expect(einstellungen).toHaveLength(12);
|
||||
expect(einstellungen).toHaveLength(13);
|
||||
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
||||
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
|
||||
subLizenzierteSockets: 1,
|
||||
sockets: 1,
|
||||
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
|
||||
ips: ["10.0.0.10"],
|
||||
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
|
||||
...überschreibungen,
|
||||
};
|
||||
@@ -72,9 +73,9 @@ function ausRegel(k: RegelKontext, name: string) {
|
||||
}
|
||||
|
||||
describe("Warnregeln", () => {
|
||||
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => {
|
||||
expect(REGELN).toHaveLength(12);
|
||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12);
|
||||
it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
|
||||
expect(REGELN).toHaveLength(13);
|
||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
|
||||
});
|
||||
|
||||
describe("Subscription läuft ab", () => {
|
||||
@@ -414,4 +415,57 @@ describe("Warnregeln", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||