Compare commits
No commits in common. "main" and "v1.0.0-rc4" have entirely different histories.
main
...
v1.0.0-rc4
250
CHANGELOG.md
250
CHANGELOG.md
@ -1,255 +1,5 @@
|
|||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
## V1 — Release Candidate 9, 18. August 2026
|
|
||||||
|
|
||||||
### Sicherungsart je Auftrag
|
|
||||||
|
|
||||||
Bisher entschied die Anlage allein: Liegt ein Elternbackup vor, wird
|
|
||||||
inkrementell gesichert. Jetzt wählbar —
|
|
||||||
|
|
||||||
- **inkrementell** (Standard, bisheriges Verhalten),
|
|
||||||
- **immer voll**, oder
|
|
||||||
- inkrementell **mit festem Volltag**, etwa „immer freitags".
|
|
||||||
|
|
||||||
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
|
|
||||||
Server in UTC, bekäme ein Betreiber in Berlin seine Vollsicherung am
|
|
||||||
Donnerstagabend und wunderte sich, warum sie freitags fehlt.
|
|
||||||
|
|
||||||
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert** — unveränderte
|
|
||||||
Blöcke werden dedupliziert. Was steigt, ist die Laufzeit. Das steht so in der
|
|
||||||
Maske, weil es die häufigste Verwechslung ist.
|
|
||||||
|
|
||||||
Migration 000014 mit drei CHECKs. Der dritte lehnt „immer voll" zusammen mit
|
|
||||||
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll.
|
|
||||||
|
|
||||||
### Behoben
|
|
||||||
|
|
||||||
- **Das Aufnahme-Token eines Agenten zeigte „undefined".** Das Feld heißt
|
|
||||||
`token`, nicht `enrollment_token` — Letzteres ist der Name im *Anfrage*körper
|
|
||||||
der Registrierung. Der dritte Formfehler dieser Art; alle konsumierten
|
|
||||||
Endpunkte sind jetzt gegen den laufenden Dienst abgeglichen statt aus der
|
|
||||||
Struktur abgeleitet.
|
|
||||||
|
|
||||||
### Aufnahmedialog mit Anleitung
|
|
||||||
|
|
||||||
Vollständige Anleitung für **Linux und Windows**, umschaltbar, mit fertig
|
|
||||||
ausgefüllten Befehlen — Serveradresse und Token eingesetzt, jeder Schritt
|
|
||||||
einzeln kopierbar. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
|
||||||
jemand `<token>` wörtlich einsetzt.
|
|
||||||
|
|
||||||
Dazu die beiden Stolperstellen: `--state` erwartet eine **Datei**, und der
|
|
||||||
Agent braucht Schreibzugriff auf das Repository.
|
|
||||||
|
|
||||||
### update.sh rüstet die Wiederherstellungsfläche nach
|
|
||||||
|
|
||||||
Sie kam mit rc8 dazu; eine Anlage aus einer älteren Fassung hat sie nicht. Ohne
|
|
||||||
sie scheitert jede Wiederherstellung an `ProtectSystem=strict`. `update.sh`
|
|
||||||
legt sie jetzt an und trägt sie in `ReadWritePaths` ein — ein Schritt, den man
|
|
||||||
von Hand ausführen muss, wird übersehen und fällt erst im Ernstfall auf.
|
|
||||||
|
|
||||||
## V1 — Release Candidate 8, 18. August 2026
|
|
||||||
|
|
||||||
Wiederherstellung von Dateien und Ordnern mit Auswahl statt Textfeld — und die
|
|
||||||
Erklärung, warum vorher gar keine Wiederherstellung funktionierte.
|
|
||||||
|
|
||||||
### Warum keine Wiederherstellung ging
|
|
||||||
|
|
||||||
Nicht die Rechte des Zielverzeichnisses, sondern die Härtung des Dienstes: Er
|
|
||||||
läuft mit `ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
|
|
||||||
Sicherungsordner. Jedes Ziel außerhalb endete mit `mkdir: permission denied` —
|
|
||||||
und zwar **nach** der Vorabprüfung, an der unangenehmsten Stelle. `/tmp`
|
|
||||||
scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein eigenes `/tmp`, und
|
|
||||||
was dort landet, ist von außen unsichtbar.
|
|
||||||
|
|
||||||
`setup.sh` legt jetzt `/srv/syncova-restore` an und trägt es ein;
|
|
||||||
`--wiederherstellungsziel` ergänzt weitere. Der Ort liegt unter `/srv`, weil
|
|
||||||
`/var/lib` auf der Sperrliste des Zielschutzes steht — beide Regeln zugleich zu
|
|
||||||
erfüllen lässt genau `/srv` übrig.
|
|
||||||
|
|
||||||
### Auswahl statt Textfeld
|
|
||||||
|
|
||||||
- **Ordnerbaum für das Ziel.** Er meldet je Verzeichnis, ob der Dienst dort
|
|
||||||
schreiben darf — **gemessen** durch eine Probedatei, nicht aus den Rechtebits
|
|
||||||
abgeleitet. Gesperrte Orte werden gezeigt, nicht versteckt: Sonst bliebe
|
|
||||||
offen, warum ein Pfad fehlt.
|
|
||||||
- **Browser für den Backup-Inhalt.** Ordner **und** einzelne Dateien lassen
|
|
||||||
sich zurückholen. Der Baum entsteht aus den Pfaden, nicht aus
|
|
||||||
Verzeichniseinträgen — ein Manifest kann eine Datei enthalten, deren
|
|
||||||
Elternordner nicht als eigener Eintrag vorliegt.
|
|
||||||
|
|
||||||
Zwei neue Endpunkte, der eingefrorene Vertrag ist entsprechend erweitert:
|
|
||||||
`GET /filesystem/browse` und `GET /backups/{id}/contents`.
|
|
||||||
|
|
||||||
### Behoben
|
|
||||||
|
|
||||||
- **„can't access property toLocaleString, chunks_checked is undefined"** beim
|
|
||||||
Integritätslauf. Die Ergebnisse liegen unter `details`, und die Felder heißen
|
|
||||||
`missing_chunks`/`corrupted_chunks`. Betrifft alle vier Prüfendpunkte — sie
|
|
||||||
tragen dieselbe Hülle. Derselbe Fehler wie zuvor bei `/retention-policies`:
|
|
||||||
die Antwortform angenommen statt geprüft. Alle konsumierten Endpunkte sind
|
|
||||||
jetzt gegen den laufenden Dienst abgeglichen.
|
|
||||||
|
|
||||||
### Geist Mono liegt im Paket
|
|
||||||
|
|
||||||
Drei Schnitte, 128 KB, OFL-Lizenz dabei. Ausgeliefert vom eigenen Ursprung —
|
|
||||||
das verlangt die CSP, und ein Backup-Server, dessen Oberfläche von der
|
|
||||||
Erreichbarkeit eines CDN abhängt, wäre auch ohne CSP falsch.
|
|
||||||
|
|
||||||
### Bekannte Grenze
|
|
||||||
|
|
||||||
**Eine Auswahl je Lauf**, kein Mehrfachhaken. Eine Liste ausgewählter Pfade
|
|
||||||
kennt die API nicht; mehrere Läufe hintereinander ergäben mehrere Ausgänge, und
|
|
||||||
ein „teilweise fehlgeschlagen" ließe sich dann nicht mehr erklären.
|
|
||||||
|
|
||||||
## V1 — Release Candidate 7, 18. August 2026
|
|
||||||
|
|
||||||
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.
|
|
||||||
|
|
||||||
### Behoben
|
|
||||||
|
|
||||||
- **`/retention` zeigte einen schwarzen Bildschirm.** `GET /retention-policies`
|
|
||||||
liefert ein Objekt `{policies, predefined}` — als einziger von zehn geprüften
|
|
||||||
Listenendpunkten. Die Oberfläche behandelte es als Array; `map` gibt es auf
|
|
||||||
einem Objekt nicht, React hängte den ganzen Baum aus. Der Regressionstest
|
|
||||||
füttert jetzt die **echte** Antwortform; ein Test mit einem Array hätte den
|
|
||||||
Fehler nie gefunden, und genau das war passiert.
|
|
||||||
- **Jedes Neuladen führte zurück zur Anmeldung.** Die Tokens lagen nur im
|
|
||||||
Arbeitsspeicher.
|
|
||||||
- **Ein Fehler in einer Komponente schwärzte die ganze Konsole.** Jetzt sitzt
|
|
||||||
eine Fehlergrenze um den Seiteninhalt: Menü und Kopfzeile bleiben stehen, der
|
|
||||||
Fehlertext ist lesbar und kopierbar.
|
|
||||||
|
|
||||||
### Sitzung
|
|
||||||
|
|
||||||
Sie überlebt ein Neuladen und endet nach **30 Minuten** — gerechnet als frühere
|
|
||||||
von zwei Grenzen: einer harten Obergrenze ab Anmeldung, die keine Interaktion
|
|
||||||
verschiebt, und einer Untätigkeitsgrenze. Die verbleibende Zeit läuft neben
|
|
||||||
„Abmelden" und wird unter fünf Minuten auffällig.
|
|
||||||
|
|
||||||
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
|
|
||||||
bestätigt: Wer beim Vermerken einer Interaktion die Obergrenze mitverschiebt,
|
|
||||||
macht aus „30 Minuten" ein „unbegrenzt, solange die Maus wackelt".
|
|
||||||
|
|
||||||
### Aussehen
|
|
||||||
|
|
||||||
Farben, Radien, Schatten und Schrift aus dem Preset `b5vnF8SMi`: violett als
|
|
||||||
Handlungsfarbe, Radius 0, schattenlos, durchgehend Geist Mono. Die
|
|
||||||
**Statusfarben bleiben** — das Preset kennt keine, und ohne sie ließe sich ein
|
|
||||||
Teilfehler nicht von einem Erfolg unterscheiden.
|
|
||||||
|
|
||||||
Dazu **Umlaute auf allen Seiten** (vorher durchgehend ae/oe/ue/ss), **33
|
|
||||||
Erläuterungen von Absätzen auf einen Satz gekürzt** und neue Module: acht
|
|
||||||
Schnellzugriffe auf der Übersicht sowie sechs mitgelieferte
|
|
||||||
Aufbewahrungsvorlagen als Kacheln — die lieferte der Server schon immer mit,
|
|
||||||
die Oberfläche warf sie bisher weg.
|
|
||||||
|
|
||||||
### Unverändert offen
|
|
||||||
|
|
||||||
Windows-Dienst, systemd-Einheit des Agenten und der Proxmox-Bootmeilenstein sind
|
|
||||||
gebaut, aber nie auf echter Hardware gefahren. Geist Mono wird nicht
|
|
||||||
mitgeliefert; ohne die Schrift auf dem Gerät greift die System-Monospace.
|
|
||||||
|
|
||||||
## V1 — Release Candidate 6, 18. August 2026
|
|
||||||
|
|
||||||
**Die Weboberfläche ist eine vollständige Verwaltungskonsole geworden.**
|
|
||||||
|
|
||||||
Vorher erreichte sie 23 von 95 fachlichen Endpunkten; schreibend waren es neun,
|
|
||||||
vier davon An- und Abmeldung. Real verwaltbar war: einen Auftrag anlegen, einen
|
|
||||||
Bericht erzeugen, eine Meldung bestätigen. Das war ein Leseinstrument mit
|
|
||||||
Assistent, keine Konsole. **Jetzt sind es 89 von 95.**
|
|
||||||
|
|
||||||
### Neu bedienbar
|
|
||||||
|
|
||||||
- **Wiederherstellung** als vierstufiger Assistent — vorher nur über `curl`. Die
|
|
||||||
Vorabprüfung ist ein eigener Schritt, weil sie den Unterschied zwischen
|
|
||||||
Hoffnung und Nachweis macht: Sie schreibt nichts und stellt fest, ob **jeder
|
|
||||||
benötigte Block noch da ist**. Die drei Hürden vor dem Überschreiben sind
|
|
||||||
sichtbar umgesetzt; läuft die dritte ins Leere, entfällt sie.
|
|
||||||
- **Wiederherstellungspunkte** mit Bewertung, Legal Hold, Fristverlängerung,
|
|
||||||
Löschung und Ransomware-Einschätzung.
|
|
||||||
- **Prüfung** mit allen fünf Prüfarten. Zustand und Ergebnis stehen
|
|
||||||
nebeneinander: Eine gescheiterte Prüfung ist kein Befund am Backup.
|
|
||||||
- **Repositories** mit Integritätslauf, Katalog-Neuaufbau, Gesundheitsprüfung und
|
|
||||||
gemessener Durchsetzungsstufe.
|
|
||||||
- **Aufbewahrung** mit Regeln und Vorschau vor dem Löschen.
|
|
||||||
- **Proxmox** — neun Endpunkte, die vorher gar keine Oberfläche hatten — und
|
|
||||||
**Agenten** samt einmaliger Anzeige des Aufnahme-Tokens.
|
|
||||||
- **Benutzer, Rollen, Benachrichtigungswege, eigener zweiter Faktor.**
|
|
||||||
|
|
||||||
### Neues Design
|
|
||||||
|
|
||||||
Tailwind v4 und Radix-Primitive nach shadcn-Muster, alles gebündelt. Dark Mode,
|
|
||||||
einklappbare Seitenleiste, Bedienung auf Tablets. Achtzehn Seiten in fünf
|
|
||||||
Bereichen.
|
|
||||||
|
|
||||||
Die tragende Entscheidung ist keine Frage des Aussehens: **Die Zuordnung der
|
|
||||||
Fachbegriffe auf die fünf Statusfarben liegt an genau einer Stelle.** Verteilt
|
|
||||||
über die Seiten erschiene früher oder später irgendwo `partial_failure` grün —
|
|
||||||
und ein Betreiber hält einen Teilfehler dann für einen Erfolg. Ein unbekannter
|
|
||||||
Serverzustand wird neutral dargestellt, niemals grün.
|
|
||||||
|
|
||||||
### Funde beim Nachweis
|
|
||||||
|
|
||||||
- **Die Einstufung heißt `successful`, nicht `unverified`.** Das ist die
|
|
||||||
gefährlichste Stelle der Oberfläche: `successful` bedeutet „der Lauf ist
|
|
||||||
durchgelaufen" — nicht „wiederherstellbar". Es ist deshalb neutral, nicht
|
|
||||||
grün. Regressionstest vorhanden.
|
|
||||||
- **`describeApiError` warf die genauere Servermeldung weg** und ersetzte sie
|
|
||||||
durch einen allgemeinen Satz. Ein `SERVICE_UNAVAILABLE` mit „Für diesen Bericht
|
|
||||||
ist keine Sicherheitsprüfung eingerichtet" wurde zu „Der Dienst ist derzeit
|
|
||||||
nicht vollständig verfügbar" — der Betreiber hätte den Fehler bei seiner Anlage
|
|
||||||
gesucht. Jetzt hat die Servermeldung Vorrang.
|
|
||||||
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
|
|
||||||
hätte ihn nicht angesagt.
|
|
||||||
- **Der Geheimnis-Scanner griff korrekt** beim Platzhaltertext des
|
|
||||||
SSH-Schlüsselfelds. Gekennzeichnet, statt das Muster aufzuweichen.
|
|
||||||
|
|
||||||
### Aufgeräumt
|
|
||||||
|
|
||||||
Ein Stylesheet statt sieben; das ausgelieferte CSS fällt von 54 auf 30 KB.
|
|
||||||
Entfernt, weil ersetzt: `JobsPanel`, `StatusIndicator`, `PageState`.
|
|
||||||
|
|
||||||
### Unverändert offen
|
|
||||||
|
|
||||||
Windows-Dienst, systemd-Einheit des Agenten und der Proxmox-Bootmeilenstein sind
|
|
||||||
gebaut, aber nie auf echter Hardware gefahren. Dazu ohne Oberfläche: sechs
|
|
||||||
Detail-Endpunkte (ihre Daten stehen in den Listen), Live-Fortschritt
|
|
||||||
(`/api/v1/events/stream` ist auch serverseitig nicht umgesetzt) und der Simple
|
|
||||||
Mode.
|
|
||||||
|
|
||||||
## V1 — Release Candidate 5, 18. August 2026
|
|
||||||
|
|
||||||
Die Oberfläche richtet sich jetzt mit ein. Bisher endete `setup.sh` mit einer
|
|
||||||
laufenden API auf `127.0.0.1:8080` und der Aufgabe, einen Webserver von Hand
|
|
||||||
davorzusetzen — der häufigste Punkt, an dem eine Einrichtung liegen blieb.
|
|
||||||
|
|
||||||
- **`setup.sh` richtet nginx und ein selbst signiertes Zertifikat ein.** Das
|
|
||||||
Zertifikat gilt für den Rechnernamen, den vollständigen Namen und **jede**
|
|
||||||
globale IPv4-Adresse des Servers (`subjectAltName` — moderne Browser lesen
|
|
||||||
den `CN` nicht mehr), 3650 Tage. Der SHA-256-Fingerabdruck wird genannt, damit
|
|
||||||
er sich beim ersten Aufruf im Browser vergleichen lässt.
|
|
||||||
- **Die API bleibt an `127.0.0.1:8080` gebunden.** Erreichbar ist sie nur durch
|
|
||||||
nginx hindurch. Sie stattdessen auf alle Schnittstellen zu legen wäre der
|
|
||||||
kürzere Weg und der falsche: Die Verschlüsselung ließe sich dann umgehen,
|
|
||||||
indem man Port 8080 direkt anspricht.
|
|
||||||
- **Die Firewall wird gemeldet, nicht geändert.** `ufw` und `firewalld` werden
|
|
||||||
erkannt und ihr Zustand ausgegeben; geöffnet wird nichts. Eine Einrichtung,
|
|
||||||
die selbsttätig einen Port ins Netz öffnet, hebelt genau die Entscheidung aus,
|
|
||||||
für die jemand die Firewall aufgesetzt hat.
|
|
||||||
- **Scheitert die Oberfläche, scheitert nicht die Einrichtung.** Die
|
|
||||||
Konfiguration wird mit `nginx -t` geprüft, **bevor** sie übernommen wird; hält
|
|
||||||
sie nicht, wird sie entfernt, der Grund genannt und der Nachholweg gezeigt.
|
|
||||||
Die Anlage läuft in jedem Fall.
|
|
||||||
- **Nachträglich einrichten:** `sudo /opt/syncova/setup.sh --weboberflaeche`.
|
|
||||||
Auslassen: `--ohne-weboberflaeche`.
|
|
||||||
|
|
||||||
Ein Fund beim Erproben:
|
|
||||||
|
|
||||||
- **`http2 on;` gibt es erst ab nginx 1.25.1.** Debian 12 liefert 1.22, wo
|
|
||||||
HTTP/2 ein Parameter von `listen` ist. Die neue Schreibweise ergibt dort
|
|
||||||
„unknown directive http2", und nginx startet nicht. Die Fassung wird jetzt
|
|
||||||
gelesen und die passende Schreibweise erzeugt.
|
|
||||||
|
|
||||||
## V1 — Release Candidate 4, 18. August 2026
|
## V1 — Release Candidate 4, 18. August 2026
|
||||||
|
|
||||||
Behebt [Issue #2](https://git.jfritzsche.de/jf/syncova-backup/issues/2): Die
|
Behebt [Issue #2](https://git.jfritzsche.de/jf/syncova-backup/issues/2): Die
|
||||||
|
|||||||
@ -1,422 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/syncova/syncova/packages/platform/logging"
|
|
||||||
"github.com/syncova/syncova/packages/recovery"
|
|
||||||
"github.com/syncova/syncova/packages/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// browseHandler bedient die beiden Blätterendpunkte.
|
|
||||||
//
|
|
||||||
// Sie sind für eine Wiederherstellung gebaut und lösen zwei Probleme, die sich
|
|
||||||
// mit einem Textfeld nicht lösen lassen:
|
|
||||||
//
|
|
||||||
// 1. **Wohin darf zurückgeschrieben werden?** Der Dienst läuft mit
|
|
||||||
// `ProtectSystem=strict`; außerhalb weniger Pfade ist das Dateisystem für
|
|
||||||
// ihn schreibgeschützt. Ein Betreiber tippt „/opt/test", bekommt
|
|
||||||
// „permission denied" und hat keine Möglichkeit zu erkennen, welcher Ort
|
|
||||||
// überhaupt in Frage kommt. `GET /filesystem/browse` beantwortet genau das
|
|
||||||
// — es meldet je Verzeichnis, ob der **Dienst** dort schreiben kann,
|
|
||||||
// geprüft durch einen tatsächlichen Schreibversuch.
|
|
||||||
//
|
|
||||||
// 2. **Was steckt in dem Backup?** Ohne Inhaltsverzeichnis lässt sich weder
|
|
||||||
// eine einzelne Datei noch ein Unterordner gezielt zurückholen. `GET
|
|
||||||
// /backups/{id}/contents` liefert das Manifest als Ebene eines Baums.
|
|
||||||
//
|
|
||||||
// Beide lesen nur.
|
|
||||||
type browseHandler struct {
|
|
||||||
restoreHandlerReference *restoreHandler
|
|
||||||
targetGuard *recovery.TargetGuard
|
|
||||||
}
|
|
||||||
|
|
||||||
// filesystemEntry ist ein Eintrag des Dateisystems.
|
|
||||||
type filesystemEntry struct {
|
|
||||||
// Name ist der letzte Pfadbestandteil.
|
|
||||||
Name string `json:"name"`
|
|
||||||
// Path ist der vollständige absolute Pfad.
|
|
||||||
Path string `json:"path"`
|
|
||||||
// IsDirectory unterscheidet Verzeichnis von Datei.
|
|
||||||
IsDirectory bool `json:"is_directory"`
|
|
||||||
// SizeBytes ist die Größe bei Dateien.
|
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
||||||
// IsWritable meldet, ob der Dienst hier anlegen darf.
|
|
||||||
//
|
|
||||||
// Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten:
|
|
||||||
// Unter `ProtectSystem=strict` sagen die Bits nichts über das aus, was der
|
|
||||||
// Namensraum zulässt.
|
|
||||||
IsWritable bool `json:"is_writable"`
|
|
||||||
// ForbiddenReason nennt den Grund, wenn der Zielschutz den Ort ausschließt.
|
|
||||||
ForbiddenReason string `json:"forbidden_reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseFilesystemResponse ist die Antwort auf das Blättern im Dateisystem.
|
|
||||||
type browseFilesystemResponse struct {
|
|
||||||
// Path ist das aufgelistete Verzeichnis.
|
|
||||||
Path string `json:"path"`
|
|
||||||
// ParentPath ist das übergeordnete Verzeichnis; leer bei der Wurzel.
|
|
||||||
ParentPath string `json:"parent_path,omitempty"`
|
|
||||||
// Entries sind die enthaltenen Verzeichnisse.
|
|
||||||
Entries []filesystemEntry `json:"entries"`
|
|
||||||
// SuggestedPaths sind Orte, an denen der Dienst nachweislich schreiben darf.
|
|
||||||
//
|
|
||||||
// Sie stehen in der Antwort, damit die Oberfläche einen brauchbaren
|
|
||||||
// Startpunkt anbieten kann, statt den Betreiber suchen zu lassen.
|
|
||||||
SuggestedPaths []string `json:"suggested_paths,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupContentEntry ist ein Eintrag im Inhaltsverzeichnis eines Backups.
|
|
||||||
type backupContentEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// Path ist der Pfad im Manifest — genau der Wert, den eine
|
|
||||||
// Wiederherstellung als `path_prefix` erwartet.
|
|
||||||
Path string `json:"path"`
|
|
||||||
IsDirectory bool `json:"is_directory"`
|
|
||||||
EntryType string `json:"entry_type"`
|
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
||||||
ModifiedAt string `json:"modified_at,omitempty"`
|
|
||||||
Mode string `json:"mode,omitempty"`
|
|
||||||
// ChildCount ist die Zahl der Einträge unterhalb eines Verzeichnisses.
|
|
||||||
ChildCount int `json:"child_count,omitempty"`
|
|
||||||
// TotalBytes ist die Datenmenge unterhalb eines Verzeichnisses.
|
|
||||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseBackupResponse ist die Antwort auf das Blättern im Backup.
|
|
||||||
type browseBackupResponse struct {
|
|
||||||
BackupID string `json:"backup_id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
ParentPath string `json:"parent_path,omitempty"`
|
|
||||||
// Entries sind die Einträge auf dieser Ebene.
|
|
||||||
Entries []backupContentEntry `json:"entries"`
|
|
||||||
// TotalEntryCount ist die Zahl aller Einträge im Backup.
|
|
||||||
TotalEntryCount int `json:"total_entry_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBrowseFilesystem bedient GET /filesystem/browse.
|
|
||||||
func (handler *browseHandler) handleBrowseFilesystem(responseWriter http.ResponseWriter, request *http.Request) {
|
|
||||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
|
||||||
|
|
||||||
requestedPath := strings.TrimSpace(request.URL.Query().Get("path"))
|
|
||||||
if requestedPath == "" {
|
|
||||||
requestedPath = "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !filepath.IsAbs(requestedPath) {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Der Pfad muss absolut sein."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Symlinks werden aufgelöst, bevor gelesen wird: Sonst ließe sich über
|
|
||||||
// einen Verweis an jeder Prüfung vorbei in ein fremdes Verzeichnis sehen.
|
|
||||||
resolvedPath, resolveError := filepath.EvalSymlinks(filepath.Clean(requestedPath))
|
|
||||||
if resolveError != nil {
|
|
||||||
resolvedPath = filepath.Clean(requestedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
directoryEntries, readError := os.ReadDir(resolvedPath)
|
|
||||||
if readError != nil {
|
|
||||||
if errors.Is(readError, os.ErrNotExist) {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewNotFoundError("Das Verzeichnis wurde nicht gefunden."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Das Verzeichnis lässt sich nicht lesen: "+readError.Error()))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entries := make([]filesystemEntry, 0, len(directoryEntries))
|
|
||||||
|
|
||||||
for _, directoryEntry := range directoryEntries {
|
|
||||||
// Nur Verzeichnisse: Ein Wiederherstellungsziel ist immer ein
|
|
||||||
// Verzeichnis, und die Dateien daneben wären nur Rauschen.
|
|
||||||
if !directoryEntry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Versteckte Verzeichnisse bleiben draußen. Wer eines braucht, tippt
|
|
||||||
// den Pfad — die Liste soll den Normalfall zeigen.
|
|
||||||
if strings.HasPrefix(directoryEntry.Name(), ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
childPath := filepath.Join(resolvedPath, directoryEntry.Name())
|
|
||||||
|
|
||||||
entry := filesystemEntry{
|
|
||||||
Name: directoryEntry.Name(),
|
|
||||||
Path: childPath,
|
|
||||||
IsDirectory: true,
|
|
||||||
IsWritable: directoryIsWritable(childPath),
|
|
||||||
}
|
|
||||||
|
|
||||||
if handler.targetGuard != nil {
|
|
||||||
if guardError := handler.targetGuard.Validate(childPath); guardError != nil {
|
|
||||||
entry.ForbiddenReason = guardError.Error()
|
|
||||||
// Ein gesperrter Ort ist nie ein zulässiges Ziel, auch wenn das
|
|
||||||
// Dateisystem ihn zuließe.
|
|
||||||
entry.IsWritable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
|
||||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
response := browseFilesystemResponse{
|
|
||||||
Path: resolvedPath,
|
|
||||||
Entries: entries,
|
|
||||||
SuggestedPaths: writableSuggestions(handler.targetGuard),
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolvedPath != "/" {
|
|
||||||
response.ParentPath = filepath.Dir(resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBrowseBackupContents bedient GET /backups/{id}/contents.
|
|
||||||
func (handler *browseHandler) handleBrowseBackupContents(responseWriter http.ResponseWriter, request *http.Request) {
|
|
||||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
|
||||||
|
|
||||||
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
|
||||||
if parseError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Die Backup-Kennung ist keine gültige UUID."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repositoryPath, backupIDInRepository, resolveError := handler.restoreHandlerReference.resolveBackup(
|
|
||||||
request.Context(), backupIdentifier)
|
|
||||||
if resolveError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger, resolveError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schreibgeschützt: Ein Inhaltsverzeichnis liest nur und soll neben einer
|
|
||||||
// laufenden Sicherung erstellt werden können.
|
|
||||||
openedRepository, openError := repository.Open(request.Context(), repositoryPath,
|
|
||||||
repository.OpenOptions{ReadOnly: true}, handler.restoreHandlerReference.logger)
|
|
||||||
if openError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewServiceUnavailableError("Das Repository des Backups ist derzeit nicht erreichbar."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = openedRepository.Close() }()
|
|
||||||
|
|
||||||
backupManifest, manifestError := openedRepository.ReadManifest(request.Context(), backupIDInRepository)
|
|
||||||
if manifestError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Das Manifest des Backups lässt sich nicht lesen: "+manifestError.Error()))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPath := strings.Trim(strings.TrimSpace(request.URL.Query().Get("path")), "/")
|
|
||||||
|
|
||||||
response := browseBackupResponse{
|
|
||||||
BackupID: backupIdentifier.String(),
|
|
||||||
Path: currentPath,
|
|
||||||
Entries: collectLevel(backupManifest.Entries, currentPath),
|
|
||||||
TotalEntryCount: len(backupManifest.Entries),
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentPath != "" {
|
|
||||||
parentPath := path.Dir(currentPath)
|
|
||||||
if parentPath == "." {
|
|
||||||
parentPath = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
response.ParentPath = parentPath
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectLevel bildet aus einem flachen Manifest eine Verzeichnisebene.
|
|
||||||
//
|
|
||||||
// Das Manifest kennt keine Baumstruktur, sondern eine flache Liste von Pfaden.
|
|
||||||
// Der Baum entsteht hier — und zwar **ohne** sich auf Verzeichniseinträge zu
|
|
||||||
// verlassen: Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis
|
|
||||||
// nicht als eigener Eintrag vorliegt. Wer nur die Einträge vom Typ `directory`
|
|
||||||
// auflistet, verliert dann ganze Teilbäume.
|
|
||||||
func collectLevel(manifestEntries []repository.ManifestEntry, currentPath string) []backupContentEntry {
|
|
||||||
prefix := ""
|
|
||||||
if currentPath != "" {
|
|
||||||
prefix = currentPath + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verzeichnisse werden über ihre Kinder erkannt und dabei gleich
|
|
||||||
// aufsummiert: Ein Betreiber will vor dem Zurückholen wissen, wie viel an
|
|
||||||
// einem Ordner hängt.
|
|
||||||
directories := make(map[string]*backupContentEntry)
|
|
||||||
files := make([]backupContentEntry, 0, 32)
|
|
||||||
|
|
||||||
for _, manifestEntry := range manifestEntries {
|
|
||||||
entryPath := strings.Trim(manifestEntry.Path, "/")
|
|
||||||
|
|
||||||
if prefix != "" && !strings.HasPrefix(entryPath, prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
remainder := strings.TrimPrefix(entryPath, prefix)
|
|
||||||
if remainder == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
separatorIndex := strings.Index(remainder, "/")
|
|
||||||
|
|
||||||
if separatorIndex < 0 {
|
|
||||||
// Direktes Kind dieser Ebene.
|
|
||||||
if manifestEntry.EntryType == "directory" {
|
|
||||||
directoryPath := prefix + remainder
|
|
||||||
|
|
||||||
if _, exists := directories[remainder]; !exists {
|
|
||||||
directories[remainder] = &backupContentEntry{
|
|
||||||
Name: remainder,
|
|
||||||
Path: directoryPath,
|
|
||||||
IsDirectory: true,
|
|
||||||
EntryType: "directory",
|
|
||||||
Mode: manifestEntry.Mode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, backupContentEntry{
|
|
||||||
Name: remainder,
|
|
||||||
Path: entryPath,
|
|
||||||
IsDirectory: false,
|
|
||||||
EntryType: manifestEntry.EntryType,
|
|
||||||
SizeBytes: manifestEntry.SizeBytes,
|
|
||||||
ModifiedAt: formatOptionalTime(manifestEntry),
|
|
||||||
Mode: manifestEntry.Mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ein Nachfahre: Er belegt, dass es das Verzeichnis gibt, auch wenn
|
|
||||||
// kein eigener Eintrag dafür existiert.
|
|
||||||
directoryName := remainder[:separatorIndex]
|
|
||||||
|
|
||||||
existing, exists := directories[directoryName]
|
|
||||||
if !exists {
|
|
||||||
existing = &backupContentEntry{
|
|
||||||
Name: directoryName,
|
|
||||||
Path: prefix + directoryName,
|
|
||||||
IsDirectory: true,
|
|
||||||
EntryType: "directory",
|
|
||||||
}
|
|
||||||
directories[directoryName] = existing
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.ChildCount++
|
|
||||||
existing.TotalBytes += manifestEntry.SizeBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
entries := make([]backupContentEntry, 0, len(directories)+len(files))
|
|
||||||
|
|
||||||
for _, directoryEntry := range directories {
|
|
||||||
entries = append(entries, *directoryEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, files...)
|
|
||||||
|
|
||||||
// Verzeichnisse zuerst, dann alphabetisch — die Reihenfolge, die jeder
|
|
||||||
// Dateimanager verwendet.
|
|
||||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
|
||||||
if entries[firstIndex].IsDirectory != entries[secondIndex].IsDirectory {
|
|
||||||
return entries[firstIndex].IsDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatOptionalTime gibt einen Zeitstempel aus, sofern gesetzt.
|
|
||||||
func formatOptionalTime(manifestEntry repository.ManifestEntry) string {
|
|
||||||
if manifestEntry.ModifiedAt.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifestEntry.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
// directoryIsWritable prüft durch einen echten Schreibversuch.
|
|
||||||
//
|
|
||||||
// Die Rechtebits zu lesen genügt nicht: Unter `ProtectSystem=strict` ist das
|
|
||||||
// Dateisystem für den Dienst außerhalb weniger Pfade schreibgeschützt, und
|
|
||||||
// davon steht nichts im Modus. Genau diese Lücke hat dazu geführt, dass eine
|
|
||||||
// Wiederherstellung nach `/opt/test` mit „permission denied" endete, obwohl
|
|
||||||
// das Verzeichnis dem Anschein nach beschreibbar war.
|
|
||||||
func directoryIsWritable(directoryPath string) bool {
|
|
||||||
probeFile, createError := os.CreateTemp(directoryPath, ".syncova-schreibprobe-*")
|
|
||||||
if createError != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
probeName := probeFile.Name()
|
|
||||||
|
|
||||||
_ = probeFile.Close()
|
|
||||||
_ = os.Remove(probeName)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// writableSuggestions nennt Orte, an denen der Dienst nachweislich schreiben darf.
|
|
||||||
//
|
|
||||||
// Ohne diese Liste sucht ein Betreiber im Blindflug: Die meisten Verzeichnisse
|
|
||||||
// eines gehärteten Systems scheiden aus, und welche übrig bleiben, hängt an der
|
|
||||||
// systemd-Einheit — nicht an etwas, das man dem Dateisystem ansieht.
|
|
||||||
func writableSuggestions(targetGuard *recovery.TargetGuard) []string {
|
|
||||||
// Reihenfolge ist Absicht: Zuerst die Flaeche, die `setup.sh` anlegt und
|
|
||||||
// in ReadWritePaths eintraegt. `/var/lib` steht bewusst nicht dabei — es
|
|
||||||
// ist im Zielschutz gesperrt.
|
|
||||||
candidates := []string{
|
|
||||||
"/srv/syncova-restore",
|
|
||||||
"/srv",
|
|
||||||
"/var/tmp",
|
|
||||||
"/home",
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions := make([]string, 0, len(candidates))
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if targetGuard != nil {
|
|
||||||
if guardError := targetGuard.Validate(candidate); guardError != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if directoryIsWritable(candidate) {
|
|
||||||
suggestions = append(suggestions, candidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return suggestions
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/syncova/syncova/packages/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum
|
|
||||||
// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen.
|
|
||||||
//
|
|
||||||
// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als
|
|
||||||
// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer
|
|
||||||
// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze
|
|
||||||
// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar.
|
|
||||||
func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) {
|
|
||||||
manifestEntries := []repository.ManifestEntry{
|
|
||||||
// Kein Eintrag fuer "berichte" selbst.
|
|
||||||
{Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900},
|
|
||||||
{Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100},
|
|
||||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 6},
|
|
||||||
}
|
|
||||||
|
|
||||||
rootLevel := collectLevel(manifestEntries, "")
|
|
||||||
|
|
||||||
if len(rootLevel) != 2 {
|
|
||||||
testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verzeichnisse stehen vorn.
|
|
||||||
if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" {
|
|
||||||
testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem
|
|
||||||
// Zurueckholen wissen, wie viel an einem Ordner haengt.
|
|
||||||
if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 {
|
|
||||||
testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte",
|
|
||||||
rootLevel[0].ChildCount, rootLevel[0].TotalBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Und eine Ebene tiefer erscheinen die Jahresordner.
|
|
||||||
deeperLevel := collectLevel(manifestEntries, "berichte")
|
|
||||||
|
|
||||||
if len(deeperLevel) != 2 {
|
|
||||||
testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest.
|
|
||||||
//
|
|
||||||
// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine
|
|
||||||
// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat.
|
|
||||||
func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) {
|
|
||||||
manifestEntries := []repository.ManifestEntry{
|
|
||||||
{Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1},
|
|
||||||
{Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
level := collectLevel(manifestEntries, "dokumente")
|
|
||||||
|
|
||||||
if len(level) != 1 || level[0].Name != "a.txt" {
|
|
||||||
testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei.
|
|
||||||
func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) {
|
|
||||||
modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
level := collectLevel([]repository.ManifestEntry{
|
|
||||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime},
|
|
||||||
}, "")
|
|
||||||
|
|
||||||
if len(level) != 1 {
|
|
||||||
testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Der Pfad ist genau der Wert, den eine Wiederherstellung als
|
|
||||||
// `path_prefix` erwartet — eine Abweichung faellt sonst erst beim
|
|
||||||
// Zurueckschreiben auf.
|
|
||||||
if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" {
|
|
||||||
testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
if level[0].ModifiedAt != "2026-08-18T10:00:00Z" {
|
|
||||||
testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -122,5 +122,3 @@ POST /api/v1/users users.write
|
|||||||
POST /api/v1/users/{id}/mfa/disable users.write
|
POST /api/v1/users/{id}/mfa/disable users.write
|
||||||
POST /api/v1/verification verification.write
|
POST /api/v1/verification verification.write
|
||||||
POST /api/v1/verification/{id}/cancel verification.write
|
POST /api/v1/verification/{id}/cancel verification.write
|
||||||
GET /api/v1/filesystem/browse restores.read
|
|
||||||
GET /api/v1/backups/{id}/contents restores.read
|
|
||||||
|
|||||||
@ -89,14 +89,6 @@ type jobRequest struct {
|
|||||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||||
// BackupMode ist "incremental" (Standard) oder "always_full".
|
|
||||||
BackupMode string `json:"backup_mode,omitempty"`
|
|
||||||
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
|
|
||||||
//
|
|
||||||
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
|
|
||||||
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
|
|
||||||
// unterscheiden.
|
|
||||||
FullBackupWeekday *int `json:"full_backup_weekday,omitempty"`
|
|
||||||
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
||||||
MaximumConcurrency int `json:"max_concurrency,omitempty"`
|
MaximumConcurrency int `json:"max_concurrency,omitempty"`
|
||||||
}
|
}
|
||||||
@ -135,14 +127,6 @@ type jobResponse struct {
|
|||||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||||
// BackupMode ist "incremental" (Standard) oder "always_full".
|
|
||||||
BackupMode string `json:"backup_mode,omitempty"`
|
|
||||||
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
|
|
||||||
//
|
|
||||||
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
|
|
||||||
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
|
|
||||||
// unterscheiden.
|
|
||||||
FullBackupWeekday *int `json:"full_backup_weekday,omitempty"`
|
|
||||||
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
||||||
MaximumConcurrency int `json:"max_concurrency"`
|
MaximumConcurrency int `json:"max_concurrency"`
|
||||||
// NextRunAt ist der nächste Zeitpunkt in UTC.
|
// NextRunAt ist der nächste Zeitpunkt in UTC.
|
||||||
|
|||||||
@ -57,8 +57,6 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job,
|
|||||||
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
|
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
|
||||||
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
||||||
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
||||||
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
|
|
||||||
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
|
|
||||||
MaximumConcurrency: maximumConcurrency,
|
MaximumConcurrency: maximumConcurrency,
|
||||||
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
||||||
CreatedBy: &creatorID,
|
CreatedBy: &creatorID,
|
||||||
@ -154,8 +152,6 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse {
|
|||||||
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
||||||
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
||||||
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
||||||
BackupMode: string(sourceJob.BackupMode),
|
|
||||||
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
|
|
||||||
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
||||||
NextRunAt: sourceJob.NextRunAt,
|
NextRunAt: sourceJob.NextRunAt,
|
||||||
LastRunAt: sourceJob.LastRunAt,
|
LastRunAt: sourceJob.LastRunAt,
|
||||||
@ -194,33 +190,3 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
|
|||||||
|
|
||||||
return scheduleData
|
return scheduleData
|
||||||
}
|
}
|
||||||
|
|
||||||
// weekdayFromPayload uebersetzt einen Wochentag aus der Anfrage.
|
|
||||||
//
|
|
||||||
// Ein Wert ausserhalb von 0..6 wird verworfen statt gekappt: Ein
|
|
||||||
// stillschweigend auf Sonntag gesetzter Montag waere ein Fehler, den niemand
|
|
||||||
// bemerkt — die Vollsicherung liefe dann am falschen Tag.
|
|
||||||
func weekdayFromPayload(requestedWeekday *int) *time.Weekday {
|
|
||||||
if requestedWeekday == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if *requestedWeekday < 0 || *requestedWeekday > 6 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
convertedWeekday := time.Weekday(*requestedWeekday)
|
|
||||||
|
|
||||||
return &convertedWeekday
|
|
||||||
}
|
|
||||||
|
|
||||||
// weekdayToPayload uebersetzt einen Wochentag fuer die Antwort.
|
|
||||||
func weekdayToPayload(storedWeekday *time.Weekday) *int {
|
|
||||||
if storedWeekday == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
convertedValue := int(*storedWeekday)
|
|
||||||
|
|
||||||
return &convertedValue
|
|
||||||
}
|
|
||||||
|
|||||||
@ -377,19 +377,6 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
|
|||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
||||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
||||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
||||||
|
|
||||||
// Blättern in Dateisystem und Backup.
|
|
||||||
//
|
|
||||||
// Beide gehören zur Wiederherstellung und tragen deshalb deren Leserecht:
|
|
||||||
// Wer eine Wiederherstellung vorbereiten darf, muss sehen können, was im
|
|
||||||
// Backup steckt und wohin sich zurückschreiben lässt.
|
|
||||||
browseHandlerInstance := &browseHandler{
|
|
||||||
restoreHandlerReference: restoreHandlerInstance,
|
|
||||||
targetGuard: restoreHandlerInstance.targetGuard,
|
|
||||||
}
|
|
||||||
|
|
||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/filesystem/browse", protected("restores.read", browseHandlerInstance.handleBrowseFilesystem))
|
|
||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/contents", protected("restores.read", browseHandlerInstance.handleBrowseBackupContents))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
||||||
|
|||||||
1954
apps/web/package-lock.json
generated
1954
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,32 +13,8 @@
|
|||||||
"lint": "eslint . --max-warnings 0"
|
"lint": "eslint . --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
||||||
"@radix-ui/react-avatar": "^1.2.6",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.20",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.23",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
|
||||||
"@radix-ui/react-progress": "^1.1.16",
|
|
||||||
"@radix-ui/react-radio-group": "^1.4.7",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
|
||||||
"@radix-ui/react-select": "^2.3.7",
|
|
||||||
"@radix-ui/react-separator": "^1.1.15",
|
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
|
||||||
"@radix-ui/react-switch": "^1.3.7",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.21",
|
|
||||||
"@radix-ui/react-toast": "^1.2.23",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.16",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^1.31.0",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0"
|
||||||
"tailwind-merge": "^3.6.0",
|
|
||||||
"tailwindcss": "^4.3.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
|
|||||||
1117
apps/web/src/App.css
Normal file
1117
apps/web/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,120 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* Wurzelkomponente der Syncova-Oberfläche.
|
* Wurzelkomponente der Syncova-Oberflaeche.
|
||||||
*
|
*
|
||||||
* Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt
|
* Die Anwendung entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und
|
||||||
* letztere auf die Bereiche aus PROMPT.md §28.
|
* verteilt die angemeldete Ansicht auf die Bereiche aus PROMPT.md §28.
|
||||||
*
|
*
|
||||||
* Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf
|
* Der Umgang mit unfertigen Bereichen ist die eine Entscheidung, die diese Datei
|
||||||
* (`jobs/<id>`). Das reicht für diese Konsole und kostet rund fünfzig Zeilen —
|
* traegt: Sie erscheinen im Menue, aber deaktiviert und mit der Angabe, was
|
||||||
* eine Router-Bibliothek wäre hier mehr Abhängigkeit als Nutzen.
|
* fehlt. Ein Menue nur aus fertigen Bereichen verschweigt den Ausbaustand; eines
|
||||||
|
* mit leeren Masken taeuscht ihn vor (PROMPT.md §139).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { setAccessTokenProvider } from './api/client';
|
import { setAccessTokenProvider } from './api/client';
|
||||||
import { AppShell } from './components/layout/AppShell';
|
|
||||||
import { ErrorBoundary } from './components/layout/ErrorBoundary';
|
|
||||||
import { LoadingState, ToastProvider } from './components/ui';
|
|
||||||
import {
|
|
||||||
fetchCurrentUser,
|
|
||||||
getAccessToken,
|
|
||||||
hasResumableSession,
|
|
||||||
logout,
|
|
||||||
} from './features/auth/authApi';
|
|
||||||
import { useSession } from './features/auth/useSession';
|
|
||||||
import { LoginPage } from './features/auth/LoginPage';
|
|
||||||
import { AlertsPage } from './features/alerts/AlertsPage';
|
|
||||||
import { DashboardPage } from './features/dashboard/DashboardPage';
|
|
||||||
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
|
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
|
||||||
|
import { JobsPanel } from './features/jobs/JobsPanel';
|
||||||
|
import { LoginPage } from './features/auth/LoginPage';
|
||||||
|
import { getAccessToken, logout } from './features/auth/authApi';
|
||||||
|
import { DashboardPage } from './features/dashboard/DashboardPage';
|
||||||
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
|
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
|
||||||
import { RolesPage } from './features/administration/RolesPage';
|
import {
|
||||||
import { SettingsPage } from './features/administration/SettingsPage';
|
AgentsPage,
|
||||||
import { UsersPage } from './features/administration/UsersPage';
|
EventsPage,
|
||||||
import { EventsPage } from './features/administration/EventsPage';
|
RepositoriesPage,
|
||||||
import { AgentsPage } from './features/infrastructure/AgentsPage';
|
RestoresPage,
|
||||||
import { ProxmoxPage } from './features/infrastructure/ProxmoxPage';
|
} from './features/inventory/InventoryPages';
|
||||||
import { ProtectedSystemsPage } from './features/infrastructure/ProtectedSystemsPage';
|
import { RolesPage, UsersPage } from './features/identity/IdentityPages';
|
||||||
import { RepositoriesPage } from './features/repositories/RepositoriesPage';
|
|
||||||
import { RetentionPage } from './features/repositories/RetentionPage';
|
|
||||||
import { BackupDetailPage } from './features/backups/BackupDetailPage';
|
|
||||||
import { VerificationPage } from './features/backups/VerificationPage';
|
|
||||||
import { RestoresPage } from './features/restores/RestoresPage';
|
|
||||||
import { JobDetailPage } from './features/jobs/JobDetailPage';
|
|
||||||
import { JobsPage } from './features/jobs/JobsPage';
|
|
||||||
import { MetricsPage } from './features/metrics/MetricsPage';
|
import { MetricsPage } from './features/metrics/MetricsPage';
|
||||||
|
import { AlertsPage } from './features/alerts/AlertsPage';
|
||||||
import { ReportsPage } from './features/reports/ReportsPage';
|
import { ReportsPage } from './features/reports/ReportsPage';
|
||||||
import { SecurityPage } from './features/security/SecurityPage';
|
import { SecurityPage } from './features/security/SecurityPage';
|
||||||
|
import { NavigationSidebar } from './navigation/NavigationSidebar';
|
||||||
import { UnavailablePage } from './navigation/UnavailablePage';
|
import { UnavailablePage } from './navigation/UnavailablePage';
|
||||||
import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages';
|
import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages';
|
||||||
import { useCurrentPage } from './navigation/useCurrentPage';
|
import { useCurrentPage } from './navigation/useCurrentPage';
|
||||||
import type { CurrentUser } from './types/auth';
|
import type { CurrentUser } from './types/auth';
|
||||||
import './styles/theme.css';
|
import './App.css';
|
||||||
|
|
||||||
// Der API-Client erhält seinen Tokenzugriff einmalig beim Laden des Moduls.
|
// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls.
|
||||||
setAccessTokenProvider(getAccessToken);
|
setAccessTokenProvider(getAccessToken);
|
||||||
|
|
||||||
|
/** Baut das Grundlayout der Anwendung. */
|
||||||
export function App(): React.JSX.Element {
|
export function App(): React.JSX.Element {
|
||||||
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
|
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
|
||||||
const { currentPageId, navigateToPage } = useCurrentPage();
|
const { currentPageId, navigateToPage } = useCurrentPage();
|
||||||
|
|
||||||
// Das gerade geöffnete Objekt innerhalb einer Seite, etwa ein Auftrag.
|
|
||||||
const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Beim Start wird geprüft, ob eine Sitzung fortgesetzt werden kann. Solange
|
|
||||||
// das läuft, erscheint weder Anmeldemaske noch Inhalt — sonst blitzte nach
|
|
||||||
// jedem Neuladen kurz die Anmeldung auf.
|
|
||||||
const [isResuming, setIsResuming] = useState(() => hasResumableSession());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResuming) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
void fetchCurrentUser()
|
|
||||||
.then((resumedUser) => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setAuthenticatedUser(resumedUser);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Der Server hat das Token abgelehnt — etwa nach einer Kontosperre.
|
|
||||||
// Dann gilt die Sitzung als beendet, unabhängig von der lokalen Uhr.
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setIsResuming(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [isResuming]);
|
|
||||||
|
|
||||||
const handleSessionExpired = useCallback(() => {
|
|
||||||
setAuthenticatedUser(null);
|
|
||||||
void logout();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sessionClock = useSession(authenticatedUser !== null, handleSessionExpired);
|
|
||||||
|
|
||||||
if (isResuming) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-page">
|
|
||||||
<LoadingState label="Sitzung wird fortgesetzt …" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authenticatedUser === null) {
|
if (authenticatedUser === null) {
|
||||||
return (
|
return <LoginPage onAuthenticated={setAuthenticatedUser} />;
|
||||||
<ToastProvider>
|
|
||||||
<LoginPage onAuthenticated={setAuthenticatedUser} />
|
|
||||||
</ToastProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Meldet den Benutzer ab und kehrt zur Anmeldemaske zurueck. */
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await logout();
|
await logout();
|
||||||
setAuthenticatedUser(null);
|
setAuthenticatedUser(null);
|
||||||
@ -122,190 +56,121 @@ export function App(): React.JSX.Element {
|
|||||||
|
|
||||||
const grantedPermissions = authenticatedUser.permissions ?? [];
|
const grantedPermissions = authenticatedUser.permissions ?? [];
|
||||||
|
|
||||||
/** Wechselt die Seite und schließt ein offenes Objekt. */
|
|
||||||
function handleNavigate(pageIdentifier: string): void {
|
|
||||||
setOpenObjectIdentifier(null);
|
|
||||||
navigateToPage(pageIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<div className="app-shell">
|
||||||
<AppShell
|
<header className="app-shell__header">
|
||||||
currentPageId={currentPageId}
|
<span className="app-shell__brand">Syncova</span>
|
||||||
onNavigate={handleNavigate}
|
|
||||||
grantedPermissions={grantedPermissions}
|
<div className="app-shell__account">
|
||||||
userName={authenticatedUser.username}
|
<span className="app-shell__username">{authenticatedUser.username}</span>
|
||||||
sessionClock={sessionClock}
|
|
||||||
onLogout={() => void handleLogout()}
|
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird
|
||||||
|
benannt, statt ihn zu verschweigen (PROMPT.md §90). */}
|
||||||
|
{!authenticatedUser.mfa_enabled && (
|
||||||
|
<span
|
||||||
|
className="app-shell__warning"
|
||||||
|
title="Fuer dieses Konto ist kein zweiter Faktor eingerichtet."
|
||||||
>
|
>
|
||||||
{/* Die Grenze sitzt um den Inhalt: Ein Fehler auf einer Seite lässt
|
MFA fehlt
|
||||||
Menü und Kopfzeile stehen, statt die Konsole zu schwärzen. */}
|
</span>
|
||||||
<ErrorBoundary resetKey={`${currentPageId}|${openObjectIdentifier ?? ''}`}>
|
)}
|
||||||
<CurrentPageContent
|
|
||||||
|
<button className="app-shell__logout" type="button" onClick={() => void handleLogout()}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="app-shell__body">
|
||||||
|
<NavigationSidebar
|
||||||
currentPageId={currentPageId}
|
currentPageId={currentPageId}
|
||||||
grantedPermissions={grantedPermissions}
|
grantedPermissions={grantedPermissions}
|
||||||
currentUser={authenticatedUser}
|
onNavigate={navigateToPage}
|
||||||
openObjectIdentifier={openObjectIdentifier}
|
|
||||||
onOpenObject={setOpenObjectIdentifier}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
|
||||||
</AppShell>
|
<main className="app-shell__main">
|
||||||
</ToastProvider>
|
<CurrentPageContent currentPageId={currentPageId} grantedPermissions={grantedPermissions} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt den Inhalt der gewählten Seite. */
|
/** Eigenschaften des Seiteninhalts. */
|
||||||
|
interface CurrentPageContentProperties {
|
||||||
|
/** Bezeichner der angezeigten Seite. */
|
||||||
|
readonly currentPageId: string;
|
||||||
|
/** Berechtigungen des angemeldeten Benutzers. */
|
||||||
|
readonly grantedPermissions: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt den Inhalt der gewaehlten Seite. */
|
||||||
function CurrentPageContent({
|
function CurrentPageContent({
|
||||||
currentPageId,
|
currentPageId,
|
||||||
grantedPermissions,
|
grantedPermissions,
|
||||||
currentUser,
|
}: CurrentPageContentProperties): React.JSX.Element {
|
||||||
openObjectIdentifier,
|
|
||||||
onOpenObject,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
readonly currentPageId: string;
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly currentUser: CurrentUser;
|
|
||||||
readonly openObjectIdentifier: string | null;
|
|
||||||
readonly onOpenObject: (objectIdentifier: string | null) => void;
|
|
||||||
readonly onNavigate: (pageIdentifier: string) => void;
|
|
||||||
}): React.JSX.Element {
|
|
||||||
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
|
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
|
||||||
|
|
||||||
if (pageDefinition === undefined) {
|
if (pageDefinition === undefined) {
|
||||||
return <DashboardPage />;
|
return <DashboardPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne
|
// Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne
|
||||||
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche
|
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche
|
||||||
// Auskunft (PROMPT.md §42).
|
// Auskunft (PROMPT.md §42).
|
||||||
if (!mayViewPage(pageDefinition, grantedPermissions)) {
|
if (!mayViewPage(pageDefinition, grantedPermissions)) {
|
||||||
return (
|
return (
|
||||||
<UnavailablePage
|
<section className="page">
|
||||||
title={pageDefinition.label}
|
<header className="page__header">
|
||||||
reason="Ihrer Rolle fehlt die Berechtigung für diesen Bereich. Die Prüfung erfolgt auf dem Server; diese Anzeige verhindert nur den Umweg über eine Fehlermeldung."
|
<h1 className="page__title">{pageDefinition.label}</h1>
|
||||||
/>
|
</header>
|
||||||
|
|
||||||
|
<div className="notice notice--warning">
|
||||||
|
<p className="notice__text">
|
||||||
|
Fuer diesen Bereich fehlt die Berechtigung {pageDefinition.requiredPermission}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pageDefinition.available) {
|
if (!pageDefinition.available) {
|
||||||
return (
|
return <UnavailablePage pageDefinition={pageDefinition} />;
|
||||||
<UnavailablePage
|
|
||||||
title={pageDefinition.label}
|
|
||||||
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfügbar.'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (pageDefinition.id) {
|
switch (pageDefinition.id) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardPage
|
<DashboardPage />
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
<div className="mt-4">
|
|
||||||
<SystemHealthPanel />
|
<SystemHealthPanel />
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'jobs':
|
case 'jobs':
|
||||||
return openObjectIdentifier ? (
|
return <JobsPanel />;
|
||||||
<JobDetailPage
|
|
||||||
jobIdentifier={openObjectIdentifier}
|
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
onBack={() => onOpenObject(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<JobsPage grantedPermissions={grantedPermissions} onOpenJob={onOpenObject} />
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'recovery-points':
|
|
||||||
return openObjectIdentifier ? (
|
|
||||||
<BackupDetailPage
|
|
||||||
backupIdentifier={openObjectIdentifier}
|
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
onBack={() => onOpenObject(null)}
|
|
||||||
onDeleted={() => onOpenObject(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<RecoveryPointsPage onOpenBackup={onOpenObject} />
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'restores':
|
|
||||||
return <RestoresPage grantedPermissions={grantedPermissions} />;
|
|
||||||
|
|
||||||
case 'verification':
|
|
||||||
return openObjectIdentifier ? (
|
|
||||||
<BackupDetailPage
|
|
||||||
backupIdentifier={openObjectIdentifier}
|
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
onBack={() => onOpenObject(null)}
|
|
||||||
onDeleted={() => onOpenObject(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VerificationPage
|
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
onOpenBackup={onOpenObject}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'repositories':
|
|
||||||
return <RepositoriesPage grantedPermissions={grantedPermissions} />;
|
|
||||||
|
|
||||||
case 'retention':
|
|
||||||
return <RetentionPage grantedPermissions={grantedPermissions} />;
|
|
||||||
|
|
||||||
case 'agents':
|
|
||||||
return <AgentsPage grantedPermissions={grantedPermissions} />;
|
|
||||||
|
|
||||||
case 'proxmox':
|
|
||||||
return <ProxmoxPage grantedPermissions={grantedPermissions} />;
|
|
||||||
|
|
||||||
case 'protected-systems':
|
|
||||||
return <ProtectedSystemsPage />;
|
|
||||||
|
|
||||||
case 'events':
|
|
||||||
return <EventsPage />;
|
|
||||||
|
|
||||||
case 'alerts':
|
|
||||||
return <AlertsPage />;
|
|
||||||
|
|
||||||
case 'metrics':
|
case 'metrics':
|
||||||
return <MetricsPage />;
|
return <MetricsPage />;
|
||||||
|
case 'alerts':
|
||||||
case 'reports':
|
return <AlertsPage />;
|
||||||
return <ReportsPage />;
|
|
||||||
|
|
||||||
case 'security':
|
case 'security':
|
||||||
return <SecurityPage />;
|
return <SecurityPage />;
|
||||||
|
case 'reports':
|
||||||
|
return <ReportsPage />;
|
||||||
|
case 'recovery-points':
|
||||||
|
return <RecoveryPointsPage />;
|
||||||
|
case 'restores':
|
||||||
|
return <RestoresPage />;
|
||||||
|
case 'repositories':
|
||||||
|
return <RepositoriesPage />;
|
||||||
|
case 'agents':
|
||||||
|
return <AgentsPage />;
|
||||||
|
case 'events':
|
||||||
|
return <EventsPage />;
|
||||||
case 'users':
|
case 'users':
|
||||||
return <UsersPage grantedPermissions={grantedPermissions} />;
|
return <UsersPage />;
|
||||||
|
|
||||||
case 'roles':
|
case 'roles':
|
||||||
return <RolesPage grantedPermissions={grantedPermissions} />;
|
return <RolesPage />;
|
||||||
|
|
||||||
case 'settings':
|
|
||||||
return (
|
|
||||||
<SettingsPage
|
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das
|
return <DashboardPage />;
|
||||||
// ist kein Fehler, sondern der Ausbaustand — und wird als solcher
|
|
||||||
// benannt, statt eine leere Maske zu zeigen.
|
|
||||||
return (
|
|
||||||
<UnavailablePage
|
|
||||||
title={pageDefinition.label}
|
|
||||||
reason="Dieser Bereich wird gerade gebaut. Die zugehörigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberfläche."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client';
|
import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client';
|
||||||
|
|
||||||
/** Baut eine Antwort, wie sie das Backend liefern würde. */
|
/** Baut eine Antwort, wie sie das Backend liefern wuerde. */
|
||||||
function buildJsonResponse(responseBody: unknown, statusCode: number): Response {
|
function buildJsonResponse(responseBody: unknown, statusCode: number): Response {
|
||||||
return new Response(JSON.stringify(responseBody), {
|
return new Response(JSON.stringify(responseBody), {
|
||||||
status: statusCode,
|
status: statusCode,
|
||||||
@ -14,7 +14,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('requestApi', () => {
|
describe('requestApi', () => {
|
||||||
it('gibt die Nutzlast aus der Standard-Hülle zurück', async () => {
|
it('gibt die Nutzlast aus der Standard-Huelle zurueck', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue(
|
vi.fn().mockResolvedValue(
|
||||||
@ -35,7 +35,7 @@ describe('requestApi', () => {
|
|||||||
|
|
||||||
await requestApi('/health');
|
await requestApi('/health');
|
||||||
|
|
||||||
// Ohne Correlation ID ließe sich eine Operation nicht Ende-zu-Ende verfolgen.
|
// Ohne Correlation ID liesse sich eine Operation nicht Ende-zu-Ende verfolgen.
|
||||||
const firstCall = fetchMock.mock.calls[0];
|
const firstCall = fetchMock.mock.calls[0];
|
||||||
expect(firstCall).toBeDefined();
|
expect(firstCall).toBeDefined();
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ describe('requestApi', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('meldet eine nicht erreichbare API verständlich', async () => {
|
it('meldet eine nicht erreichbare API verstaendlich', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||||
|
|
||||||
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
|
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
|
||||||
@ -79,24 +79,24 @@ describe('requestApi', () => {
|
|||||||
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
|
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200)));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200)));
|
||||||
|
|
||||||
// Eine Antwort außerhalb des Vertrags darf nicht stillschweigend
|
// Eine Antwort ausserhalb des Vertrags darf nicht stillschweigend
|
||||||
// als leeres Ergebnis interpretiert werden (PROMPT.md §140).
|
// als leeres Ergebnis interpretiert werden (PROMPT.md §140).
|
||||||
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lehnt eine Fehlerantwort ohne Fehlerkörper ab', async () => {
|
it('lehnt eine Fehlerantwort ohne Fehlerkoerper ab', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500)));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500)));
|
||||||
|
|
||||||
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reicht einen Abbruch unverändert durch', async () => {
|
it('reicht einen Abbruch unveraendert durch', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
|
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ein Abbruch ist Folge des Aufräumens und kein Fehlerfall.
|
// Ein Abbruch ist Folge des Aufraeumens und kein Fehlerfall.
|
||||||
await expect(requestApi('/health')).rejects.toSatisfy(
|
await expect(requestApi('/health')).rejects.toSatisfy(
|
||||||
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
|
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP-Client für die Syncova-API.
|
* HTTP-Client fuer die Syncova-API.
|
||||||
*
|
*
|
||||||
* Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als
|
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
|
||||||
* ApiError. Aufrufer müssen sich damit nicht mit HTTP-Details befassen und es
|
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
|
||||||
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
|
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
|
||||||
* (PROMPT.md §140: keine stillen Fehler).
|
* (PROMPT.md §140: keine stillen Fehler).
|
||||||
*/
|
*/
|
||||||
@ -12,24 +12,14 @@ import type { ErrorResponse, SuccessResponse } from '../types/api';
|
|||||||
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
|
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
|
||||||
const API_BASE_PATH = '/api/v1';
|
const API_BASE_PATH = '/api/v1';
|
||||||
|
|
||||||
/** Header, über den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
|
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
|
||||||
const CORRELATION_ID_HEADER = 'X-Correlation-ID';
|
const CORRELATION_ID_HEADER = 'X-Correlation-ID';
|
||||||
|
|
||||||
/**
|
|
||||||
* Header, der eine Anfrage genau einmal wirken lässt (SYNCOVA_API.md §5).
|
|
||||||
*
|
|
||||||
* Er gehört an alle anlegenden und zerstörenden Aufrufe. Ohne ihn erzeugt ein
|
|
||||||
* Doppelklick oder ein wiederholter Versuch nach einer Zeitüberschreitung zwei
|
|
||||||
* Aufträge — und bei einer Wiederherstellung zwei gleichzeitige Läufe in
|
|
||||||
* dasselbe Ziel.
|
|
||||||
*/
|
|
||||||
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fehler einer API-Anfrage.
|
* Fehler einer API-Anfrage.
|
||||||
*
|
*
|
||||||
* Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
|
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
|
||||||
* einen Vorfall gegenüber dem Betreiber eindeutig benennen kann.
|
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
|
||||||
*/
|
*/
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
/** Stabiler maschinenlesbarer Fehlercode. */
|
/** Stabiler maschinenlesbarer Fehlercode. */
|
||||||
@ -60,8 +50,8 @@ export class ApiError extends Error {
|
|||||||
/**
|
/**
|
||||||
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
|
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
|
||||||
*
|
*
|
||||||
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhält sie über
|
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
|
||||||
* diese Funktion. Andernfalls entstünde ein Zirkelbezug zwischen dem Client und
|
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
|
||||||
* dem Anmeldemodul, das seinerseits den Client verwendet.
|
* dem Anmeldemodul, das seinerseits den Client verwendet.
|
||||||
*/
|
*/
|
||||||
let accessTokenProvider: () => string | null = () => null;
|
let accessTokenProvider: () => string | null = () => null;
|
||||||
@ -71,24 +61,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void
|
|||||||
accessTokenProvider = tokenProvider;
|
accessTokenProvider = tokenProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fehlercode für eine nicht erreichbare API. */
|
/** Fehlercode fuer eine nicht erreichbare API. */
|
||||||
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
|
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
|
||||||
|
|
||||||
/** Fehlercode für eine unverständliche Antwort. */
|
/** Fehlercode fuer eine unverstaendliche Antwort. */
|
||||||
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
|
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt eine Correlation ID für einen Request.
|
* Erzeugt eine Correlation ID fuer einen Request.
|
||||||
*
|
*
|
||||||
* crypto.randomUUID ist in allen unterstützten Browsern verfügbar; der
|
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
|
||||||
* Rückfall deckt ältere Testumgebungen ab.
|
* Rueckfall deckt aeltere Testumgebungen ab.
|
||||||
*/
|
*/
|
||||||
function createCorrelationId(): string {
|
function createCorrelationId(): string {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rückfall ohne kryptografische Garantie - die Correlation ID dient allein
|
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein
|
||||||
// der Nachvollziehbarkeit, nicht der Sicherheit.
|
// der Nachvollziehbarkeit, nicht der Sicherheit.
|
||||||
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
|
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
|
||||||
}
|
}
|
||||||
@ -97,22 +87,14 @@ function createCorrelationId(): string {
|
|||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
/** HTTP-Methode; Standard ist GET. */
|
/** HTTP-Methode; Standard ist GET. */
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
/** Optionaler Anfragekörper, der als JSON gesendet wird. */
|
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
/** Signal zum Abbrechen der Anfrage. */
|
/** Signal zum Abbrechen der Anfrage. */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/**
|
|
||||||
* Schlüssel für Idempotenz.
|
|
||||||
*
|
|
||||||
* `true` erzeugt einen zufälligen Schlüssel; eine Zeichenkette wird
|
|
||||||
* unverändert verwendet, damit ein Wiederholungsversuch derselben Handlung
|
|
||||||
* denselben Schlüssel trägt.
|
|
||||||
*/
|
|
||||||
idempotencyKey?: string | true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Führt eine Anfrage gegen die Syncova-API aus.
|
* Fuehrt eine Anfrage gegen die Syncova-API aus.
|
||||||
*
|
*
|
||||||
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
|
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
|
||||||
* @returns Die Nutzlast der Antwort.
|
* @returns Die Nutzlast der Antwort.
|
||||||
@ -134,15 +116,8 @@ export async function requestApi<TPayload>(
|
|||||||
requestHeaders['Content-Type'] = 'application/json';
|
requestHeaders['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestOptions.idempotencyKey !== undefined) {
|
|
||||||
requestHeaders[IDEMPOTENCY_KEY_HEADER] =
|
|
||||||
requestOptions.idempotencyKey === true
|
|
||||||
? createCorrelationId()
|
|
||||||
: requestOptions.idempotencyKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
|
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
|
||||||
// unauthentifiziert - der Server entscheidet dann über den Zugriff.
|
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
|
||||||
const accessToken = accessTokenProvider();
|
const accessToken = accessTokenProvider();
|
||||||
if (accessToken !== null) {
|
if (accessToken !== null) {
|
||||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||||
@ -154,7 +129,7 @@ export async function requestApi<TPayload>(
|
|||||||
method: requestMethod,
|
method: requestMethod,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
|
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
|
||||||
// Die Sitzung läuft über ein Cookie bzw. einen Token desselben Ursprungs.
|
// Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs.
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
||||||
});
|
});
|
||||||
@ -166,13 +141,13 @@ export async function requestApi<TPayload>(
|
|||||||
|
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
code: NETWORK_ERROR_CODE,
|
code: NETWORK_ERROR_CODE,
|
||||||
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.',
|
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
|
||||||
statusCode: 0,
|
statusCode: 0,
|
||||||
requestId: correlationId,
|
requestId: correlationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 204 trägt per Definition keinen Körper.
|
// 204 traegt per Definition keinen Koerper.
|
||||||
if (httpResponse.status === 204) {
|
if (httpResponse.status === 204) {
|
||||||
return undefined as TPayload;
|
return undefined as TPayload;
|
||||||
}
|
}
|
||||||
@ -183,19 +158,19 @@ export async function requestApi<TPayload>(
|
|||||||
} catch {
|
} catch {
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
code: MALFORMED_RESPONSE_CODE,
|
code: MALFORMED_RESPONSE_CODE,
|
||||||
message: 'Die Antwort des Servers war unverständlich.',
|
message: 'Die Antwort des Servers war unverstaendlich.',
|
||||||
statusCode: httpResponse.status,
|
statusCode: httpResponse.status,
|
||||||
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maßgeblich ist die Antworthülle, nicht allein der HTTP-Status.
|
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status.
|
||||||
//
|
//
|
||||||
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
|
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
|
||||||
// Betriebszustand, die Hülle den Inhalt. GET /api/v1/health nutzt genau diese
|
// Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese
|
||||||
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
|
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
|
||||||
// aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden
|
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
|
||||||
// Status außerhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
|
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
|
||||||
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
|
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
|
||||||
const errorResponse = parsedBody as Partial<ErrorResponse>;
|
const errorResponse = parsedBody as Partial<ErrorResponse>;
|
||||||
if (errorResponse.error) {
|
if (errorResponse.error) {
|
||||||
@ -224,17 +199,17 @@ export async function requestApi<TPayload>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei von der API herunter.
|
* Laedt eine Datei von der API herunter.
|
||||||
*
|
*
|
||||||
* Sie steht neben requestApi und nicht darin: Eine Datei trägt **keine**
|
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
|
||||||
* Antworthülle, sondern ist der Inhalt selbst. Würde man sie durch requestApi
|
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
|
||||||
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
|
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
|
||||||
* unverständliche Antwort — obwohl alles in Ordnung ist.
|
* unverstaendliche Antwort — obwohl alles in Ordnung ist.
|
||||||
*
|
*
|
||||||
* Der Fehlerfall geht dagegen sehr wohl durch die Hülle: Scheitert die Anfrage,
|
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
|
||||||
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, bevor die
|
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
|
||||||
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
|
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
|
||||||
* „bericht.pdf" im Download-Ordner, und der Anwender sähe statt einer Meldung
|
* „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung
|
||||||
* eine kaputte Datei.
|
* eine kaputte Datei.
|
||||||
*/
|
*/
|
||||||
export async function downloadApiFile(
|
export async function downloadApiFile(
|
||||||
@ -272,7 +247,7 @@ export async function downloadApiFile(
|
|||||||
|
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
code: NETWORK_ERROR_CODE,
|
code: NETWORK_ERROR_CODE,
|
||||||
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.',
|
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
|
||||||
statusCode: 0,
|
statusCode: 0,
|
||||||
requestId: correlationId,
|
requestId: correlationId,
|
||||||
});
|
});
|
||||||
@ -310,8 +285,8 @@ export async function downloadApiFile(
|
|||||||
/**
|
/**
|
||||||
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
|
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
|
||||||
*
|
*
|
||||||
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer wählt einen —
|
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
|
||||||
* ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner.
|
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
|
||||||
*/
|
*/
|
||||||
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
|
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
|
||||||
if (dispositionHeader === null) {
|
if (dispositionHeader === null) {
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Allgemeiner Lade-Hook für API-Ressourcen.
|
* Allgemeiner Lade-Hook fuer API-Ressourcen.
|
||||||
*
|
*
|
||||||
* Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis
|
* Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis
|
||||||
* vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein
|
* vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein
|
||||||
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe
|
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse
|
||||||
* (PROMPT.md §139).
|
* (PROMPT.md §139).
|
||||||
*
|
*
|
||||||
* Der Hook ersetzt die Wiederholung derselben dreißig Zeilen in jeder Seite.
|
* Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite.
|
||||||
* Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit
|
* Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit
|
||||||
* eigener Fehlerbehandlung wäre ein zweiter Ort, an dem sie fehlen kann.
|
* eigener Fehlerbehandlung waere ein zweiter Ort, an dem sie fehlen kann.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
@ -25,17 +25,17 @@ export interface UseApiResourceResult<TPayload> {
|
|||||||
readonly data: TPayload | null;
|
readonly data: TPayload | null;
|
||||||
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */
|
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */
|
||||||
readonly loadError: ApiError | null;
|
readonly loadError: ApiError | null;
|
||||||
/** Lädt die Ressource erneut. */
|
/** Laedt die Ressource erneut. */
|
||||||
readonly reload: () => void;
|
readonly reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Ressource und hält ihren Zustand.
|
* Laedt eine Ressource und haelt ihren Zustand.
|
||||||
*
|
*
|
||||||
* @param loadResource Ladefunktion; sie erhält ein Abbruchsignal.
|
* @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal.
|
||||||
* @param dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner
|
* @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner
|
||||||
* Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge
|
* Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge
|
||||||
* ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt bei jedem
|
* ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem
|
||||||
* Rendern neu.
|
* Rendern neu.
|
||||||
*/
|
*/
|
||||||
export function useApiResource<TPayload>(
|
export function useApiResource<TPayload>(
|
||||||
@ -45,11 +45,11 @@ export function useApiResource<TPayload>(
|
|||||||
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
|
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
|
||||||
const [reloadCounter, setReloadCounter] = useState(0);
|
const [reloadCounter, setReloadCounter] = useState(0);
|
||||||
|
|
||||||
// Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus
|
// Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus
|
||||||
// lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
|
// laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
|
||||||
// Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein
|
// Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein
|
||||||
// setState im Effektkörper löste dagegen eine zweite Renderrunde aus,
|
// setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus,
|
||||||
// bevor überhaupt etwas geladen wurde.
|
// bevor ueberhaupt etwas geladen wurde.
|
||||||
const [loadResult, setLoadResult] = useState<{
|
const [loadResult, setLoadResult] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
data: TPayload | null;
|
data: TPayload | null;
|
||||||
@ -71,7 +71,7 @@ export function useApiResource<TPayload>(
|
|||||||
|
|
||||||
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
|
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
// Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens.
|
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
|
||||||
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -95,14 +95,14 @@ export function useApiResource<TPayload>(
|
|||||||
void loadFromApi();
|
void loadFromApi();
|
||||||
|
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
// loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern
|
// loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern
|
||||||
// neu gebildete Funktion löste sonst eine Endlosschleife aus. Der
|
// neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der
|
||||||
// effectiveKey steuert das Neuladen ausdrücklich.
|
// effectiveKey steuert das Neuladen ausdruecklich.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [effectiveKey]);
|
}, [effectiveKey]);
|
||||||
|
|
||||||
// Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die
|
// Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die
|
||||||
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die
|
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die
|
||||||
// Tabelle also nicht aufblitzen.
|
// Tabelle also nicht aufblitzen.
|
||||||
if (loadResult === null || loadResult.key !== effectiveKey) {
|
if (loadResult === null || loadResult.key !== effectiveKey) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* Haken für schreibende Aufrufe.
|
|
||||||
*
|
|
||||||
* Bisher gab es nur `useApiResource` zum Lesen — was daran lag, dass die
|
|
||||||
* Oberfläche kaum etwas schreiben konnte. Dieser Haken trägt die drei Dinge,
|
|
||||||
* die eine verändernde Handlung in einer Betriebskonsole braucht:
|
|
||||||
*
|
|
||||||
* 1. **Doppelklickschutz.** Solange die Anfrage läuft, wird keine zweite
|
|
||||||
* ausgelöst. Bei einem Sicherungslauf wäre die zweite ein 409, bei einer
|
|
||||||
* Löschung schlimmstenfalls mehr als beabsichtigt.
|
|
||||||
* 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit
|
|
||||||
* der sich ein Vorfall später im Serverprotokoll wiederfinden lässt.
|
|
||||||
* 3. **Kein `setState` nach dem Aushängen.** Ein Dialog, der sich nach dem
|
|
||||||
* Absenden schließt, ist weg, bevor die Antwort da ist.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { ApiError } from './client';
|
|
||||||
|
|
||||||
export interface MutationState {
|
|
||||||
/** Läuft die Anfrage gerade? */
|
|
||||||
readonly isRunning: boolean;
|
|
||||||
/** Fehler des letzten Versuchs, sonst null. */
|
|
||||||
readonly error: ApiError | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MutationResult<TArguments extends unknown[], TResult> {
|
|
||||||
readonly isRunning: boolean;
|
|
||||||
readonly error: ApiError | null;
|
|
||||||
/** Führt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */
|
|
||||||
readonly run: (...callArguments: TArguments) => Promise<TResult | null>;
|
|
||||||
/** Verwirft eine stehende Fehlermeldung. */
|
|
||||||
readonly reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Baut einen Aufrufer für eine verändernde API-Handlung.
|
|
||||||
*
|
|
||||||
* @param performRequest Die eigentliche Anfrage.
|
|
||||||
* @param callbacks Rückmeldungen bei Erfolg und Fehler.
|
|
||||||
*/
|
|
||||||
export function useMutation<TArguments extends unknown[], TResult>(
|
|
||||||
performRequest: (...callArguments: TArguments) => Promise<TResult>,
|
|
||||||
callbacks: {
|
|
||||||
readonly onSuccess?: (result: TResult) => void;
|
|
||||||
readonly onError?: (error: ApiError) => void;
|
|
||||||
} = {},
|
|
||||||
): MutationResult<TArguments, TResult> {
|
|
||||||
const [mutationState, setMutationState] = useState<MutationState>({
|
|
||||||
isRunning: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Die Rückmeldungen liegen in einer Referenz, damit `run` seine Identität
|
|
||||||
// behält. Sonst löste jede Renderrunde einen neuen Effekt aus, der `run`
|
|
||||||
// in seiner Abhängigkeitsliste hat.
|
|
||||||
const callbacksReference = useRef(callbacks);
|
|
||||||
|
|
||||||
// Die Zuweisung gehört in einen Effekt, nicht in den Renderkörper: Während
|
|
||||||
// des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React
|
|
||||||
// darf ein Rendern verwerfen oder zweimal ausführen.
|
|
||||||
useEffect(() => {
|
|
||||||
callbacksReference.current = callbacks;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMountedReference = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedReference.current = true;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMountedReference.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const run = useCallback(
|
|
||||||
async (...callArguments: TArguments): Promise<TResult | null> => {
|
|
||||||
setMutationState({ isRunning: true, error: null });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await performRequest(...callArguments);
|
|
||||||
|
|
||||||
if (isMountedReference.current) {
|
|
||||||
setMutationState({ isRunning: false, error: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Rückmeldung läuft auch dann, wenn die Komponente weg ist: Ein
|
|
||||||
// Dialog schließt sich nach dem Absenden, und die Liste dahinter soll
|
|
||||||
// sich trotzdem aktualisieren.
|
|
||||||
callbacksReference.current.onSuccess?.(result);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (caughtError) {
|
|
||||||
// Ein Abbruch ist kein Fehler des Anwenders.
|
|
||||||
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
|
||||||
if (isMountedReference.current) {
|
|
||||||
setMutationState({ isRunning: false, error: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiError =
|
|
||||||
caughtError instanceof ApiError
|
|
||||||
? caughtError
|
|
||||||
: new ApiError({
|
|
||||||
code: 'UNEXPECTED_ERROR',
|
|
||||||
message:
|
|
||||||
caughtError instanceof Error
|
|
||||||
? caughtError.message
|
|
||||||
: 'Ein unerwarteter Fehler ist aufgetreten.',
|
|
||||||
statusCode: 0,
|
|
||||||
requestId: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMountedReference.current) {
|
|
||||||
setMutationState({ isRunning: false, error: apiError });
|
|
||||||
}
|
|
||||||
|
|
||||||
callbacksReference.current.onError?.(apiError);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[performRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setMutationState({ isRunning: false, error: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRunning: mutationState.isRunning,
|
|
||||||
error: mutationState.error,
|
|
||||||
run,
|
|
||||||
reset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bekannte Lagen samt Abhilfe. */
|
|
||||||
const errorCodeExplanations: Readonly<Record<string, string>> = {
|
|
||||||
// Diese Lage entsteht bei einem zweiten Anstoß und ist kein Fehler des
|
|
||||||
// Anwenders — der Auftrag läuft bereits.
|
|
||||||
JOB_ALREADY_RUNNING:
|
|
||||||
'Dieser Auftrag läuft bereits. Ein zweiter Lauf würde in dieselbe Kette schreiben.',
|
|
||||||
RESTORE_ALREADY_RUNNING:
|
|
||||||
'In dieses Ziel läuft bereits eine Wiederherstellung. Zwei gleichzeitige würden sich gegenseitig überschreiben.',
|
|
||||||
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprüft.',
|
|
||||||
TARGET_NOT_EMPTY:
|
|
||||||
'Das Zielverzeichnis ist nicht leer. Überschreiben verlangt die ausdrückliche Bestätigung.',
|
|
||||||
RESTORE_TARGET_FORBIDDEN:
|
|
||||||
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurückgeschrieben.',
|
|
||||||
LAST_ADMINISTRATOR:
|
|
||||||
'Das ist der letzte Administrator. Er lässt sich weder löschen noch deaktivieren — sonst wäre die Anlage nicht mehr verwaltbar.',
|
|
||||||
BUILTIN_ROLE_IMMUTABLE:
|
|
||||||
'Mitgelieferte Rollen sind unveränderlich. Eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.',
|
|
||||||
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Aufträge oder Sicherungen.',
|
|
||||||
REPOSITORY_LOCKED:
|
|
||||||
'Das Repository ist gesperrt — vermutlich läuft eine Sicherung. Hält der Zustand an, hilft "syncova-repo break-lock".',
|
|
||||||
REPOSITORY_FULL:
|
|
||||||
'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf würde die Lage verschärfen.',
|
|
||||||
IMMUTABLE_UNTIL:
|
|
||||||
'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und lässt sich bis zum Fristende nicht löschen.',
|
|
||||||
LEGAL_HOLD:
|
|
||||||
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
|
|
||||||
SERVICE_UNAVAILABLE:
|
|
||||||
'Der Dienst ist derzeit nicht vollständig verfügbar. Das ist kein Problem Ihrer Sitzung.',
|
|
||||||
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung für diese Handlung.',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert einen Satz, den ein Betreiber versteht.
|
|
||||||
*
|
|
||||||
* **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht,
|
|
||||||
* welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als
|
|
||||||
* jede allgemeine Erklärung. Die Tabelle springt nur ein, wenn der Server
|
|
||||||
* keine Meldung mitschickt.
|
|
||||||
*
|
|
||||||
* Das war zunächst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
|
|
||||||
* `SERVICE_UNAVAILABLE` mit der Meldung „Für diesen Bericht ist keine
|
|
||||||
* Sicherheitsprüfung eingerichtet." wurde durch den allgemeinen Satz „Der
|
|
||||||
* Dienst ist derzeit nicht vollständig verfügbar" ersetzt. Der Betreiber
|
|
||||||
* hätte den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
|
|
||||||
* einen Berichts.
|
|
||||||
*/
|
|
||||||
export function describeApiError(apiError: ApiError): string {
|
|
||||||
const serverMessage = apiError.message.trim();
|
|
||||||
|
|
||||||
if (serverMessage !== '') {
|
|
||||||
return serverMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorCodeExplanations[apiError.code] ?? 'Die Anfrage ist fehlgeschlagen.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert die allgemeine Erklärung zu einem Fehlercode, sofern es eine gibt.
|
|
||||||
*
|
|
||||||
* Gedacht als **Ergänzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage
|
|
||||||
* eine bekannte Abhilfe hat, lässt sie sich zusätzlich anzeigen.
|
|
||||||
*/
|
|
||||||
export function explainApiErrorCode(errorCode: string): string | undefined {
|
|
||||||
return errorCodeExplanations[errorCode];
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,92 +0,0 @@
|
|||||||
Copyright (c) 2023 Vercel, in collaboration with basement.studio
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
64
apps/web/src/components/PageState.tsx
Normal file
64
apps/web/src/components/PageState.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Gemeinsame Zustandsanzeigen der Seiten.
|
||||||
|
*
|
||||||
|
* Laden, Fehler und Leere sehen ueberall gleich aus — und vor allem: Leere wird
|
||||||
|
* ausdruecklich als Leere benannt. Eine Tabelle ohne Zeilen und ohne Hinweis
|
||||||
|
* laesst offen, ob es nichts gibt oder ob etwas schiefging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiError } from '../api/client';
|
||||||
|
|
||||||
|
/** Eigenschaften der Ladeanzeige. */
|
||||||
|
interface LoadingStateProperties {
|
||||||
|
/** Was gerade geladen wird. */
|
||||||
|
readonly what: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt an, dass Daten geladen werden. */
|
||||||
|
export function LoadingState({ what }: LoadingStateProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<p className="page__state" role="status">
|
||||||
|
{what} werden geladen…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Fehleranzeige. */
|
||||||
|
interface ErrorStateProperties {
|
||||||
|
/** Der aufgetretene Fehler. */
|
||||||
|
readonly error: ApiError;
|
||||||
|
/** Laedt erneut. */
|
||||||
|
readonly onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Fehler samt Request-ID. */
|
||||||
|
export function ErrorState({ error, onRetry }: ErrorStateProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="notice notice--critical" role="alert">
|
||||||
|
<p className="notice__text">{error.message}</p>
|
||||||
|
|
||||||
|
{/* Die Request-ID gehoert sichtbar in die Oberflaeche: Mit ihr laesst sich
|
||||||
|
ein Vorfall im Serverlog eindeutig wiederfinden (PROMPT.md §50). */}
|
||||||
|
{error.requestId !== '' && (
|
||||||
|
<p className="notice__meta">
|
||||||
|
Fehlercode {error.code} · Vorgang {error.requestId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="button button--secondary" type="button" onClick={onRetry}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Leeranzeige. */
|
||||||
|
interface EmptyStateProperties {
|
||||||
|
/** Der erklaerende Text. */
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt an, dass es nichts anzuzeigen gibt. */
|
||||||
|
export function EmptyState({ message }: EmptyStateProperties): React.JSX.Element {
|
||||||
|
return <p className="page__state page__state--empty">{message}</p>;
|
||||||
|
}
|
||||||
20
apps/web/src/components/StatusIndicator.css
Normal file
20
apps/web/src/components/StatusIndicator.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/* Darstellung der Statusanzeige. */
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator__dot {
|
||||||
|
/* Feste Groesse, damit der Punkt in Tabellen nicht springt. */
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
44
apps/web/src/components/StatusIndicator.tsx
Normal file
44
apps/web/src/components/StatusIndicator.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Statusanzeige des Syncova-Designsystems (PROMPT.md §105/§106).
|
||||||
|
*
|
||||||
|
* Der Zustand wird nicht allein ueber Farbe vermittelt, sondern immer zusaetzlich
|
||||||
|
* ueber Text. Farbe allein waere fuer farbfehlsichtige Anwender unzugaenglich
|
||||||
|
* (PROMPT.md §107).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HealthStatus } from '../types/api';
|
||||||
|
import './StatusIndicator.css';
|
||||||
|
|
||||||
|
/** Zuordnung von Zustand zu Anzeigetext und Farbvariable. */
|
||||||
|
const STATUS_PRESENTATION: Record<HealthStatus, { label: string; colorVariable: string }> = {
|
||||||
|
healthy: { label: 'Fehlerfrei', colorVariable: 'var(--color-status-healthy)' },
|
||||||
|
degraded: { label: 'Eingeschraenkt', colorVariable: 'var(--color-status-warning)' },
|
||||||
|
warning: { label: 'Warnung', colorVariable: 'var(--color-status-warning)' },
|
||||||
|
critical: { label: 'Kritisch', colorVariable: 'var(--color-status-critical)' },
|
||||||
|
offline: { label: 'Nicht erreichbar', colorVariable: 'var(--color-status-high)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eigenschaften der Statusanzeige. */
|
||||||
|
export interface StatusIndicatorProps {
|
||||||
|
/** Anzuzeigender Zustand. */
|
||||||
|
status: HealthStatus;
|
||||||
|
/** Optionale abweichende Beschriftung. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Zustand als farbigen Punkt mit Beschriftung. */
|
||||||
|
export function StatusIndicator({ status, label }: StatusIndicatorProps): React.JSX.Element {
|
||||||
|
const presentation = STATUS_PRESENTATION[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="status-indicator">
|
||||||
|
{/* Der Punkt ist rein dekorativ; die Information steht im Text daneben. */}
|
||||||
|
<span
|
||||||
|
className="status-indicator__dot"
|
||||||
|
style={{ backgroundColor: presentation.colorVariable }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="status-indicator__label">{label ?? presentation.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,284 +0,0 @@
|
|||||||
/**
|
|
||||||
* Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt.
|
|
||||||
*
|
|
||||||
* Die Seitenleiste lässt sich einklappen und merkt sich das. Der Grund ist
|
|
||||||
* nicht Ästhetik: Wer eine Störung bearbeitet, hat oft eine breite Tabelle mit
|
|
||||||
* Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menü sind dann im Weg.
|
|
||||||
*
|
|
||||||
* Auf schmalen Geräten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit
|
|
||||||
* auf Tablets (PROMPT.md §68) — und ein Bereitschaftsdienst sieht nachts als
|
|
||||||
* Erstes auf ein Telefon.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, Timer, X } from 'lucide-react';
|
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from '@/components/ui';
|
|
||||||
import { formatSessionClock, type SessionClock } from '@/features/auth/useSession';
|
|
||||||
import {
|
|
||||||
ALL_PAGES,
|
|
||||||
SECTION_LABELS,
|
|
||||||
mayViewPage,
|
|
||||||
type PageDefinition,
|
|
||||||
} from '@/navigation/pages';
|
|
||||||
|
|
||||||
/** Speicherort der Themenwahl. */
|
|
||||||
const THEME_STORAGE_KEY = 'syncova.theme';
|
|
||||||
const SIDEBAR_STORAGE_KEY = 'syncova.sidebar-collapsed';
|
|
||||||
|
|
||||||
type ThemePreference = 'light' | 'dark';
|
|
||||||
|
|
||||||
/** Liest die gespeicherte Themenwahl, sonst die des Betriebssystems. */
|
|
||||||
function readInitialTheme(): ThemePreference {
|
|
||||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
|
|
||||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
||||||
return storedTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShell({
|
|
||||||
currentPageId,
|
|
||||||
onNavigate,
|
|
||||||
grantedPermissions,
|
|
||||||
userName,
|
|
||||||
sessionClock,
|
|
||||||
onLogout,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
readonly currentPageId: string;
|
|
||||||
readonly onNavigate: (pageIdentifier: string) => void;
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly userName: string;
|
|
||||||
/** Verbleibende Sitzungszeit für die Anzeige. */
|
|
||||||
readonly sessionClock: SessionClock;
|
|
||||||
readonly onLogout: () => void;
|
|
||||||
readonly children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(readInitialTheme);
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
|
|
||||||
() => window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'ja',
|
|
||||||
);
|
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
// Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort
|
|
||||||
// hängen — und weil ein Dialog im Portal sonst im falschen Thema erschiene.
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.setAttribute('data-theme', themePreference);
|
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
|
||||||
}, [themePreference]);
|
|
||||||
|
|
||||||
const visiblePages = ALL_PAGES.filter((page) => mayViewPage(page, grantedPermissions));
|
|
||||||
const currentPage = ALL_PAGES.find((page) => page.id === currentPageId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen bg-page">
|
|
||||||
{/* Abdunklung hinter der Schublade auf schmalen Geräten. */}
|
|
||||||
{isMobileMenuOpen ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
aria-label="Menü schließen"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-line bg-sidebar transition-[width,transform]',
|
|
||||||
isSidebarCollapsed ? 'w-16' : 'w-60',
|
|
||||||
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex h-14 items-center gap-2 border-b border-line px-4">
|
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded bg-accent text-xs font-bold text-accent-fg">
|
|
||||||
S
|
|
||||||
</div>
|
|
||||||
{!isSidebarCollapsed ? (
|
|
||||||
<span className="truncate text-sm font-semibold text-fg">Syncova</span>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden"
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
aria-label="Menü schließen"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Hauptnavigation">
|
|
||||||
{SECTION_LABELS.map(([sectionKey, sectionLabel]) => {
|
|
||||||
const pagesInSection = visiblePages.filter((page) => page.section === sectionKey);
|
|
||||||
|
|
||||||
if (pagesInSection.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={sectionKey} className="mb-4 last:mb-0">
|
|
||||||
{!isSidebarCollapsed ? (
|
|
||||||
<p className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle">
|
|
||||||
{sectionLabel}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{pagesInSection.map((page) => (
|
|
||||||
<li key={page.id}>
|
|
||||||
<NavigationItem
|
|
||||||
page={page}
|
|
||||||
isActive={page.id === currentPageId}
|
|
||||||
isCollapsed={isSidebarCollapsed}
|
|
||||||
onSelect={() => {
|
|
||||||
// Die Schublade schließt sich beim Auswählen, nicht
|
|
||||||
// in einem Effekt auf den Seitenwechsel: Der Anlass
|
|
||||||
// ist die Handlung, nicht der neue Zustand.
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
onNavigate(page.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="border-t border-line p-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setIsSidebarCollapsed((wasCollapsed) => {
|
|
||||||
const nextState = !wasCollapsed;
|
|
||||||
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, nextState ? 'ja' : 'nein');
|
|
||||||
|
|
||||||
return nextState;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="hidden w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg-muted hover:bg-hover hover:text-fg lg:flex"
|
|
||||||
title={isSidebarCollapsed ? 'Menü ausklappen' : 'Menü einklappen'}
|
|
||||||
>
|
|
||||||
{isSidebarCollapsed ? (
|
|
||||||
<PanelLeft className="size-4 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PanelLeftClose className="size-4 shrink-0" />
|
|
||||||
<span>Einklappen</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex min-w-0 flex-1 flex-col transition-[margin]',
|
|
||||||
isSidebarCollapsed ? 'lg:ml-16' : 'lg:ml-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-card/95 px-4 backdrop-blur">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden"
|
|
||||||
onClick={() => setIsMobileMenuOpen(true)}
|
|
||||||
aria-label="Menü öffnen"
|
|
||||||
>
|
|
||||||
<Menu className="size-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h1 className="truncate text-sm font-semibold text-fg">
|
|
||||||
{currentPage?.label ?? 'Syncova'}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
setThemePreference((currentTheme) =>
|
|
||||||
currentTheme === 'dark' ? 'light' : 'dark',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={themePreference === 'dark' ? 'Helles Thema' : 'Dunkles Thema'}
|
|
||||||
aria-label="Thema wechseln"
|
|
||||||
>
|
|
||||||
{themePreference === 'dark' ? <Sun /> : <Moon />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="hidden max-w-40 truncate px-2 text-sm text-fg-muted sm:inline">
|
|
||||||
{userName}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Die Uhr steht neben „Abmelden", weil dort auch die Handlung
|
|
||||||
sitzt, die sie ankündigt. Unter fünf Minuten wird sie auffällig —
|
|
||||||
früher zu warnen erzeugt Gewöhnung, später bleibt keine Zeit. */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs tabular',
|
|
||||||
sessionClock.isExpiringSoon
|
|
||||||
? 'bg-warning-bg font-medium text-warning'
|
|
||||||
: 'text-fg-subtle',
|
|
||||||
)}
|
|
||||||
title={`Die Sitzung endet in ${formatSessionClock(sessionClock.remainingSeconds)}. Jede Eingabe verlängert sie, bis die harte Obergrenze von 30 Minuten erreicht ist.`}
|
|
||||||
>
|
|
||||||
<Timer className="size-3.5" aria-hidden />
|
|
||||||
<span className="sr-only">Verbleibende Sitzungszeit: </span>
|
|
||||||
{formatSessionClock(sessionClock.remainingSeconds)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={onLogout}>
|
|
||||||
Abmelden
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="min-w-0 flex-1 p-4 lg:p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationItem({
|
|
||||||
page,
|
|
||||||
isActive,
|
|
||||||
isCollapsed,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
readonly page: PageDefinition;
|
|
||||||
readonly isActive: boolean;
|
|
||||||
readonly isCollapsed: boolean;
|
|
||||||
readonly onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const IconComponent = page.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
title={isCollapsed ? page.label : undefined}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-accent-subtle font-medium text-accent'
|
|
||||||
: 'text-fg-muted hover:bg-hover hover:text-fg',
|
|
||||||
// Ein noch nicht verfügbarer Bereich bleibt anklickbar — die Zielseite
|
|
||||||
// erklärt, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
|
|
||||||
!page.available && 'opacity-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconComponent className="size-4 shrink-0" aria-hidden />
|
|
||||||
{!isCollapsed ? (
|
|
||||||
<>
|
|
||||||
<span className="truncate">{page.label}</span>
|
|
||||||
{!page.available ? (
|
|
||||||
<span className="ml-auto text-[10px] text-fg-subtle">bald</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Fehlergrenze um den Seiteninhalt.
|
|
||||||
*
|
|
||||||
* Ohne sie reißt ein einziger Fehler in einer Komponente den gesamten Baum ab —
|
|
||||||
* React hängt alles aus, und übrig bleibt eine leere Seite. Im dunklen Thema ist
|
|
||||||
* das ein schwarzer Bildschirm ohne jeden Hinweis, und ein Betreiber hat keine
|
|
||||||
* Möglichkeit zu melden, was schiefging.
|
|
||||||
*
|
|
||||||
* Die Grenze sitzt **um den Inhalt**, nicht um die ganze Anwendung: Seitenleiste
|
|
||||||
* und Kopfzeile bleiben stehen, und man kann auf eine andere Seite wechseln,
|
|
||||||
* statt neu laden zu müssen.
|
|
||||||
*
|
|
||||||
* Klassenkomponente, weil React für Fehlergrenzen keine Entsprechung als Haken
|
|
||||||
* anbietet.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AlertTriangle } from 'lucide-react';
|
|
||||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
||||||
import { Button } from '@/components/ui';
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
|
||||||
/** Wechselt der Wert, wird der Fehler verworfen — etwa beim Seitenwechsel. */
|
|
||||||
readonly resetKey: string;
|
|
||||||
readonly children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
readonly caughtError: Error | null;
|
|
||||||
readonly componentStack: string;
|
|
||||||
readonly resetKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
||||||
public constructor(properties: ErrorBoundaryProps) {
|
|
||||||
super(properties);
|
|
||||||
|
|
||||||
this.state = { caughtError: null, componentStack: '', resetKey: properties.resetKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getDerivedStateFromError(caughtError: Error): Partial<ErrorBoundaryState> {
|
|
||||||
return { caughtError };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verwirft den Fehler beim Seitenwechsel.
|
|
||||||
*
|
|
||||||
* Abgeleitet statt in einem Effekt gesetzt: Sonst zeigte die neue Seite noch
|
|
||||||
* einen Wimpernschlag lang den Fehler der alten.
|
|
||||||
*/
|
|
||||||
public static getDerivedStateFromProps(
|
|
||||||
nextProperties: ErrorBoundaryProps,
|
|
||||||
currentState: ErrorBoundaryState,
|
|
||||||
): Partial<ErrorBoundaryState> | null {
|
|
||||||
if (nextProperties.resetKey !== currentState.resetKey) {
|
|
||||||
return { caughtError: null, componentStack: '', resetKey: nextProperties.resetKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override componentDidCatch(caughtError: Error, errorInfo: ErrorInfo): void {
|
|
||||||
this.setState({ componentStack: errorInfo.componentStack ?? '' });
|
|
||||||
|
|
||||||
// Die Meldung gehört in die Entwicklerkonsole, damit sie sich kopieren
|
|
||||||
// lässt. Sie hier zu verschlucken wäre der stille Fehler, den die
|
|
||||||
// Entwicklungsregeln ausdrücklich verbieten.
|
|
||||||
console.error('Unbehandelter Fehler in der Oberfläche:', caughtError, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override render(): ReactNode {
|
|
||||||
const { caughtError, componentStack } = this.state;
|
|
||||||
|
|
||||||
if (caughtError === null) {
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-critical/30 bg-card p-6" role="alert">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h2 className="text-sm font-semibold text-fg">
|
|
||||||
Diese Seite konnte nicht angezeigt werden
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-fg-muted">
|
|
||||||
Die übrige Konsole arbeitet weiter. Wechseln Sie den Bereich oder
|
|
||||||
laden Sie neu.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<pre className="mt-4 max-h-40 overflow-auto rounded-md bg-sunken p-3 text-xs text-fg">
|
|
||||||
{caughtError.message || caughtError.name}
|
|
||||||
{componentStack ? `\n${componentStack.trim().split('\n').slice(0, 6).join('\n')}` : ''}
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => this.setState({ caughtError: null, componentStack: '' })}
|
|
||||||
>
|
|
||||||
Erneut versuchen
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
|
|
||||||
Seite neu laden
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
/** Kopfbereich einer Seite: Titel, Erläuterung, Handlungen. */
|
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Button } from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export function PageHeader({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
onBack,
|
|
||||||
backLabel = 'Zurück',
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
readonly title: ReactNode;
|
|
||||||
readonly description?: ReactNode | undefined;
|
|
||||||
readonly actions?: ReactNode | undefined;
|
|
||||||
readonly onBack?: (() => void) | undefined;
|
|
||||||
readonly backLabel?: string | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn('mb-5', className)}>
|
|
||||||
{onBack ? (
|
|
||||||
<Button variant="ghost" size="sm" className="-ml-2 mb-2" onClick={onBack}>
|
|
||||||
<ChevronLeft />
|
|
||||||
{backLabel}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-fg">{title}</h2>
|
|
||||||
{description ? (
|
|
||||||
<p className="mt-1 max-w-3xl text-sm text-fg-muted">{description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Schaltfläche.
|
|
||||||
*
|
|
||||||
* Die Ausprägung "destructive" ist bewusst eigenständig und nicht nur eine
|
|
||||||
* rote Farbe: Löschende Handlungen sollen sich auch dann unterscheiden, wenn
|
|
||||||
* jemand die Oberfläche in Graustufen betrachtet oder farbfehlsichtig ist.
|
|
||||||
* Deshalb trägt sie zusätzlich einen kräftigen Rand.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
// Gemeinsame Grundlage aller Ausprägungen.
|
|
||||||
[
|
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg',
|
|
||||||
'text-sm font-medium transition-colors',
|
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
||||||
].join(' '),
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
primary: 'bg-accent text-accent-fg hover:bg-accent-hover',
|
|
||||||
secondary: 'border border-line-strong bg-card text-fg hover:bg-hover',
|
|
||||||
ghost: 'text-fg-muted hover:bg-hover hover:text-fg',
|
|
||||||
destructive:
|
|
||||||
'border border-critical bg-critical text-white hover:brightness-110 dark:text-fg-inverted',
|
|
||||||
link: 'text-accent underline-offset-4 hover:underline',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
sm: 'h-8 px-3 text-xs',
|
|
||||||
md: 'h-9 px-4',
|
|
||||||
lg: 'h-10 px-6',
|
|
||||||
icon: 'size-9',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'secondary',
|
|
||||||
size: 'md',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
/** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */
|
|
||||||
readonly asChild?: boolean | undefined;
|
|
||||||
/** Zeigt einen Ladeanzeiger und sperrt die Schaltfläche. */
|
|
||||||
readonly isLoading?: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
||||||
{ className, variant, size, asChild = false, isLoading = false, children, disabled, ...restProps },
|
|
||||||
forwardedReference,
|
|
||||||
) {
|
|
||||||
const RenderedComponent = asChild ? Slot : 'button';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RenderedComponent
|
|
||||||
ref={forwardedReference}
|
|
||||||
className={cn(buttonVariants({ variant, size }), className)}
|
|
||||||
// Während eine Handlung läuft, darf sie nicht ein zweites Mal ausgelöst
|
|
||||||
// werden. Bei einem Sicherungslauf wäre der zweite Klick ein 409, bei
|
|
||||||
// einer Löschung schlimmstenfalls mehr als beabsichtigt.
|
|
||||||
disabled={disabled || isLoading}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{isLoading ? <Loader2 className="animate-spin" aria-hidden /> : null}
|
|
||||||
{children}
|
|
||||||
</RenderedComponent>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { buttonVariants };
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
/** Karte — die Grundfläche aller Inhalte. */
|
|
||||||
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export function Card({
|
|
||||||
className,
|
|
||||||
asChild = false,
|
|
||||||
...restProps
|
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
|
||||||
/**
|
|
||||||
* Rendert die Kindkomponente statt eines <div>.
|
|
||||||
*
|
|
||||||
* Fuer anklickbare Kacheln: Eine Schaltflaeche ist mit der Tastatur
|
|
||||||
* erreichbar, ein div mit Klick-Handler nicht.
|
|
||||||
*/
|
|
||||||
readonly asChild?: boolean | undefined;
|
|
||||||
}) {
|
|
||||||
const RenderedComponent = asChild ? Slot : 'div';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RenderedComponent
|
|
||||||
className={cn('border border-line bg-card', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardHeader({
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
action,
|
|
||||||
...restProps
|
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
|
||||||
readonly title?: ReactNode | undefined;
|
|
||||||
readonly description?: ReactNode | undefined;
|
|
||||||
readonly action?: ReactNode | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-start justify-between gap-4 border-b border-line px-5 py-3.5',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
{title ? <h2 className="text-sm font-semibold text-fg">{title}</h2> : null}
|
|
||||||
{description ? (
|
|
||||||
<p className="mt-1 text-sm text-fg-muted">{description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{action ? <div className="shrink-0">{action}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardContent({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return <div className={cn('px-5 py-4', className)} {...restProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardFooter({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-end gap-2 border-t border-line px-5 py-3',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tabelle.
|
|
||||||
*
|
|
||||||
* Eine Betriebskonsole besteht zu großen Teilen aus Tabellen, deshalb steckt
|
|
||||||
* die Mühe hier und nicht in Effekten:
|
|
||||||
*
|
|
||||||
* - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter
|
|
||||||
* trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte;
|
|
||||||
* die erste verlangt eine Schaltfläche, die zweite nicht.
|
|
||||||
* - **Während des Nachladens bleibt die alte Tabelle stehen** und wird nur
|
|
||||||
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen lässt die Seite bei
|
|
||||||
* jedem Filterwechsel aufblitzen.
|
|
||||||
* - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export interface TableColumn<RowType> {
|
|
||||||
readonly key: string;
|
|
||||||
readonly header: ReactNode;
|
|
||||||
readonly render: (row: RowType) => ReactNode;
|
|
||||||
/** Rechtsbündig — für Zahlen. */
|
|
||||||
readonly numeric?: boolean | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTable<RowType>({
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
getRowKey,
|
|
||||||
onRowClick,
|
|
||||||
isLoading = false,
|
|
||||||
emptyTitle = 'Keine Einträge',
|
|
||||||
emptyDescription,
|
|
||||||
emptyAction,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
readonly columns: ReadonlyArray<TableColumn<RowType>>;
|
|
||||||
readonly rows: readonly RowType[];
|
|
||||||
readonly getRowKey: (row: RowType) => string;
|
|
||||||
readonly onRowClick?: ((row: RowType) => void) | undefined;
|
|
||||||
readonly isLoading?: boolean | undefined;
|
|
||||||
readonly emptyTitle?: ReactNode | undefined;
|
|
||||||
readonly emptyDescription?: ReactNode | undefined;
|
|
||||||
readonly emptyAction?: ReactNode | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
if (rows.length === 0 && !isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
|
|
||||||
<p className="text-sm font-medium text-fg">{emptyTitle}</p>
|
|
||||||
{emptyDescription ? (
|
|
||||||
<p className="mt-1 max-w-md text-sm text-fg-muted">{emptyDescription}</p>
|
|
||||||
) : null}
|
|
||||||
{emptyAction ? <div className="mt-4">{emptyAction}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('w-full overflow-x-auto', className)}>
|
|
||||||
<table className="w-full border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-line bg-sunken/50">
|
|
||||||
{columns.map((column) => (
|
|
||||||
<th
|
|
||||||
key={column.key}
|
|
||||||
scope="col"
|
|
||||||
className={cn(
|
|
||||||
'whitespace-nowrap px-4 py-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle',
|
|
||||||
column.numeric ? 'text-right' : 'text-left',
|
|
||||||
column.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{column.header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
className={cn(
|
|
||||||
'transition-opacity',
|
|
||||||
// Beim Nachladen abblenden statt ersetzen — sonst blitzt die
|
|
||||||
// Tabelle bei jedem Filterwechsel auf.
|
|
||||||
isLoading && rows.length > 0 ? 'opacity-50' : 'opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={getRowKey(row)}
|
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
||||||
// Eine anklickbare Zeile muss auch mit der Tastatur erreichbar
|
|
||||||
// sein; sonst ist die halbe Konsole ohne Maus unbedienbar.
|
|
||||||
tabIndex={onRowClick ? 0 : undefined}
|
|
||||||
role={onRowClick ? 'button' : undefined}
|
|
||||||
onKeyDown={
|
|
||||||
onRowClick
|
|
||||||
? (keyboardEvent) => {
|
|
||||||
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
|
|
||||||
keyboardEvent.preventDefault();
|
|
||||||
onRowClick(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'border-b border-line/60 last:border-0',
|
|
||||||
onRowClick && 'cursor-pointer hover:bg-hover',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<td
|
|
||||||
key={column.key}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2.5 align-middle text-fg',
|
|
||||||
column.numeric && 'tabular text-right',
|
|
||||||
column.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{column.render(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{isLoading && rows.length === 0 ? (
|
|
||||||
<div className="space-y-2 px-4 py-4">
|
|
||||||
{[0, 1, 2, 3, 4].map((placeholderIndex) => (
|
|
||||||
<div
|
|
||||||
key={placeholderIndex}
|
|
||||||
className="h-8 animate-pulse rounded bg-sunken"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dialoge.
|
|
||||||
*
|
|
||||||
* Zwei Bauarten, und der Unterschied ist fachlich:
|
|
||||||
*
|
|
||||||
* - `Dialog` für Eingaben (anlegen, bearbeiten). Er lässt sich mit Escape und
|
|
||||||
* einem Klick daneben schließen.
|
|
||||||
* - `ConfirmDialog` für Handlungen, die etwas verändern. Er verlangt eine
|
|
||||||
* ausdrückliche Bestätigung. Bei zerstörenden Handlungen kann er zusätzlich
|
|
||||||
* ein wörtlich einzutippendes Wort verlangen — dasselbe Mittel, das
|
|
||||||
* `uninstall.sh` und die Wiederherstellung mit Überschreiben verwenden. Ein
|
|
||||||
* versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus,
|
|
||||||
* und ein Klick aus Gewohnheit auch nicht.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import { useState, type ReactNode } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
export const DialogRoot = DialogPrimitive.Root;
|
|
||||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
|
||||||
export const DialogClose = DialogPrimitive.Close;
|
|
||||||
|
|
||||||
export function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
size = 'md',
|
|
||||||
}: {
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
readonly children: ReactNode;
|
|
||||||
readonly size?: 'sm' | 'md' | 'lg' | 'xl' | undefined;
|
|
||||||
}) {
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'max-w-md',
|
|
||||||
md: 'max-w-lg',
|
|
||||||
lg: 'max-w-2xl',
|
|
||||||
xl: 'max-w-4xl',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Portal>
|
|
||||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[1px] data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
className={cn(
|
|
||||||
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2',
|
|
||||||
'max-h-[calc(100vh-4rem)] overflow-y-auto',
|
|
||||||
'border border-line-strong bg-card',
|
|
||||||
sizeClasses[size],
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
className="absolute right-4 top-4 rounded-md p-1 text-fg-muted transition-colors hover:bg-hover hover:text-fg"
|
|
||||||
aria-label="Schließen"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPrimitive.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogHeader({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
readonly title: ReactNode;
|
|
||||||
readonly description?: ReactNode | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="border-b border-line px-5 py-4 pr-12">
|
|
||||||
<DialogPrimitive.Title className="text-base font-semibold text-fg">
|
|
||||||
{title}
|
|
||||||
</DialogPrimitive.Title>
|
|
||||||
{description ? (
|
|
||||||
<DialogPrimitive.Description className="mt-1 text-sm text-fg-muted">
|
|
||||||
{description}
|
|
||||||
</DialogPrimitive.Description>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogBody({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
readonly children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return <div className={cn('space-y-4 px-5 py-4', className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogFooter({ children }: { readonly children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bestätigungsdialog für verändernde Handlungen.
|
|
||||||
*
|
|
||||||
* `confirmationWord` ist die zweite Hürde: Wird es gesetzt, muss der Betreiber
|
|
||||||
* es wörtlich eintippen. Das gehört ausschließlich an Handlungen, die Daten
|
|
||||||
* unwiederbringlich entfernen oder überschreiben — ein Ritual ohne Anlass
|
|
||||||
* gewöhnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
|
|
||||||
*/
|
|
||||||
export function ConfirmDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = 'Bestätigen',
|
|
||||||
cancelLabel = 'Abbrechen',
|
|
||||||
confirmationWord,
|
|
||||||
confirmationHint,
|
|
||||||
isDestructive = false,
|
|
||||||
isLoading = false,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
readonly open: boolean;
|
|
||||||
readonly onOpenChange: (isOpen: boolean) => void;
|
|
||||||
readonly title: ReactNode;
|
|
||||||
readonly description?: ReactNode | undefined;
|
|
||||||
readonly confirmLabel?: string | undefined;
|
|
||||||
readonly cancelLabel?: string | undefined;
|
|
||||||
/** Wird gesetzt, muss der Betreiber dieses Wort wörtlich eintippen. */
|
|
||||||
readonly confirmationWord?: string | undefined;
|
|
||||||
readonly confirmationHint?: ReactNode | undefined;
|
|
||||||
readonly isDestructive?: boolean | undefined;
|
|
||||||
readonly isLoading?: boolean | undefined;
|
|
||||||
readonly onConfirm: () => void | Promise<void>;
|
|
||||||
readonly children?: ReactNode | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent size="sm">
|
|
||||||
{/* Der Inhalt hängt im Portal und wird beim Schließen ausgehängt.
|
|
||||||
Damit verschwindet die eingetippte Bestätigung von selbst — ein
|
|
||||||
Zurücksetzen im Effekt braucht es nicht, und die Hürde steht beim
|
|
||||||
nächsten Öffnen wieder. */}
|
|
||||||
<ConfirmDialogBody
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
confirmLabel={confirmLabel}
|
|
||||||
cancelLabel={cancelLabel}
|
|
||||||
confirmationWord={confirmationWord}
|
|
||||||
confirmationHint={confirmationHint}
|
|
||||||
isDestructive={isDestructive}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onCancel={() => onOpenChange(false)}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ConfirmDialogBody>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inhalt des Bestätigungsdialogs; hält die eingetippte Bestätigung. */
|
|
||||||
function ConfirmDialogBody({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel,
|
|
||||||
cancelLabel,
|
|
||||||
confirmationWord,
|
|
||||||
confirmationHint,
|
|
||||||
isDestructive,
|
|
||||||
isLoading,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
readonly title: ReactNode;
|
|
||||||
readonly description?: ReactNode | undefined;
|
|
||||||
readonly confirmLabel: string;
|
|
||||||
readonly cancelLabel: string;
|
|
||||||
readonly confirmationWord?: string | undefined;
|
|
||||||
readonly confirmationHint?: ReactNode | undefined;
|
|
||||||
readonly isDestructive: boolean;
|
|
||||||
readonly isLoading: boolean;
|
|
||||||
readonly onCancel: () => void;
|
|
||||||
readonly onConfirm: () => void | Promise<void>;
|
|
||||||
readonly children?: ReactNode | undefined;
|
|
||||||
}) {
|
|
||||||
const [typedConfirmation, setTypedConfirmation] = useState('');
|
|
||||||
|
|
||||||
const confirmationSatisfied =
|
|
||||||
!confirmationWord || typedConfirmation.trim() === confirmationWord;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DialogHeader title={title} description={description} />
|
|
||||||
<DialogBody>
|
|
||||||
{children}
|
|
||||||
{confirmationWord ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="confirmation-input"
|
|
||||||
className="block text-sm font-medium text-fg"
|
|
||||||
>
|
|
||||||
{confirmationHint ?? (
|
|
||||||
<>
|
|
||||||
Tippen Sie zur Bestätigung{' '}
|
|
||||||
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
{confirmationWord}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="confirmation-input"
|
|
||||||
type="text"
|
|
||||||
autoComplete="off"
|
|
||||||
value={typedConfirmation}
|
|
||||||
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
|
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 font-mono text-sm text-fg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={onCancel} disabled={isLoading}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={isDestructive ? 'destructive' : 'primary'}
|
|
||||||
onClick={() => void onConfirm()}
|
|
||||||
disabled={!confirmationSatisfied}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zustandsanzeigen: Laden, Fehler, Leere, Hinweise.
|
|
||||||
*
|
|
||||||
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
|
|
||||||
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
|
|
||||||
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
|
|
||||||
* Begründung, nie als "0" — eine Null liest sich wie "geprüft und in
|
|
||||||
* Ordnung", und genau das wäre falsch.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
export function LoadingState({ label = 'Wird geladen …' }: { readonly label?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center gap-2 px-6 py-14 text-sm text-fg-muted">
|
|
||||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorState({
|
|
||||||
title = 'Die Anfrage ist fehlgeschlagen',
|
|
||||||
message,
|
|
||||||
requestId,
|
|
||||||
onRetry,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
readonly title?: string | undefined;
|
|
||||||
readonly message?: string | undefined;
|
|
||||||
readonly requestId?: string | undefined;
|
|
||||||
readonly onRetry?: (() => void) | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-center px-6 py-12 text-center',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<TriangleAlert className="size-6 text-critical" aria-hidden />
|
|
||||||
<p className="mt-3 text-sm font-medium text-fg">{title}</p>
|
|
||||||
{message ? <p className="mt-1 max-w-lg text-sm text-fg-muted">{message}</p> : null}
|
|
||||||
{requestId ? (
|
|
||||||
<p className="mt-3 font-mono text-xs text-fg-subtle">
|
|
||||||
Vorgangsnummer: {requestId}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{onRetry ? (
|
|
||||||
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
|
|
||||||
Erneut versuchen
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyState({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
action,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
readonly title: ReactNode;
|
|
||||||
readonly description?: ReactNode | undefined;
|
|
||||||
readonly action?: ReactNode | undefined;
|
|
||||||
readonly icon?: ReactNode | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
|
|
||||||
{icon ? <div className="mb-3 text-fg-subtle">{icon}</div> : null}
|
|
||||||
<p className="text-sm font-medium text-fg">{title}</p>
|
|
||||||
{description ? (
|
|
||||||
<p className="mt-1 max-w-md text-sm text-fg-muted">{description}</p>
|
|
||||||
) : null}
|
|
||||||
{action ? <div className="mt-4">{action}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hinweis für eine Kennzahl ohne Datengrundlage.
|
|
||||||
*
|
|
||||||
* Der Grund gehört dazu. "—" allein sieht nach einem Fehler der Oberfläche
|
|
||||||
* aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine
|
|
||||||
* Handlungsanweisung.
|
|
||||||
*/
|
|
||||||
export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-fg-muted">
|
|
||||||
<Info className="size-3.5 shrink-0" aria-hidden />
|
|
||||||
{reason}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auffälliger Hinweiskasten. */
|
|
||||||
export function Callout({
|
|
||||||
tone = 'info',
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
role = 'note',
|
|
||||||
}: {
|
|
||||||
readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined;
|
|
||||||
readonly title?: ReactNode | undefined;
|
|
||||||
readonly children?: ReactNode | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
/**
|
|
||||||
* ARIA-Rolle.
|
|
||||||
*
|
|
||||||
* Standard ist `note` — die meisten Hinweise erklären etwas und stehen schon
|
|
||||||
* beim Öffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
|
|
||||||
* erscheint, gehört dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
|
|
||||||
* statt ihn im Seiteninhalt untergehen zu lassen.
|
|
||||||
*/
|
|
||||||
readonly role?: 'note' | 'alert' | 'status' | undefined;
|
|
||||||
}) {
|
|
||||||
const toneClasses = {
|
|
||||||
info: 'border-info/30 bg-info-bg text-info',
|
|
||||||
warning: 'border-warning/30 bg-warning-bg text-warning',
|
|
||||||
critical: 'border-critical/30 bg-critical-bg text-critical',
|
|
||||||
healthy: 'border-healthy/30 bg-healthy-bg text-healthy',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('rounded-md border p-3', toneClasses[tone], className)} role={role}>
|
|
||||||
<div className="flex gap-2.5">
|
|
||||||
<IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
||||||
<div className="min-w-0 text-sm">
|
|
||||||
{title ? <p className="font-medium">{title}</p> : null}
|
|
||||||
{children ? (
|
|
||||||
<div className={cn('text-fg', title && 'mt-1')}>{children}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Beschriftetes Wertepaar für Detailseiten. */
|
|
||||||
export function DetailItem({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
readonly label: ReactNode;
|
|
||||||
readonly children: ReactNode;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn('min-w-0', className)}>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-fg">{children}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
/**
|
|
||||||
* Formularelemente.
|
|
||||||
*
|
|
||||||
* Jedes Feld trägt eine Beschriftung, die mit ihm verbunden ist (`htmlFor`),
|
|
||||||
* und optional einen Hinweistext. Der Hinweis ist keine Zierde: In einer
|
|
||||||
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" über
|
|
||||||
* Datenverlust, und ein Betreiber soll nicht raten müssen, was die Anlage mit
|
|
||||||
* seiner Eingabe tut.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const fieldClasses = [
|
|
||||||
'w-full rounded-lg border border-line-strong bg-card px-3 py-2 text-sm text-fg transition-colors',
|
|
||||||
'placeholder:text-fg-subtle',
|
|
||||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
export function Field({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
error,
|
|
||||||
required,
|
|
||||||
htmlFor,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
readonly label: ReactNode;
|
|
||||||
readonly hint?: ReactNode | undefined;
|
|
||||||
readonly error?: ReactNode | undefined;
|
|
||||||
readonly required?: boolean | undefined;
|
|
||||||
readonly htmlFor?: string | undefined;
|
|
||||||
readonly children: ReactNode;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-1.5', className)}>
|
|
||||||
<label htmlFor={htmlFor} className="block text-sm font-medium text-fg">
|
|
||||||
{label}
|
|
||||||
{required ? (
|
|
||||||
<span className="ml-1 text-critical" aria-label="Pflichtfeld">
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{error ? (
|
|
||||||
<p className="text-xs text-critical">{error}</p>
|
|
||||||
) : hint ? (
|
|
||||||
<p className="text-xs text-fg-muted">{hint}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextInput = forwardRef<
|
|
||||||
HTMLInputElement,
|
|
||||||
InputHTMLAttributes<HTMLInputElement> & {
|
|
||||||
readonly label?: ReactNode | undefined;
|
|
||||||
readonly hint?: ReactNode | undefined;
|
|
||||||
readonly error?: ReactNode | undefined;
|
|
||||||
}
|
|
||||||
>(function TextInput({ label, hint, error, className, id, ...restProps }, forwardedReference) {
|
|
||||||
const generatedId = useId();
|
|
||||||
const inputId = id ?? generatedId;
|
|
||||||
|
|
||||||
const inputElement = (
|
|
||||||
<input
|
|
||||||
ref={forwardedReference}
|
|
||||||
id={inputId}
|
|
||||||
className={cn(fieldClasses, error && 'border-critical', className)}
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!label) {
|
|
||||||
return inputElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field label={label} hint={hint} error={error} required={restProps.required} htmlFor={inputId}>
|
|
||||||
{inputElement}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TextArea = forwardRef<
|
|
||||||
HTMLTextAreaElement,
|
|
||||||
TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
||||||
readonly label?: ReactNode | undefined;
|
|
||||||
readonly hint?: ReactNode | undefined;
|
|
||||||
readonly error?: ReactNode | undefined;
|
|
||||||
}
|
|
||||||
>(function TextArea({ label, hint, error, className, id, ...restProps }, forwardedReference) {
|
|
||||||
const generatedId = useId();
|
|
||||||
const fieldId = id ?? generatedId;
|
|
||||||
|
|
||||||
const textAreaElement = (
|
|
||||||
<textarea
|
|
||||||
ref={forwardedReference}
|
|
||||||
id={fieldId}
|
|
||||||
className={cn(fieldClasses, 'min-h-20 resize-y', error && 'border-critical', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!label) {
|
|
||||||
return textAreaElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field label={label} hint={hint} error={error} required={restProps.required} htmlFor={fieldId}>
|
|
||||||
{textAreaElement}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SelectInput = forwardRef<
|
|
||||||
HTMLSelectElement,
|
|
||||||
SelectHTMLAttributes<HTMLSelectElement> & {
|
|
||||||
readonly label?: ReactNode | undefined;
|
|
||||||
readonly hint?: ReactNode | undefined;
|
|
||||||
readonly error?: ReactNode | undefined;
|
|
||||||
}
|
|
||||||
>(function SelectInput({ label, hint, error, className, id, children, ...restProps }, forwardedReference) {
|
|
||||||
const generatedId = useId();
|
|
||||||
const fieldId = id ?? generatedId;
|
|
||||||
|
|
||||||
const selectElement = (
|
|
||||||
<select
|
|
||||||
ref={forwardedReference}
|
|
||||||
id={fieldId}
|
|
||||||
className={cn(fieldClasses, 'pr-8', error && 'border-critical', className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!label) {
|
|
||||||
return selectElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field label={label} hint={hint} error={error} required={restProps.required} htmlFor={fieldId}>
|
|
||||||
{selectElement}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Kontrollkästchen mit Beschriftung und Erläuterung. */
|
|
||||||
export function CheckboxField({
|
|
||||||
checked,
|
|
||||||
onCheckedChange,
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
disabled,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
readonly checked: boolean;
|
|
||||||
readonly onCheckedChange: (isChecked: boolean) => void;
|
|
||||||
readonly label: ReactNode;
|
|
||||||
readonly hint?: ReactNode | undefined;
|
|
||||||
readonly disabled?: boolean | undefined;
|
|
||||||
readonly id?: string | undefined;
|
|
||||||
}) {
|
|
||||||
const generatedId = useId();
|
|
||||||
const fieldId = id ?? generatedId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2.5">
|
|
||||||
<input
|
|
||||||
id={fieldId}
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)}
|
|
||||||
className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<label
|
|
||||||
htmlFor={fieldId}
|
|
||||||
className={cn('block text-sm text-fg', disabled && 'opacity-60')}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{hint ? <p className="mt-0.5 text-xs text-fg-muted">{hint}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prüft die Zuordnung der Fachbegriffe auf die fünf Bedeutungen.
|
|
||||||
*
|
|
||||||
* Diese Tabelle ist der eine Ort, an dem ein falscher Ton die Aussage der
|
|
||||||
* ganzen Anlage umkehren kann: Ein grünes Abzeichen an einem Teilfehler oder
|
|
||||||
* an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und
|
|
||||||
* niemand sieht es, weil die Oberfläche einwandfrei aussieht.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveStatus } from './StatusBadge';
|
|
||||||
|
|
||||||
describe('Statuszuordnung', () => {
|
|
||||||
it('färbt einen Teilfehler niemals grün', () => {
|
|
||||||
// Entwicklungsregel 1: Teilfehler heißen PARTIAL FAILURE, nie SUCCESS.
|
|
||||||
expect(resolveStatus('partial_failure').tone).not.toBe('healthy');
|
|
||||||
expect(resolveStatus('partial_failure').tone).toBe('warning');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('färbt ein gesichertes, aber ungepruftes Backup nicht grün', () => {
|
|
||||||
// `successful` heißt "der Lauf ist durchgelaufen" — nicht
|
|
||||||
// "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf
|
|
||||||
// `recoverable`.
|
|
||||||
expect(resolveStatus('successful').tone).toBe('neutral');
|
|
||||||
expect(resolveStatus('recoverable').tone).toBe('healthy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('behandelt ein beschädigtes Backup als kritisch, nicht als Warnung', () => {
|
|
||||||
// Ein Backup mit einem beschädigten Block ist nicht zu 70 Prozent
|
|
||||||
// wiederherstellbar, sondern gar nicht.
|
|
||||||
expect(resolveStatus('corrupted').tone).toBe('critical');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('färbt einen nur softwareseitig durchgesetzten Löschschutz als Warnung', () => {
|
|
||||||
// `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups
|
|
||||||
// löschen. Das als "in Ordnung" zu zeigen wäre die gefährlichste
|
|
||||||
// Beschönigung der Anlage.
|
|
||||||
expect(resolveStatus('advisory').tone).toBe('warning');
|
|
||||||
expect(resolveStatus('filesystem').tone).toBe('healthy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('zeigt einen unbekannten Zustand neutral und niemals grün', () => {
|
|
||||||
// Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht
|
|
||||||
// als "in Ordnung" durchgehen. Grün wäre der bequeme Standard und der
|
|
||||||
// gefährlichste.
|
|
||||||
const unknownStatus = resolveStatus('ein_neuer_serverzustand');
|
|
||||||
|
|
||||||
expect(unknownStatus.tone).toBe('neutral');
|
|
||||||
expect(unknownStatus.label).toBe('ein_neuer_serverzustand');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('behandelt einen fehlenden Wert als unbekannt', () => {
|
|
||||||
expect(resolveStatus(null).tone).toBe('neutral');
|
|
||||||
expect(resolveStatus(undefined).label).toBe('Unbekannt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('kennt jede Einstufung, die der Server vergeben kann', () => {
|
|
||||||
// Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort
|
|
||||||
// eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der
|
|
||||||
// Oberfläche als englischer Rohwert.
|
|
||||||
for (const classification of [
|
|
||||||
'failed',
|
|
||||||
'corrupted',
|
|
||||||
'successful',
|
|
||||||
'verified',
|
|
||||||
'recoverable',
|
|
||||||
]) {
|
|
||||||
expect(
|
|
||||||
resolveStatus(classification).label,
|
|
||||||
`Einstufung ${classification} ist nicht übersetzt`,
|
|
||||||
).not.toBe(classification);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* Statusanzeige — die wichtigste Komponente der Konsole.
|
|
||||||
*
|
|
||||||
* Sie bildet die Fachbegriffe der API auf die fünf Bedeutungen aus PROMPT.md
|
|
||||||
* §106 ab. Dass diese Abbildung **an einer einzigen Stelle** steht, ist der
|
|
||||||
* eigentliche Punkt: Wäre sie über die Seiten verteilt, würde früher oder
|
|
||||||
* später irgendwo "partial_failure" grün erscheinen — und ein Betreiber hält
|
|
||||||
* einen Teilfehler dann für einen Erfolg.
|
|
||||||
*
|
|
||||||
* Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den
|
|
||||||
* Entscheidungen der Phasen:
|
|
||||||
*
|
|
||||||
* - "partial_failure" ist **niemals** grün (Entwicklungsregel 1).
|
|
||||||
* - "unverified" ist neutral, nicht grün: Ohne Wiederherstellungstest ist die
|
|
||||||
* Wiederherstellbarkeit unbekannt, und Unbekanntes zählt nie als gut
|
|
||||||
* (Phase 10).
|
|
||||||
* - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschädigtes Backup
|
|
||||||
* ist nicht zu 70 % wiederherstellbar, sondern gar nicht.
|
|
||||||
* - "advisory" beim Löschschutz ist eine Warnung: Der Schutz ist eine
|
|
||||||
* Software-Regel, kein Schutz des Dateisystems (Phase 11).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
/** Die fünf Bedeutungen. Mehr gibt es nicht. */
|
|
||||||
export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral';
|
|
||||||
|
|
||||||
interface StatusDefinition {
|
|
||||||
readonly tone: StatusTone;
|
|
||||||
readonly label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung.
|
|
||||||
*
|
|
||||||
* Ein unbekannter Schlüssel wird bewusst **neutral** dargestellt und mit dem
|
|
||||||
* Rohwert beschriftet — nicht grün. Ein neuer Serverzustand, den diese Tabelle
|
|
||||||
* noch nicht kennt, darf nicht als "in Ordnung" durchgehen.
|
|
||||||
*/
|
|
||||||
const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
|
||||||
// --- Läufe ---
|
|
||||||
succeeded: { tone: 'healthy', label: 'Erfolgreich' },
|
|
||||||
running: { tone: 'info', label: 'Läuft' },
|
|
||||||
queued: { tone: 'info', label: 'Eingereiht' },
|
|
||||||
pending: { tone: 'neutral', label: 'Ausstehend' },
|
|
||||||
partial_failure: { tone: 'warning', label: 'Teilfehler' },
|
|
||||||
failed: { tone: 'critical', label: 'Fehlgeschlagen' },
|
|
||||||
cancelled: { tone: 'neutral', label: 'Abgebrochen' },
|
|
||||||
|
|
||||||
// --- Aufträge ---
|
|
||||||
enabled: { tone: 'healthy', label: 'Aktiv' },
|
|
||||||
paused: { tone: 'warning', label: 'Angehalten' },
|
|
||||||
disabled: { tone: 'neutral', label: 'Deaktiviert' },
|
|
||||||
|
|
||||||
// --- Einstufung der Wiederherstellungspunkte (Phase 10) ---
|
|
||||||
//
|
|
||||||
// Das Vokabular lautet: failed, successful, verified, recoverable, corrupted.
|
|
||||||
//
|
|
||||||
// `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht
|
|
||||||
// grün: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
|
|
||||||
// sich daraus etwas wiederherstellen lässt. Ein grünes Abzeichen läse sich
|
|
||||||
// hier als "geprüft und in Ordnung", und genau diese Verwechslung soll die
|
|
||||||
// ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf
|
|
||||||
// `recoverable`; alles davor ist ein Indiz.
|
|
||||||
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
|
|
||||||
verified: { tone: 'info', label: 'Geprüft' },
|
|
||||||
successful: { tone: 'neutral', label: 'Gesichert, ungeprüft' },
|
|
||||||
corrupted: { tone: 'critical', label: 'Beschädigt' },
|
|
||||||
|
|
||||||
// --- Prüfergebnisse ---
|
|
||||||
clean: { tone: 'healthy', label: 'Ohne Befund' },
|
|
||||||
findings: { tone: 'critical', label: 'Mit Befund' },
|
|
||||||
|
|
||||||
// --- Repositories ---
|
|
||||||
active: { tone: 'healthy', label: 'Aktiv' },
|
|
||||||
read_only: { tone: 'warning', label: 'Nur lesend' },
|
|
||||||
unavailable: { tone: 'critical', label: 'Nicht erreichbar' },
|
|
||||||
maintenance: { tone: 'warning', label: 'Wartung' },
|
|
||||||
|
|
||||||
// --- Durchsetzungsstufe des Löschschutzes (Phase 11) ---
|
|
||||||
filesystem: { tone: 'healthy', label: 'Dateisystem' },
|
|
||||||
storage: { tone: 'healthy', label: 'Speichersystem' },
|
|
||||||
advisory: { tone: 'warning', label: 'Nur Software' },
|
|
||||||
none: { tone: 'critical', label: 'Kein Schutz' },
|
|
||||||
|
|
||||||
// --- Gesundheit ---
|
|
||||||
healthy: { tone: 'healthy', label: 'Fehlerfrei' },
|
|
||||||
degraded: { tone: 'warning', label: 'Eingeschränkt' },
|
|
||||||
critical: { tone: 'critical', label: 'Kritisch' },
|
|
||||||
unknown: { tone: 'neutral', label: 'Unbekannt' },
|
|
||||||
|
|
||||||
// --- Meldungen ---
|
|
||||||
open: { tone: 'critical', label: 'Offen' },
|
|
||||||
acknowledged: { tone: 'warning', label: 'Bestätigt' },
|
|
||||||
resolved: { tone: 'healthy', label: 'Erledigt' },
|
|
||||||
suppressed: { tone: 'neutral', label: 'Unterdrückt' },
|
|
||||||
|
|
||||||
// --- Schweregrade ---
|
|
||||||
info: { tone: 'info', label: 'Information' },
|
|
||||||
low: { tone: 'info', label: 'Gering' },
|
|
||||||
medium: { tone: 'warning', label: 'Mittel' },
|
|
||||||
warning: { tone: 'warning', label: 'Warnung' },
|
|
||||||
high: { tone: 'high', label: 'Hoch' },
|
|
||||||
|
|
||||||
// --- Agenten ---
|
|
||||||
online: { tone: 'healthy', label: 'Verbunden' },
|
|
||||||
offline: { tone: 'critical', label: 'Nicht erreichbar' },
|
|
||||||
stale: { tone: 'warning', label: 'Veraltet' },
|
|
||||||
revoked: { tone: 'neutral', label: 'Gesperrt' },
|
|
||||||
|
|
||||||
// --- Ransomware-Einstufung (Phase 16) ---
|
|
||||||
elevated: { tone: 'warning', label: 'Auffällig' },
|
|
||||||
// "unknown" oben deckt den Fall unter fünf Vergleichsläufen mit ab.
|
|
||||||
};
|
|
||||||
|
|
||||||
const toneClasses: Readonly<Record<StatusTone, string>> = {
|
|
||||||
healthy: 'bg-healthy-bg text-healthy',
|
|
||||||
warning: 'bg-warning-bg text-warning',
|
|
||||||
high: 'bg-high-bg text-high',
|
|
||||||
critical: 'bg-critical-bg text-critical',
|
|
||||||
info: 'bg-info-bg text-info',
|
|
||||||
neutral: 'bg-neutral-bg text-neutral',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Liefert Ton und Beschriftung zu einem Fachbegriff. */
|
|
||||||
export function resolveStatus(rawStatus: string | null | undefined): StatusDefinition {
|
|
||||||
if (!rawStatus) {
|
|
||||||
return { tone: 'neutral', label: 'Unbekannt' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedStatus = rawStatus.toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
statusDefinitions[normalizedStatus] ?? {
|
|
||||||
// Unbekannt bleibt neutral. Grün wäre hier der bequeme und gefährliche
|
|
||||||
// Standard: Ein neuer Fehlerzustand des Servers sähe aus wie Erfolg.
|
|
||||||
tone: 'neutral',
|
|
||||||
label: rawStatus,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({
|
|
||||||
status,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
showDot = true,
|
|
||||||
}: {
|
|
||||||
readonly status: string | null | undefined;
|
|
||||||
/** Überschreibt die Beschriftung, der Ton bleibt. */
|
|
||||||
readonly label?: ReactNode | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
readonly showDot?: boolean | undefined;
|
|
||||||
}) {
|
|
||||||
const resolved = resolveStatus(status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium',
|
|
||||||
toneClasses[resolved.tone],
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showDot ? (
|
|
||||||
<span className="size-1.5 rounded-full bg-current" aria-hidden />
|
|
||||||
) : null}
|
|
||||||
{label ?? resolved.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur der farbige Punkt — für dichte Tabellen. */
|
|
||||||
export function StatusDot({
|
|
||||||
status,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
readonly status: string | null | undefined;
|
|
||||||
readonly className?: string | undefined;
|
|
||||||
}) {
|
|
||||||
const resolved = resolveStatus(status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn('inline-flex items-center gap-2', className)}
|
|
||||||
title={resolved.label}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn('size-2 rounded-full', toneClasses[resolved.tone].split(' ')[1])}
|
|
||||||
style={{ backgroundColor: 'currentColor' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="sr-only">{resolved.label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Kurzmeldungen nach einer Handlung.
|
|
||||||
*
|
|
||||||
* Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die
|
|
||||||
* `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht
|
|
||||||
* funktioniert" — und damit ist weder ihm noch einer späteren Fehlersuche
|
|
||||||
* geholfen. Die Kennung lässt sich mit einem Klick kopieren, weil sie sonst
|
|
||||||
* abgetippt wird und dabei ein Zeichen verlorengeht.
|
|
||||||
*
|
|
||||||
* Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer
|
|
||||||
* gerade wegsieht, während eine Löschung fehlschlägt, hätte sonst keine
|
|
||||||
* Chance, es zu bemerken.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Check, Copy, X } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export interface ToastMessage {
|
|
||||||
readonly id: string;
|
|
||||||
readonly tone: 'success' | 'error' | 'info';
|
|
||||||
readonly title: string;
|
|
||||||
readonly description?: string | undefined;
|
|
||||||
readonly requestId?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastContextValue {
|
|
||||||
readonly showSuccess: (title: string, description?: string) => void;
|
|
||||||
readonly showError: (title: string, description?: string, requestId?: string) => void;
|
|
||||||
readonly showInfo: (title: string, description?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
||||||
|
|
||||||
/** Zugriff auf die Kurzmeldungen. */
|
|
||||||
export function useToast(): ToastContextValue {
|
|
||||||
const contextValue = useContext(ToastContext);
|
|
||||||
|
|
||||||
if (!contextValue) {
|
|
||||||
throw new Error('useToast benötigt einen ToastProvider im Elternbaum.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return contextValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { readonly children: ReactNode }) {
|
|
||||||
const [activeMessages, setActiveMessages] = useState<readonly ToastMessage[]>([]);
|
|
||||||
|
|
||||||
const removeMessage = useCallback((messageId: string) => {
|
|
||||||
setActiveMessages((previousMessages) =>
|
|
||||||
previousMessages.filter((message) => message.id !== messageId),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addMessage = useCallback(
|
|
||||||
(message: Omit<ToastMessage, 'id'>) => {
|
|
||||||
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
|
|
||||||
setActiveMessages((previousMessages) => [
|
|
||||||
...previousMessages,
|
|
||||||
{ ...message, id: messageId },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Fehler bleiben stehen, bis sie jemand schließt. Alles andere
|
|
||||||
// verschwindet nach fünf Sekunden.
|
|
||||||
if (message.tone !== 'error') {
|
|
||||||
window.setTimeout(() => removeMessage(messageId), 5000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[removeMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextValue = useMemo<ToastContextValue>(
|
|
||||||
() => ({
|
|
||||||
showSuccess: (title, description) => addMessage({ tone: 'success', title, description }),
|
|
||||||
showError: (title, description, requestId) =>
|
|
||||||
addMessage({ tone: 'error', title, description, requestId }),
|
|
||||||
showInfo: (title, description) => addMessage({ tone: 'info', title, description }),
|
|
||||||
}),
|
|
||||||
[addMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-[min(26rem,calc(100vw-2rem))] flex-col gap-2"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{activeMessages.map((message) => (
|
|
||||||
<ToastCard
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
onDismiss={() => removeMessage(message.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToastCard({
|
|
||||||
message,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
readonly message: ToastMessage;
|
|
||||||
readonly onDismiss: () => void;
|
|
||||||
}) {
|
|
||||||
const [hasCopied, setHasCopied] = useState(false);
|
|
||||||
|
|
||||||
const toneClasses = {
|
|
||||||
success: 'border-l-healthy',
|
|
||||||
error: 'border-l-critical',
|
|
||||||
info: 'border-l-info',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const copyRequestId = async () => {
|
|
||||||
if (!message.requestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(message.requestId);
|
|
||||||
setHasCopied(true);
|
|
||||||
window.setTimeout(() => setHasCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
// Ohne Zwischenablage-Recht bleibt die Kennung lesbar am Bildschirm —
|
|
||||||
// das ist der Zweck, das Kopieren ist die Bequemlichkeit.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-auto border border-l-4 border-line-strong bg-raised p-3',
|
|
||||||
toneClasses[message.tone],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-fg">{message.title}</p>
|
|
||||||
{message.description ? (
|
|
||||||
<p className="mt-0.5 text-sm text-fg-muted">{message.description}</p>
|
|
||||||
) : null}
|
|
||||||
{message.requestId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void copyRequestId()}
|
|
||||||
className="mt-2 inline-flex items-center gap-1.5 rounded bg-sunken px-2 py-1 font-mono text-[11px] text-fg-muted transition-colors hover:text-fg"
|
|
||||||
title="Vorgangsnummer kopieren"
|
|
||||||
>
|
|
||||||
{hasCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
||||||
{message.requestId}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDismiss}
|
|
||||||
className="shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg"
|
|
||||||
aria-label="Meldung schließen"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
/** Sammelausfuhr der Bausteine — ein Importpfad statt zehn. */
|
|
||||||
|
|
||||||
export { Button, buttonVariants, type ButtonProps } from './Button';
|
|
||||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
|
||||||
export { DataTable, type TableColumn } from './DataTable';
|
|
||||||
export {
|
|
||||||
ConfirmDialog,
|
|
||||||
DialogBody,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
DialogTrigger,
|
|
||||||
} from './Dialog';
|
|
||||||
export { Callout, DetailItem, EmptyState, ErrorState, LoadingState, NotMeasured } from './Feedback';
|
|
||||||
export { CheckboxField, Field, SelectInput, TextArea, TextInput } from './Form';
|
|
||||||
export { resolveStatus, StatusBadge, StatusDot, type StatusTone } from './StatusBadge';
|
|
||||||
export { ToastProvider, useToast, type ToastMessage } from './Toast';
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ereignisprotokoll.
|
|
||||||
*
|
|
||||||
* Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per
|
|
||||||
* Anwendungslogik. Es lässt sich weder aus der Oberfläche noch über die API
|
|
||||||
* ändern oder löschen — ein Protokoll, das sich bereinigen ließe, wäre
|
|
||||||
* keines. Deshalb gibt es hier keine Handlungen, nur eine Suche.
|
|
||||||
*
|
|
||||||
* Die Adresse in der Spalte „Herkunft" ist die der tatsächlichen Verbindung.
|
|
||||||
* Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind fälschbar, und
|
|
||||||
* im Audit stünde sonst eine beliebige Adresse. Hinter einem Reverse Proxy —
|
|
||||||
* also im Normalfall dieser Anlage — steht dort dessen Adresse.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RefreshCw, Search } from 'lucide-react';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { requestApi } from '@/api/client';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
TextInput,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils';
|
|
||||||
|
|
||||||
/** Ein Eintrag des Auditprotokolls. */
|
|
||||||
interface AuditEvent {
|
|
||||||
readonly id: string;
|
|
||||||
readonly created_at: string;
|
|
||||||
readonly action: string;
|
|
||||||
readonly actor_username?: string;
|
|
||||||
readonly entity_type?: string;
|
|
||||||
readonly entity_id?: string;
|
|
||||||
readonly result: string;
|
|
||||||
readonly ip_address?: string;
|
|
||||||
readonly correlation_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die letzten Ereignisse. */
|
|
||||||
async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> {
|
|
||||||
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', {
|
|
||||||
signal: abortSignal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventsPage() {
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const eventsResource = useApiResource<readonly AuditEvent[]>(
|
|
||||||
useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filterung im Browser.
|
|
||||||
*
|
|
||||||
* Die Liste umfasst höchstens hundert Einträge; eine serverseitige Suche
|
|
||||||
* wäre hier mehr Vertrag als Nutzen. Bei größeren Beständen gehört sie
|
|
||||||
* auf den Server — das ist eine bewusste Grenze, keine Auslassung.
|
|
||||||
*/
|
|
||||||
const filteredEvents = useMemo(() => {
|
|
||||||
const allEvents = eventsResource.data ?? [];
|
|
||||||
const normalizedTerm = searchTerm.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!normalizedTerm) {
|
|
||||||
return allEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
return allEvents.filter((auditEvent) =>
|
|
||||||
[
|
|
||||||
auditEvent.action,
|
|
||||||
auditEvent.actor_username,
|
|
||||||
auditEvent.entity_type,
|
|
||||||
auditEvent.entity_id,
|
|
||||||
auditEvent.correlation_id,
|
|
||||||
]
|
|
||||||
.filter((field): field is string => typeof field === 'string')
|
|
||||||
.some((field) => field.toLowerCase().includes(normalizedTerm)),
|
|
||||||
);
|
|
||||||
}, [eventsResource.data, searchTerm]);
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<AuditEvent>> = [
|
|
||||||
{
|
|
||||||
key: 'created_at',
|
|
||||||
header: 'Zeitpunkt',
|
|
||||||
render: (auditEvent) => (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-fg">{formatDateTime(auditEvent.created_at)}</p>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{formatRelativeTime(auditEvent.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'action',
|
|
||||||
header: 'Handlung',
|
|
||||||
render: (auditEvent) => (
|
|
||||||
<code className="font-mono text-xs text-fg">{auditEvent.action}</code>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actor',
|
|
||||||
header: 'Wer',
|
|
||||||
render: (auditEvent) => (
|
|
||||||
<span className="text-sm">
|
|
||||||
{/* Ein fehlender Akteur ist die Anlage selbst — etwa bei der
|
|
||||||
Erstinbetriebnahme oder einem Zeitplanlauf. */}
|
|
||||||
{auditEvent.actor_username ?? (
|
|
||||||
<span className="text-fg-muted">System</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'entity',
|
|
||||||
header: 'Gegenstand',
|
|
||||||
render: (auditEvent) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm text-fg">{auditEvent.entity_type ?? '—'}</p>
|
|
||||||
{auditEvent.entity_id ? (
|
|
||||||
<p className="truncate font-mono text-xs text-fg-muted">
|
|
||||||
{auditEvent.entity_id}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'result',
|
|
||||||
header: 'Ausgang',
|
|
||||||
render: (auditEvent) => <StatusBadge status={auditEvent.result} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'correlation',
|
|
||||||
header: 'Vorgang',
|
|
||||||
render: (auditEvent) =>
|
|
||||||
auditEvent.correlation_id ? (
|
|
||||||
<code className="font-mono text-xs text-fg-subtle">
|
|
||||||
{auditEvent.correlation_id.slice(0, 8)}…
|
|
||||||
</code>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (eventsResource.loadState === 'failed' && eventsResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Ereignisprotokoll" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(eventsResource.loadError)}
|
|
||||||
requestId={eventsResource.loadError.requestId}
|
|
||||||
onRetry={eventsResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Ereignisprotokoll"
|
|
||||||
description="Sicherheitsrelevante Handlungen. Nur lesbar."
|
|
||||||
actions={
|
|
||||||
<Button variant="secondary" onClick={eventsResource.reload}>
|
|
||||||
<RefreshCw />
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout tone="info" className="mb-4">
|
|
||||||
Append-only: weder hier noch über die API änderbar.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="border-b border-line px-5 py-3">
|
|
||||||
<div className="relative max-w-sm">
|
|
||||||
<Search
|
|
||||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-subtle"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
className="pl-9"
|
|
||||||
placeholder="Handlung, Benutzer oder Vorgangsnummer"
|
|
||||||
aria-label="Ereignisse durchsuchen"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(changeEvent) => setSearchTerm(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={filteredEvents}
|
|
||||||
getRowKey={(auditEvent) => auditEvent.id}
|
|
||||||
isLoading={eventsResource.loadState === 'loading'}
|
|
||||||
emptyTitle={
|
|
||||||
searchTerm ? 'Kein Treffer' : 'Das Auditprotokoll ist leer'
|
|
||||||
}
|
|
||||||
emptyDescription={
|
|
||||||
searchTerm
|
|
||||||
? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.'
|
|
||||||
: 'Sobald jemand eine sicherheitsrelevante Handlung ausführt, erscheint sie hier.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
/**
|
|
||||||
* Rollen und Berechtigungen.
|
|
||||||
*
|
|
||||||
* Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste
|
|
||||||
* mit 34 Einträgen dazu führt, dass jemand „alles" ankreuzt. Zwei Paare sind
|
|
||||||
* bewusst getrennt und werden hier ausdrücklich erklärt:
|
|
||||||
*
|
|
||||||
* - `restores.execute` enthält **nicht** `restores.overwrite`. Wiederherstellen
|
|
||||||
* ist etwas anderes als vorhandene Daten überschreiben.
|
|
||||||
* - `backups.delete` enthält **nicht** `immutability.manage`. Wer aufräumen
|
|
||||||
* darf, darf keinen Löschschutz aufheben — das ist der Schritt, der einem
|
|
||||||
* Angreifer den Weg öffnet.
|
|
||||||
*
|
|
||||||
* Mitgelieferte Rollen sind unveränderlich und werden als solche gezeigt,
|
|
||||||
* statt eine Maske anzubieten, die der Server ablehnt.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Lock, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CheckboxField,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import {
|
|
||||||
createRole,
|
|
||||||
deleteRole,
|
|
||||||
listPermissions,
|
|
||||||
listRoles,
|
|
||||||
type Permission,
|
|
||||||
type Role,
|
|
||||||
} from './administrationApi';
|
|
||||||
|
|
||||||
/** Erklärungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */
|
|
||||||
const PERMISSION_NOTES: Readonly<Record<string, string>> = {
|
|
||||||
'restores.overwrite':
|
|
||||||
'Nicht in restores.execute enthalten. Erlaubt das Überschreiben vorhandener Daten am Ziel.',
|
|
||||||
'immutability.manage':
|
|
||||||
'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Löschschutz und Legal Hold.',
|
|
||||||
'verification.restore_test':
|
|
||||||
'Liest ein Backup vollständig und schreibt es versuchsweise zurück — die einzige Prüfung, die ein Nachweis ist.',
|
|
||||||
'settings.write':
|
|
||||||
'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfährt.',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RolesPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
||||||
const [rolePendingDeletion, setRolePendingDeletion] = useState<Role | null>(null);
|
|
||||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
|
||||||
|
|
||||||
const [draftName, setDraftName] = useState('');
|
|
||||||
const [draftDescription, setDraftDescription] = useState('');
|
|
||||||
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const rolesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRoles(abortSignal), []),
|
|
||||||
);
|
|
||||||
const permissionsResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listPermissions(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('roles.write');
|
|
||||||
|
|
||||||
const createMutation = useMutation(createRole, {
|
|
||||||
onSuccess: (role) => {
|
|
||||||
toast.showSuccess('Rolle angelegt', role.name);
|
|
||||||
setIsCreateDialogOpen(false);
|
|
||||||
setDraftName('');
|
|
||||||
setDraftDescription('');
|
|
||||||
setDraftPermissions([]);
|
|
||||||
rolesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Rolle ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation(deleteRole, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Rolle gelöscht');
|
|
||||||
setRolePendingDeletion(null);
|
|
||||||
rolesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const roles = rolesResource.data ?? [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Berechtigungen nach Bereich gruppiert.
|
|
||||||
*
|
|
||||||
* Der Rückfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein
|
|
||||||
* `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die
|
|
||||||
* Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als
|
|
||||||
* Muster trotzdem falsch.
|
|
||||||
*/
|
|
||||||
const permissionsByCategory = useMemo(() => {
|
|
||||||
const grouped = new Map<string, Permission[]>();
|
|
||||||
|
|
||||||
for (const permission of permissionsResource.data ?? []) {
|
|
||||||
// Ohne ausdrückliche Kategorie dient der Teil vor dem Punkt als Bereich —
|
|
||||||
// "backups.read" gehört zu "backups".
|
|
||||||
const categoryName =
|
|
||||||
permission.category ?? permission.name.split('.')[0] ?? 'sonstige';
|
|
||||||
const existing = grouped.get(categoryName) ?? [];
|
|
||||||
existing.push(permission);
|
|
||||||
grouped.set(categoryName, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
||||||
}, [permissionsResource.data]);
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<Role>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Rolle',
|
|
||||||
render: (role) => (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{role.is_builtin ? (
|
|
||||||
<Lock className="size-3.5 shrink-0 text-fg-subtle" aria-label="mitgeliefert" />
|
|
||||||
) : null}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{role.name}</p>
|
|
||||||
{role.description ? (
|
|
||||||
<p className="truncate text-xs text-fg-muted">{role.description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'permissions',
|
|
||||||
header: 'Berechtigungen',
|
|
||||||
numeric: true,
|
|
||||||
render: (role) => role.permissions.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'users',
|
|
||||||
header: 'Zugewiesen',
|
|
||||||
numeric: true,
|
|
||||||
render: (role) => role.user_count ?? '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'kind',
|
|
||||||
header: 'Art',
|
|
||||||
render: (role) => (
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{role.is_builtin ? 'Mitgeliefert (unveränderlich)' : 'Selbst angelegt'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (role) =>
|
|
||||||
mayWrite && !role.is_builtin ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Löschen"
|
|
||||||
aria-label={`Rolle ${role.name} löschen`}
|
|
||||||
onClick={(clickEvent) => {
|
|
||||||
clickEvent.stopPropagation();
|
|
||||||
setRolePendingDeletion(role);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (rolesResource.loadState === 'failed' && rolesResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Rollen" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(rolesResource.loadError)}
|
|
||||||
requestId={rolesResource.loadError.requestId}
|
|
||||||
onRetry={rolesResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Rollen"
|
|
||||||
description="Wer was darf. Geprüft wird immer auf dem Server."
|
|
||||||
actions={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Rolle anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={roles}
|
|
||||||
getRowKey={(role) => role.id}
|
|
||||||
onRowClick={(role) => setSelectedRole(role)}
|
|
||||||
isLoading={rolesResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Keine Rollen"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedRole ? (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-sm font-semibold text-fg">{selectedRole.name}</h2>
|
|
||||||
<p className="mt-1 text-sm text-fg-muted">
|
|
||||||
{selectedRole.description ?? 'Ohne Beschreibung'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}>
|
|
||||||
Schließen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
{selectedRole.is_builtin ? (
|
|
||||||
<Callout tone="info" className="mb-4">
|
|
||||||
Mitgelieferte Rollen sind unveränderlich.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{selectedRole.permissions.map((permissionName) => (
|
|
||||||
<span
|
|
||||||
key={permissionName}
|
|
||||||
className="rounded bg-sunken px-2 py-1 font-mono text-xs text-fg-muted"
|
|
||||||
title={PERMISSION_NOTES[permissionName]}
|
|
||||||
>
|
|
||||||
{permissionName}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* --- Rolle anlegen --- */}
|
|
||||||
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
||||||
<DialogContent size="lg">
|
|
||||||
<DialogHeader
|
|
||||||
title="Rolle anlegen"
|
|
||||||
description="Vergeben Sie nur, was gebraucht wird."
|
|
||||||
/>
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
value={draftName}
|
|
||||||
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
|
|
||||||
placeholder="Bereitschaft Nacht"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Beschreibung"
|
|
||||||
value={draftDescription}
|
|
||||||
onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)}
|
|
||||||
placeholder="Darf Läufe anstoßen und Meldungen bestätigen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-4 border-t border-line pt-4">
|
|
||||||
{permissionsByCategory.map(([categoryName, categoryPermissions]) => (
|
|
||||||
<div key={categoryName}>
|
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
{categoryName}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{categoryPermissions.map((permission) => (
|
|
||||||
<CheckboxField
|
|
||||||
key={permission.name}
|
|
||||||
checked={draftPermissions.includes(permission.name)}
|
|
||||||
onCheckedChange={(isChecked) =>
|
|
||||||
setDraftPermissions((currentPermissions) =>
|
|
||||||
isChecked
|
|
||||||
? [...currentPermissions, permission.name]
|
|
||||||
: currentPermissions.filter(
|
|
||||||
(permissionName) => permissionName !== permission.name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<code className="font-mono text-xs">{permission.name}</code>
|
|
||||||
}
|
|
||||||
hint={PERMISSION_NOTES[permission.name] ?? permission.description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={createMutation.isRunning}
|
|
||||||
disabled={!draftName.trim() || draftPermissions.length === 0}
|
|
||||||
onClick={() =>
|
|
||||||
void createMutation.run({
|
|
||||||
name: draftName.trim(),
|
|
||||||
permissions: draftPermissions,
|
|
||||||
...(draftDescription.trim() ? { description: draftDescription.trim() } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Anlegen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={rolePendingDeletion !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)}
|
|
||||||
title="Rolle löschen"
|
|
||||||
description={rolePendingDeletion?.name}
|
|
||||||
confirmLabel="Löschen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (rolePendingDeletion) {
|
|
||||||
void deleteMutation.run(rolePendingDeletion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="warning">
|
|
||||||
Konten mit dieser Rolle verlieren die Rechte sofort.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,405 +0,0 @@
|
|||||||
/**
|
|
||||||
* Einstellungen: Benachrichtigungswege und der eigene zweite Faktor.
|
|
||||||
*
|
|
||||||
* Der wichtigste Satz dieser Seite steht bei der Schwelle: **Nur neue Meldungen
|
|
||||||
* werden zugestellt, und nur ab dem eingestellten Schweregrad.** Ohne Schwelle
|
|
||||||
* schaltet der Bereitschaftsdienst nach einer Woche die Benachrichtigungen ab —
|
|
||||||
* und dann kommt auch die kritische nicht mehr an.
|
|
||||||
*
|
|
||||||
* Webhooks verlangen HTTPS. Ein Schalter „Zertifikat egal" existiert nicht, und
|
|
||||||
* interne Adressen werden abgelehnt: `169.254.169.254` ist der Metadatendienst
|
|
||||||
* einer Cloud, `127.0.0.1:5432` die eigene Datenbank.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Bell, Plus, ShieldCheck, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
SelectInput,
|
|
||||||
StatusBadge,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import type { CurrentUser } from '@/types/auth';
|
|
||||||
import {
|
|
||||||
confirmMfa,
|
|
||||||
createNotificationChannel,
|
|
||||||
deleteNotificationChannel,
|
|
||||||
enrollMfa,
|
|
||||||
listNotificationChannels,
|
|
||||||
type MfaEnrollment,
|
|
||||||
type NotificationChannel,
|
|
||||||
} from './administrationApi';
|
|
||||||
|
|
||||||
export function SettingsPage({
|
|
||||||
grantedPermissions,
|
|
||||||
currentUser,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly currentUser: CurrentUser;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isChannelDialogOpen, setIsChannelDialogOpen] = useState(false);
|
|
||||||
const [channelPendingDeletion, setChannelPendingDeletion] =
|
|
||||||
useState<NotificationChannel | null>(null);
|
|
||||||
const [mfaEnrollment, setMfaEnrollment] = useState<MfaEnrollment | null>(null);
|
|
||||||
const [mfaCode, setMfaCode] = useState('');
|
|
||||||
const [isMfaConfirmed, setIsMfaConfirmed] = useState(false);
|
|
||||||
|
|
||||||
const [draftName, setDraftName] = useState('');
|
|
||||||
const [draftType, setDraftType] = useState('email');
|
|
||||||
const [draftSeverity, setDraftSeverity] = useState('high');
|
|
||||||
const [draftTarget, setDraftTarget] = useState('');
|
|
||||||
|
|
||||||
const channelsResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listNotificationChannels(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWriteSettings =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('settings.write');
|
|
||||||
|
|
||||||
const createChannelMutation = useMutation(createNotificationChannel, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Benachrichtigungsweg angelegt');
|
|
||||||
setIsChannelDialogOpen(false);
|
|
||||||
setDraftName('');
|
|
||||||
setDraftTarget('');
|
|
||||||
channelsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Der Weg ließ sich nicht anlegen',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteChannelMutation = useMutation(deleteNotificationChannel, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Benachrichtigungsweg entfernt');
|
|
||||||
setChannelPendingDeletion(null);
|
|
||||||
channelsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const enrollMfaMutation = useMutation(enrollMfa, {
|
|
||||||
onSuccess: (enrollment) => setMfaEnrollment(enrollment),
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Die Einrichtung ließ sich nicht beginnen',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmMfaMutation = useMutation(confirmMfa, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Zweiter Faktor eingerichtet',
|
|
||||||
'Ab der nächsten Anmeldung wird er verlangt.',
|
|
||||||
);
|
|
||||||
setIsMfaConfirmed(true);
|
|
||||||
setMfaEnrollment(null);
|
|
||||||
setMfaCode('');
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Der Code wurde nicht anerkannt',
|
|
||||||
`${describeApiError(apiError)} — prüfen Sie die Uhrzeit des Geräts; TOTP hängt an der Zeit.`,
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const channels = channelsResource.data ?? [];
|
|
||||||
const mfaIsActive = isMfaConfirmed || currentUser.mfa_enabled;
|
|
||||||
|
|
||||||
const channelColumns: ReadonlyArray<TableColumn<NotificationChannel>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Weg',
|
|
||||||
render: (channel) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{channel.name}</p>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{channel.channel_type === 'email' ? 'E-Mail' : 'Webhook'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'severity',
|
|
||||||
header: 'Ab Schweregrad',
|
|
||||||
render: (channel) => <StatusBadge status={channel.minimum_severity} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (channel) =>
|
|
||||||
mayWriteSettings ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Entfernen"
|
|
||||||
aria-label={`${channel.name} entfernen`}
|
|
||||||
onClick={() => setChannelPendingDeletion(channel)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const webhookIsSecure =
|
|
||||||
draftType !== 'webhook' || draftTarget.trim().toLowerCase().startsWith('https://');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Einstellungen"
|
|
||||||
description="Benachrichtigungen und der eigene Zugang."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* --- Eigener zweiter Faktor --- */}
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader
|
|
||||||
title="Zweiter Faktor"
|
|
||||||
description="Für das eigene Konto."
|
|
||||||
action={
|
|
||||||
mfaIsActive ? (
|
|
||||||
<StatusBadge status="healthy" label="Eingerichtet" />
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="warning" label="Fehlt" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
{mfaIsActive ? (
|
|
||||||
<p className="text-sm text-fg-muted">
|
|
||||||
Ihr Konto verlangt bei der Anmeldung einen zeitbasierten Code. Ein
|
|
||||||
abgefangener Code gilt im selben 30-Sekunden-Fenster kein zweites
|
|
||||||
Mal.
|
|
||||||
</p>
|
|
||||||
) : mfaEnrollment ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt">
|
|
||||||
Tragen Sie es in Ihre Authentifizierungs-App ein und
|
|
||||||
bestätigen Sie mit dem angezeigten Code. Erst dann wird der
|
|
||||||
zweite Faktor scharf — sonst hätten Sie ein Geheimnis
|
|
||||||
hinterlegt, dessen App Sie nie geprüft haben.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-line bg-sunken p-3">
|
|
||||||
<p className="text-xs text-fg-subtle">Geheimnis (Base32)</p>
|
|
||||||
<code className="mt-1 block break-all font-mono text-sm text-fg">
|
|
||||||
{mfaEnrollment.secret}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
|
||||||
<TextInput
|
|
||||||
label="Code aus der App"
|
|
||||||
className="w-40 font-mono"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
maxLength={6}
|
|
||||||
value={mfaCode}
|
|
||||||
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
|
|
||||||
placeholder="123456"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={confirmMfaMutation.isRunning}
|
|
||||||
disabled={mfaCode.trim().length !== 6}
|
|
||||||
onClick={() => void confirmMfaMutation.run(mfaCode.trim())}
|
|
||||||
>
|
|
||||||
Bestätigen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-fg-muted">
|
|
||||||
Ihr Konto ist nur durch sein Passwort geschützt. Bei einem Konto
|
|
||||||
mit Löschrecht ist das der Punkt, an dem eine Übernahme zum
|
|
||||||
Datenverlust führt.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={enrollMfaMutation.isRunning}
|
|
||||||
onClick={() => void enrollMfaMutation.run()}
|
|
||||||
>
|
|
||||||
<ShieldCheck />
|
|
||||||
Zweiten Faktor einrichten
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* --- Benachrichtigungswege --- */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="Benachrichtigungswege"
|
|
||||||
description="Wohin Meldungen zugestellt werden."
|
|
||||||
action={
|
|
||||||
mayWriteSettings ? (
|
|
||||||
<Button variant="primary" size="sm" onClick={() => setIsChannelDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Weg anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{channelsResource.loadState === 'failed' && channelsResource.loadError ? (
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(channelsResource.loadError)}
|
|
||||||
requestId={channelsResource.loadError.requestId}
|
|
||||||
onRetry={channelsResource.reload}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={channelColumns}
|
|
||||||
rows={channels}
|
|
||||||
getRowKey={(channel) => channel.id}
|
|
||||||
isLoading={channelsResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Benachrichtigungsweg"
|
|
||||||
emptyDescription="Ohne Weg erfährt niemand von einem Ausfall."
|
|
||||||
emptyAction={
|
|
||||||
mayWriteSettings ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
|
|
||||||
<Bell />
|
|
||||||
Ersten Weg anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* --- Weg anlegen --- */}
|
|
||||||
<DialogRoot open={isChannelDialogOpen} onOpenChange={setIsChannelDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader title="Benachrichtigungsweg anlegen" />
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="Bezeichnung"
|
|
||||||
required
|
|
||||||
value={draftName}
|
|
||||||
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
|
|
||||||
placeholder="Bereitschaft"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label="Art"
|
|
||||||
value={draftType}
|
|
||||||
onChange={(changeEvent) => setDraftType(changeEvent.target.value)}
|
|
||||||
>
|
|
||||||
<option value="email">E-Mail</option>
|
|
||||||
<option value="webhook">Webhook</option>
|
|
||||||
</SelectInput>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label={draftType === 'email' ? 'Empfaengeradresse' : 'Webhook-Adresse'}
|
|
||||||
required
|
|
||||||
className={draftType === 'webhook' ? 'font-mono' : undefined}
|
|
||||||
value={draftTarget}
|
|
||||||
onChange={(changeEvent) => setDraftTarget(changeEvent.target.value)}
|
|
||||||
placeholder={
|
|
||||||
draftType === 'email' ? 'bereitschaft@example.de' : 'https://hooks.example/…'
|
|
||||||
}
|
|
||||||
error={
|
|
||||||
draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure
|
|
||||||
? 'Nur https. Ein Webhook trägt oft ein Token im Pfad.'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label="Ab Schweregrad zustellen"
|
|
||||||
value={draftSeverity}
|
|
||||||
onChange={(changeEvent) => setDraftSeverity(changeEvent.target.value)}
|
|
||||||
hint="Nur neue Meldungen werden zugestellt, aktualisierte nicht."
|
|
||||||
>
|
|
||||||
<option value="info">Information (alles)</option>
|
|
||||||
<option value="warning">Warnung</option>
|
|
||||||
<option value="high">Hoch (empfohlen)</option>
|
|
||||||
<option value="critical">Nur kritisch</option>
|
|
||||||
</SelectInput>
|
|
||||||
|
|
||||||
<Callout tone="info">
|
|
||||||
Eine zu niedrige Schwelle ist gefährlicher als eine zu hohe.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
{draftType === 'webhook' ? (
|
|
||||||
<Callout tone="warning">
|
|
||||||
Interne Adressen werden abgelehnt — geprüft beim Anlegen und vor jedem Aufruf.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsChannelDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={createChannelMutation.isRunning}
|
|
||||||
disabled={!draftName.trim() || !draftTarget.trim() || !webhookIsSecure}
|
|
||||||
onClick={() =>
|
|
||||||
void createChannelMutation.run({
|
|
||||||
name: draftName.trim(),
|
|
||||||
channel_type: draftType,
|
|
||||||
minimum_severity: draftSeverity,
|
|
||||||
configuration:
|
|
||||||
draftType === 'email'
|
|
||||||
? { recipient: draftTarget.trim() }
|
|
||||||
: { url: draftTarget.trim() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Anlegen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={channelPendingDeletion !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setChannelPendingDeletion(null)}
|
|
||||||
title="Benachrichtigungsweg entfernen"
|
|
||||||
description={channelPendingDeletion?.name}
|
|
||||||
confirmLabel="Entfernen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteChannelMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (channelPendingDeletion) {
|
|
||||||
void deleteChannelMutation.run(channelPendingDeletion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="warning">
|
|
||||||
Danach wird über diesen Weg nichts mehr zugestellt.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,453 +0,0 @@
|
|||||||
/**
|
|
||||||
* Benutzer.
|
|
||||||
*
|
|
||||||
* Der Schutz des letzten Administrators liegt beim Server — die Oberfläche
|
|
||||||
* kann ihn nicht durchsetzen, weil sie nicht weiß, wer sonst noch Administrator
|
|
||||||
* ist. Was sie tut: den Serverfehler in einen verständlichen Satz übersetzen,
|
|
||||||
* statt „LAST_ADMINISTRATOR" anzuzeigen.
|
|
||||||
*
|
|
||||||
* Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein
|
|
||||||
* Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffällt.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Plus, ShieldOff, Trash2, UserCog } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CheckboxField,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatRelativeTime } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
createUser,
|
|
||||||
deleteUser,
|
|
||||||
disableUserMfa,
|
|
||||||
listRoles,
|
|
||||||
listUsers,
|
|
||||||
updateUser,
|
|
||||||
type User,
|
|
||||||
} from './administrationApi';
|
|
||||||
|
|
||||||
export function UsersPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
||||||
const [userPendingDeletion, setUserPendingDeletion] = useState<User | null>(null);
|
|
||||||
const [userPendingMfaReset, setUserPendingMfaReset] = useState<User | null>(null);
|
|
||||||
const [userBeingEdited, setUserBeingEdited] = useState<User | null>(null);
|
|
||||||
|
|
||||||
const [draftUsername, setDraftUsername] = useState('');
|
|
||||||
const [draftEmail, setDraftEmail] = useState('');
|
|
||||||
const [draftPassword, setDraftPassword] = useState('');
|
|
||||||
const [draftRoles, setDraftRoles] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const usersResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listUsers(abortSignal), []),
|
|
||||||
);
|
|
||||||
const rolesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRoles(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('users.write');
|
|
||||||
|
|
||||||
const createMutation = useMutation(createUser, {
|
|
||||||
onSuccess: (user) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Konto angelegt',
|
|
||||||
`${user.username} — richten Sie als Nächstes den zweiten Faktor ein.`,
|
|
||||||
);
|
|
||||||
setIsCreateDialogOpen(false);
|
|
||||||
setDraftUsername('');
|
|
||||||
setDraftEmail('');
|
|
||||||
setDraftPassword('');
|
|
||||||
setDraftRoles([]);
|
|
||||||
usersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Das Konto ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation(updateUser, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Konto geändert');
|
|
||||||
setUserBeingEdited(null);
|
|
||||||
usersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Änderung schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation(deleteUser, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Konto gelöscht');
|
|
||||||
setUserPendingDeletion(null);
|
|
||||||
usersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mfaResetMutation = useMutation(disableUserMfa, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Zweiter Faktor zurückgesetzt',
|
|
||||||
'Das Konto ist jetzt nur noch durch sein Passwort geschützt.',
|
|
||||||
);
|
|
||||||
setUserPendingMfaReset(null);
|
|
||||||
usersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const users = usersResource.data ?? [];
|
|
||||||
const roles = rolesResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<User>> = [
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
header: 'Konto',
|
|
||||||
render: (user) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{user.username}</p>
|
|
||||||
<p className="truncate text-xs text-fg-muted">{user.email ?? '—'}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (user) => <StatusBadge status={user.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'roles',
|
|
||||||
header: 'Rollen',
|
|
||||||
render: (user) => (
|
|
||||||
<span className="text-sm text-fg-muted">{user.roles.join(', ') || '—'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'mfa',
|
|
||||||
header: 'Zweiter Faktor',
|
|
||||||
render: (user) =>
|
|
||||||
user.mfa_enabled ? (
|
|
||||||
<StatusBadge status="healthy" label="Eingerichtet" />
|
|
||||||
) : (
|
|
||||||
// Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird
|
|
||||||
// benannt, nicht verschwiegen.
|
|
||||||
<StatusBadge status="warning" label="Fehlt" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'last_login',
|
|
||||||
header: 'Letzte Anmeldung',
|
|
||||||
render: (user) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{user.last_login_at ? formatRelativeTime(user.last_login_at) : 'Noch nie'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (user) =>
|
|
||||||
mayWrite ? (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-1"
|
|
||||||
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
||||||
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Bearbeiten"
|
|
||||||
aria-label={`${user.username} bearbeiten`}
|
|
||||||
onClick={() => {
|
|
||||||
setUserBeingEdited(user);
|
|
||||||
setDraftEmail(user.email ?? '');
|
|
||||||
setDraftRoles(user.roles);
|
|
||||||
setDraftPassword('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserCog />
|
|
||||||
</Button>
|
|
||||||
{user.mfa_enabled ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Zweiten Faktor zurücksetzen"
|
|
||||||
aria-label={`Zweiten Faktor von ${user.username} zurücksetzen`}
|
|
||||||
onClick={() => setUserPendingMfaReset(user)}
|
|
||||||
>
|
|
||||||
<ShieldOff />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Löschen"
|
|
||||||
aria-label={`${user.username} löschen`}
|
|
||||||
onClick={() => setUserPendingDeletion(user)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const usersWithoutMfa = users.filter((user) => !user.mfa_enabled).length;
|
|
||||||
|
|
||||||
if (usersResource.loadState === 'failed' && usersResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Benutzer" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(usersResource.loadError)}
|
|
||||||
requestId={usersResource.loadError.requestId}
|
|
||||||
onRetry={usersResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Benutzer"
|
|
||||||
description="Konten und ihre Rollen."
|
|
||||||
actions={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Konto anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{usersWithoutMfa > 0 ? (
|
|
||||||
<Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt">
|
|
||||||
{usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort
|
|
||||||
geschützt. Bei Konten mit Löschrecht ist das der Punkt, an dem ein
|
|
||||||
übernommenes Konto zum Datenverlust führt.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={users}
|
|
||||||
getRowKey={(user) => user.id}
|
|
||||||
isLoading={usersResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Keine Konten"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* --- Anlegen --- */}
|
|
||||||
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader title="Konto anlegen" />
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="Anmeldename"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
value={draftUsername}
|
|
||||||
onChange={(changeEvent) => setDraftUsername(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="E-Mail"
|
|
||||||
type="email"
|
|
||||||
value={draftEmail}
|
|
||||||
onChange={(changeEvent) => setDraftEmail(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Passwort"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={draftPassword}
|
|
||||||
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
|
|
||||||
hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlässt diese Maske nur im Anfragekörper."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-fg">Rollen</p>
|
|
||||||
{roles.map((role) => (
|
|
||||||
<CheckboxField
|
|
||||||
key={role.id}
|
|
||||||
checked={draftRoles.includes(role.name)}
|
|
||||||
onCheckedChange={(isChecked) =>
|
|
||||||
setDraftRoles((currentRoles) =>
|
|
||||||
isChecked
|
|
||||||
? [...currentRoles, role.name]
|
|
||||||
: currentRoles.filter((roleName) => roleName !== role.name),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label={role.name}
|
|
||||||
hint={role.description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={createMutation.isRunning}
|
|
||||||
disabled={!draftUsername.trim() || !draftPassword || draftRoles.length === 0}
|
|
||||||
onClick={() =>
|
|
||||||
void createMutation.run({
|
|
||||||
username: draftUsername.trim(),
|
|
||||||
password: draftPassword,
|
|
||||||
roles: draftRoles,
|
|
||||||
...(draftEmail.trim() ? { email: draftEmail.trim() } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Anlegen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
{/* --- Bearbeiten --- */}
|
|
||||||
<DialogRoot
|
|
||||||
open={userBeingEdited !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setUserBeingEdited(null)}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader
|
|
||||||
title="Konto bearbeiten"
|
|
||||||
description={userBeingEdited?.username}
|
|
||||||
/>
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="E-Mail"
|
|
||||||
type="email"
|
|
||||||
value={draftEmail}
|
|
||||||
onChange={(changeEvent) => setDraftEmail(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Neues Passwort (leer lassen, um es zu behalten)"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={draftPassword}
|
|
||||||
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-fg">Rollen</p>
|
|
||||||
{roles.map((role) => (
|
|
||||||
<CheckboxField
|
|
||||||
key={role.id}
|
|
||||||
checked={draftRoles.includes(role.name)}
|
|
||||||
onCheckedChange={(isChecked) =>
|
|
||||||
setDraftRoles((currentRoles) =>
|
|
||||||
isChecked
|
|
||||||
? [...currentRoles, role.name]
|
|
||||||
: currentRoles.filter((roleName) => roleName !== role.name),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label={role.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Callout tone="info">
|
|
||||||
Dem letzten Administrator lassen sich die Rechte nicht entziehen.
|
|
||||||
</Callout>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setUserBeingEdited(null)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={updateMutation.isRunning}
|
|
||||||
onClick={() => {
|
|
||||||
if (!userBeingEdited) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateMutation.run(userBeingEdited.id, {
|
|
||||||
email: draftEmail.trim(),
|
|
||||||
roles: draftRoles,
|
|
||||||
...(draftPassword ? { password: draftPassword } : {}),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={userPendingDeletion !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)}
|
|
||||||
title="Konto löschen"
|
|
||||||
description={userPendingDeletion?.username}
|
|
||||||
confirmLabel="Löschen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteMutation.isRunning}
|
|
||||||
confirmationWord={userPendingDeletion?.username}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (userPendingDeletion) {
|
|
||||||
void deleteMutation.run(userPendingDeletion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="info">
|
|
||||||
Die Einträge im Ereignisprotokoll bleiben bestehen.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={userPendingMfaReset !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)}
|
|
||||||
title="Zweiten Faktor zurücksetzen"
|
|
||||||
description={userPendingMfaReset?.username}
|
|
||||||
confirmLabel="Zurücksetzen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={mfaResetMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (userPendingMfaReset) {
|
|
||||||
void mfaResetMutation.run(userPendingMfaReset.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="warning">
|
|
||||||
Danach genügt das Passwort allein. Prüfen Sie die Anfrage auf anderem Weg.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,252 +0,0 @@
|
|||||||
/**
|
|
||||||
* API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA.
|
|
||||||
*
|
|
||||||
* Diese vier gehören zusammen, weil sie dieselbe Frage betreffen: **Wer darf
|
|
||||||
* was, und wer erfährt davon?** Zwei Schutzmechanismen prägen die Masken:
|
|
||||||
*
|
|
||||||
* - **Der letzte Administrator ist geschützt** gegen Löschung, Deaktivierung
|
|
||||||
* und Rollenentzug. Sonst wäre die Anlage nicht mehr verwaltbar.
|
|
||||||
* - **Mitgelieferte Rollen sind unveränderlich.** Eine Änderung verschöbe die
|
|
||||||
* Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas
|
|
||||||
* anderes bedeutet als bei ihrer Vergabe.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Benutzer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Ein Benutzerkonto. */
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
email?: string;
|
|
||||||
/** active, disabled oder locked. */
|
|
||||||
status: string;
|
|
||||||
roles: string[];
|
|
||||||
mfa_enabled: boolean;
|
|
||||||
last_login_at?: string;
|
|
||||||
created_at?: string;
|
|
||||||
/** Zähler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */
|
|
||||||
failed_login_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Benutzer. */
|
|
||||||
export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
|
|
||||||
return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legt ein Benutzerkonto an.
|
|
||||||
*
|
|
||||||
* Das Passwort geht ausschließlich über den Anfragekörper — niemals als
|
|
||||||
* Abfrageparameter, sonst stünde es in jedem Zugriffsprotokoll.
|
|
||||||
*/
|
|
||||||
export async function createUser(userInput: {
|
|
||||||
username: string;
|
|
||||||
email?: string;
|
|
||||||
password: string;
|
|
||||||
roles: string[];
|
|
||||||
}): Promise<User> {
|
|
||||||
return requestApi<User>('/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: userInput,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ändert ein Benutzerkonto. */
|
|
||||||
export async function updateUser(
|
|
||||||
userIdentifier: string,
|
|
||||||
changes: {
|
|
||||||
email?: string;
|
|
||||||
status?: string;
|
|
||||||
password?: string;
|
|
||||||
roles?: string[];
|
|
||||||
},
|
|
||||||
): Promise<User> {
|
|
||||||
return requestApi<User>(`/users/${encodeURIComponent(userIdentifier)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: changes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Löscht ein Benutzerkonto. */
|
|
||||||
export async function deleteUser(userIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setzt den zweiten Faktor eines Kontos zurück.
|
|
||||||
*
|
|
||||||
* Der Weg für den Fall, dass jemand sein Gerät verloren hat. Es ist eine
|
|
||||||
* sicherheitsrelevante Handlung und wird auditiert: Danach genügt das
|
|
||||||
* Passwort allein.
|
|
||||||
*/
|
|
||||||
export async function disableUserMfa(userIdentifier: string): Promise<User> {
|
|
||||||
return requestApi<User>(`/users/${encodeURIComponent(userIdentifier)}/mfa/disable`, {
|
|
||||||
method: 'POST',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Rollen
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Eine Rolle. */
|
|
||||||
export interface Role {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
permissions: string[];
|
|
||||||
/** Mitgelieferte Rollen sind unveränderlich. */
|
|
||||||
is_builtin?: boolean;
|
|
||||||
user_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eine Berechtigung. */
|
|
||||||
export interface Permission {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
/** Fachlicher Bereich, etwa "backups" oder "restores". */
|
|
||||||
category?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Rollen. */
|
|
||||||
export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> {
|
|
||||||
return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die vergebbaren Berechtigungen. */
|
|
||||||
export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> {
|
|
||||||
return requestApi<Permission[]>('/permissions', abortSignal ? { signal: abortSignal } : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt eine Rolle an. */
|
|
||||||
export async function createRole(roleInput: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
permissions: string[];
|
|
||||||
}): Promise<Role> {
|
|
||||||
return requestApi<Role>('/roles', {
|
|
||||||
method: 'POST',
|
|
||||||
body: roleInput,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ändert eine Rolle. */
|
|
||||||
export async function updateRole(
|
|
||||||
roleIdentifier: string,
|
|
||||||
changes: { description?: string; permissions?: string[] },
|
|
||||||
): Promise<Role> {
|
|
||||||
return requestApi<Role>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: changes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Löscht eine Rolle. */
|
|
||||||
export async function deleteRole(roleIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Benachrichtigungswege
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Ein Zustellweg für Meldungen. */
|
|
||||||
export interface NotificationChannel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
/** email oder webhook. */
|
|
||||||
channel_type: string;
|
|
||||||
/**
|
|
||||||
* Schwelle, ab der zugestellt wird.
|
|
||||||
*
|
|
||||||
* Ohne sie schaltet der Bereitschaftsdienst nach einer Woche die
|
|
||||||
* Benachrichtigungen ab — und dann kommt auch die kritische nicht mehr an.
|
|
||||||
*/
|
|
||||||
minimum_severity: string;
|
|
||||||
/**
|
|
||||||
* Konfiguration ohne Geheimnisse.
|
|
||||||
*
|
|
||||||
* Bei einem Webhook steht die Adresse hier, und die trägt oft ein Token im
|
|
||||||
* Pfad — der Server liefert sie deshalb geschwärzt oder gar nicht aus.
|
|
||||||
*/
|
|
||||||
configuration?: Record<string, unknown>;
|
|
||||||
enabled?: boolean;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Benachrichtigungswege. */
|
|
||||||
export async function listNotificationChannels(
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<NotificationChannel[]> {
|
|
||||||
return requestApi<NotificationChannel[]>(
|
|
||||||
'/notification-channels',
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt einen Benachrichtigungsweg an. */
|
|
||||||
export async function createNotificationChannel(channelInput: {
|
|
||||||
name: string;
|
|
||||||
channel_type: string;
|
|
||||||
minimum_severity: string;
|
|
||||||
configuration: Record<string, unknown>;
|
|
||||||
secret?: string;
|
|
||||||
}): Promise<NotificationChannel> {
|
|
||||||
return requestApi<NotificationChannel>('/notification-channels', {
|
|
||||||
method: 'POST',
|
|
||||||
body: channelInput,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Löscht einen Benachrichtigungsweg. */
|
|
||||||
export async function deleteNotificationChannel(channelIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(
|
|
||||||
`/notification-channels/${encodeURIComponent(channelIdentifier)}`,
|
|
||||||
{ method: 'DELETE', idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Eigener zweiter Faktor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Antwort auf die MFA-Einrichtung. */
|
|
||||||
export interface MfaEnrollment {
|
|
||||||
/** Das gemeinsame Geheimnis in Base32 — einmalig. */
|
|
||||||
secret: string;
|
|
||||||
/** URI für die Authentifizierungs-App. */
|
|
||||||
otpauth_url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Beginnt die Einrichtung des zweiten Faktors. */
|
|
||||||
export async function enrollMfa(): Promise<MfaEnrollment> {
|
|
||||||
return requestApi<MfaEnrollment>('/me/mfa/enroll', { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schließt die Einrichtung mit einem Code ab.
|
|
||||||
*
|
|
||||||
* Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn hätte jemand
|
|
||||||
* ein Geheimnis hinterlegt, dessen App er nie geprüft hat — und sich beim
|
|
||||||
* nächsten Anmelden ausgesperrt.
|
|
||||||
*/
|
|
||||||
export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> {
|
|
||||||
return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { code },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,46 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Meldungen.
|
* Meldungen.
|
||||||
*
|
*
|
||||||
* Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
|
* Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der
|
||||||
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiß,
|
* Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es
|
||||||
* was überhaupt geprüft wird.** Ohne die Liste bliebe offen, ob „keine
|
* wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf
|
||||||
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprüft".
|
* geachtet wird.
|
||||||
*
|
*
|
||||||
* „Zur Kenntnis nehmen" schließt eine Meldung nicht. Sie bleibt offen und in
|
* Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist
|
||||||
* der Liste, bis ihre Ursache verschwindet — sonst verschwände der Zustand aus
|
* erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird.
|
||||||
* der Übersicht, obwohl er weiterbesteht.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CheckboxField,
|
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
StatusBadge,
|
|
||||||
useToast,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { cn, formatDateTime } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
acknowledgeAlert,
|
acknowledgeAlert,
|
||||||
fetchAlertOverview,
|
fetchAlertOverview,
|
||||||
fetchAlerts,
|
fetchAlerts,
|
||||||
resolveAlert,
|
resolveAlert,
|
||||||
} from './alertsApi';
|
} from './alertsApi';
|
||||||
import type { Alert, AlertOverview } from './alertsApi';
|
import type { Alert, AlertOverview, AlertSeverity } from './alertsApi';
|
||||||
|
|
||||||
|
/** Zeigt die Meldungen. */
|
||||||
export function AlertsPage(): React.JSX.Element {
|
export function AlertsPage(): React.JSX.Element {
|
||||||
const toast = useToast();
|
|
||||||
const [onlyActive, setOnlyActive] = useState(true);
|
const [onlyActive, setOnlyActive] = useState(true);
|
||||||
const [actionCounter, setActionCounter] = useState(0);
|
const [actionCounter, setActionCounter] = useState(0);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadAlerts = useCallback(
|
const loadAlerts = useCallback(
|
||||||
(abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal),
|
(abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal),
|
||||||
@ -59,250 +44,264 @@ export function AlertsPage(): React.JSX.Element {
|
|||||||
|
|
||||||
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
|
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
|
||||||
|
|
||||||
const acknowledgeMutation = useMutation(
|
/** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */
|
||||||
(alertIdentifier: string) => acknowledgeAlert(alertIdentifier, ''),
|
async function runAlertAction(action: () => Promise<void>): Promise<void> {
|
||||||
{
|
try {
|
||||||
onSuccess: () => {
|
await action();
|
||||||
toast.showSuccess(
|
setActionError(null);
|
||||||
'Zur Kenntnis genommen',
|
|
||||||
'Die Meldung bleibt offen, bis ihre Ursache verschwindet.',
|
|
||||||
);
|
|
||||||
setActionCounter((previousCounter) => previousCounter + 1);
|
setActionCounter((previousCounter) => previousCounter + 1);
|
||||||
},
|
} catch (caughtError) {
|
||||||
onError: (apiError) =>
|
setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler');
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const resolveMutation = useMutation(
|
|
||||||
(alertIdentifier: string) => resolveAlert(alertIdentifier, 'Von Hand geschlossen.'),
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Meldung geschlossen');
|
|
||||||
setActionCounter((previousCounter) => previousCounter + 1);
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="page">
|
||||||
<PageHeader
|
<header className="page__header">
|
||||||
title="Meldungen"
|
<h1 className="page__title">Meldungen</h1>
|
||||||
description={
|
|
||||||
overviewResource.data !== null
|
|
||||||
? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprüft.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{overviewResource.data !== null ? (
|
{overviewResource.data !== null && (
|
||||||
<SummaryBar overview={overviewResource.data} />
|
<span className="page__meta">
|
||||||
) : null}
|
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '}
|
||||||
|
Regeln werden geprueft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
<Card className="mb-4">
|
{overviewResource.data !== null && <SummaryBar overview={overviewResource.data} />}
|
||||||
<div className="border-b border-line px-5 py-3">
|
|
||||||
<CheckboxField
|
<div className="filter-bar">
|
||||||
|
<label className="filter-bar__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
checked={onlyActive}
|
checked={onlyActive}
|
||||||
onCheckedChange={setOnlyActive}
|
onChange={(changeEvent) => setOnlyActive(changeEvent.target.checked)}
|
||||||
label="Nur unerledigte"
|
|
||||||
/>
|
/>
|
||||||
|
Nur unerledigte
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadState === 'loading' ? <LoadingState label="Die Meldungen werden geladen …" /> : null}
|
{actionError !== null && (
|
||||||
|
<div className="notice notice--critical" role="alert">
|
||||||
|
<p className="notice__text">{actionError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadState === 'failed' && loadError !== null ? (
|
{loadState === 'loading' && <LoadingState what="Die Meldungen" />}
|
||||||
<ErrorState
|
{loadState === 'failed' && loadError !== null && (
|
||||||
message={describeApiError(loadError)}
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
requestId={loadError.requestId}
|
)}
|
||||||
onRetry={reload}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null && data.length === 0 ? (
|
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
|
message={
|
||||||
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprüft wird — erst damit ist eine leere Liste eine gute Nachricht."
|
onlyActive
|
||||||
|
? 'Keine unerledigten Meldungen. Die Regeln unten sagen, worauf geachtet wird.'
|
||||||
|
: 'Es gibt keine Meldungen.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null && data.length > 0 ? (
|
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
||||||
<ul className="divide-y divide-line">
|
<ul className="alert-list">
|
||||||
{data.map((alert) => (
|
{data.map((alert) => (
|
||||||
<AlertRow
|
<AlertCard
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
alert={alert}
|
alert={alert}
|
||||||
isBusy={acknowledgeMutation.isRunning || resolveMutation.isRunning}
|
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))}
|
||||||
onAcknowledge={() => void acknowledgeMutation.run(alert.id)}
|
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))}
|
||||||
onResolve={() => void resolveMutation.run(alert.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{overviewResource.data !== null ? <RuleList overview={overviewResource.data} /> : null}
|
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />}
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Zusammenfassung. */
|
||||||
|
interface SummaryBarProperties {
|
||||||
|
/** Die Meldungslage samt Regelwerk. */
|
||||||
|
readonly overview: AlertOverview;
|
||||||
|
}
|
||||||
|
|
||||||
/** Zeigt die Meldungslage in Zahlen. */
|
/** Zeigt die Meldungslage in Zahlen. */
|
||||||
function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
|
function SummaryBar({ overview }: SummaryBarProperties): React.JSX.Element {
|
||||||
const { summary } = overview;
|
const { summary } = overview;
|
||||||
|
|
||||||
const figures = [
|
|
||||||
{ label: 'Kritisch', value: summary.critical_count, tone: 'critical' as const },
|
|
||||||
{ label: 'Ernst', value: summary.high_count, tone: 'high' as const },
|
|
||||||
{ label: 'Unbearbeitet', value: summary.open_count, tone: null },
|
|
||||||
{ label: 'Zur Kenntnis genommen', value: summary.acknowledged_count, tone: null },
|
|
||||||
{ label: 'Erledigt (24 h)', value: summary.resolved_last_day, tone: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="summary-bar">
|
||||||
{figures.map((figure) => (
|
<SummaryFigure
|
||||||
<Card key={figure.label} className="p-4">
|
label="Kritisch"
|
||||||
<p
|
value={summary.critical_count}
|
||||||
className={cn(
|
severity={summary.critical_count > 0 ? 'critical' : undefined}
|
||||||
'text-2xl font-semibold tabular',
|
/>
|
||||||
// Farbe nur, wenn der Wert eine Aussage trägt: Null kritische
|
<SummaryFigure
|
||||||
// Meldungen sind neutral, nicht grün — grün hieße "geprüft
|
label="Ernst"
|
||||||
// und in Ordnung", und das sagt diese Zahl allein nicht.
|
value={summary.high_count}
|
||||||
figure.tone && figure.value > 0
|
severity={summary.high_count > 0 ? 'high' : undefined}
|
||||||
? figure.tone === 'critical'
|
/>
|
||||||
? 'text-critical'
|
<SummaryFigure label="Unbearbeitet" value={summary.open_count} />
|
||||||
: 'text-high'
|
<SummaryFigure label="Zur Kenntnis genommen" value={summary.acknowledged_count} />
|
||||||
: 'text-fg',
|
<SummaryFigure label="Erledigt (24 h)" value={summary.resolved_last_day} />
|
||||||
)}
|
|
||||||
>
|
|
||||||
{figure.value}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-fg-muted">{figure.label}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt eine einzelne Meldung. */
|
/** Eigenschaften einer Kennzahl der Zusammenfassung. */
|
||||||
function AlertRow({
|
interface SummaryFigureProperties {
|
||||||
alert,
|
/** Beschriftung. */
|
||||||
isBusy,
|
readonly label: string;
|
||||||
onAcknowledge,
|
/** Der Wert. */
|
||||||
onResolve,
|
readonly value: number;
|
||||||
}: {
|
/** Statusfarbe, sofern der Wert eine ist. */
|
||||||
|
readonly severity?: AlertSeverity | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine einzelne Kennzahl. */
|
||||||
|
function SummaryFigure({ label, value, severity }: SummaryFigureProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={severity === undefined ? 'summary-figure' : `summary-figure summary-figure--${severity}`}>
|
||||||
|
<span className="summary-figure__value">{value}</span>
|
||||||
|
<span className="summary-figure__label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Meldungskarte. */
|
||||||
|
interface AlertCardProperties {
|
||||||
|
/** Die dargestellte Meldung. */
|
||||||
readonly alert: Alert;
|
readonly alert: Alert;
|
||||||
readonly isBusy: boolean;
|
/** Nimmt die Meldung zur Kenntnis. */
|
||||||
readonly onAcknowledge: () => void;
|
readonly onAcknowledge: () => void;
|
||||||
|
/** Schliesst die Meldung. */
|
||||||
readonly onResolve: () => void;
|
readonly onResolve: () => void;
|
||||||
}): React.JSX.Element {
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine einzelne Meldung. */
|
||||||
|
function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProperties): React.JSX.Element {
|
||||||
const isResolved = alert.status === 'resolved';
|
const isResolved = alert.status === 'resolved';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={cn('px-5 py-4', isResolved && 'opacity-60')}>
|
<li className={`alert-card alert-card--${alert.severity}${isResolved ? ' alert-card--resolved' : ''}`}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="alert-card__head">
|
||||||
<div className="min-w-0 flex-1">
|
<span className={`badge badge--${severityClassOf(alert.severity)}`}>{alert.severity}</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<StatusBadge status={alert.severity} />
|
<h2 className="alert-card__title">{alert.title}</h2>
|
||||||
<h3 className="min-w-0 font-medium text-fg">{alert.title}</h3>
|
|
||||||
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
|
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
|
||||||
zwanzigmal ein Zustand. */}
|
zwanzigmal ein Zustand. */}
|
||||||
{alert.occurrence_count > 1 ? (
|
{alert.occurrence_count > 1 && (
|
||||||
<span className="rounded bg-sunken px-1.5 py-0.5 text-xs text-fg-muted">
|
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span>
|
||||||
{alert.occurrence_count}× aufgetreten
|
)}
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1.5 text-sm text-fg">{alert.message}</p>
|
<p className="alert-card__message">{alert.message}</p>
|
||||||
|
|
||||||
<p className="mt-1.5 text-xs text-fg-muted">
|
<p className="alert-card__meta">
|
||||||
Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '}
|
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} ·
|
||||||
{formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name}
|
Regel {alert.rule_name}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{alert.status === 'acknowledged' ? (
|
{alert.status === 'acknowledged' && (
|
||||||
<p className="mt-1 text-xs text-fg-muted">
|
<p className="alert-card__meta">
|
||||||
Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die
|
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt
|
||||||
Meldung <strong>bleibt offen</strong>, bis ihre Ursache verschwindet.
|
offen, bis ihre Ursache verschwindet.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{isResolved ? (
|
{isResolved && (
|
||||||
<p className="mt-1 text-xs text-fg-muted">
|
<p className="alert-card__meta">
|
||||||
Erledigt am {formatDateTime(alert.resolved_at)}
|
Erledigt am {formatTimestamp(alert.resolved_at)}
|
||||||
{alert.resolution_note !== undefined && alert.resolution_note !== ''
|
{alert.resolution_note !== undefined && alert.resolution_note !== ''
|
||||||
? ` — ${alert.resolution_note}`
|
? ` — ${alert.resolution_note}`
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isResolved ? (
|
{!isResolved && (
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="alert-card__actions">
|
||||||
{alert.status === 'open' ? (
|
{alert.status === 'open' && (
|
||||||
<Button size="sm" disabled={isBusy} onClick={onAcknowledge}>
|
<button className="button button--secondary" type="button" onClick={onAcknowledge}>
|
||||||
Zur Kenntnis nehmen
|
Zur Kenntnis nehmen
|
||||||
</Button>
|
</button>
|
||||||
) : null}
|
)}
|
||||||
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
|
|
||||||
Schließen
|
<button className="button button--secondary" type="button" onClick={onResolve}>
|
||||||
</Button>
|
Schliessen
|
||||||
</div>
|
</button>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Regelliste. */
|
||||||
|
interface RuleListProperties {
|
||||||
|
/** Die Meldungslage samt Regelwerk. */
|
||||||
|
readonly overview: AlertOverview;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeigt, worauf geachtet wird.
|
* Zeigt, worauf geachtet wird.
|
||||||
*
|
*
|
||||||
* Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt
|
* Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in
|
||||||
* den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten
|
* Ordnung" oder „es wird nichts geprueft".
|
||||||
* Regel ausdrücklich, dass sie nicht greift, und warum.
|
|
||||||
*/
|
*/
|
||||||
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
|
function RuleList({ overview }: RuleListProperties): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="rule-list">
|
||||||
<CardHeader
|
<h2 className="rule-list__title">Worauf geachtet wird</h2>
|
||||||
title="Worauf geachtet wird"
|
|
||||||
description="Erst mit dieser Liste ist eine leere Meldungsliste eine gute Nachricht."
|
<ul className="rule-list__items">
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{overview.rules.map((rule) => (
|
{overview.rules.map((rule) => (
|
||||||
<li
|
<li
|
||||||
|
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'}
|
||||||
key={rule.name}
|
key={rule.name}
|
||||||
className={cn(
|
|
||||||
'rounded-md border border-line p-3',
|
|
||||||
!rule.available && 'opacity-70',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="rule-list__head">
|
||||||
<span className="font-medium text-fg">{rule.title}</span>
|
<span className="rule-list__name">{rule.title}</span>
|
||||||
|
|
||||||
{rule.available ? (
|
{rule.available ? (
|
||||||
<StatusBadge status={rule.severity} />
|
<span className={`badge badge--${severityClassOf(rule.severity)}`}>{rule.severity}</span>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge status="neutral" label="wird nicht geprüft" />
|
<span className="badge badge--neutral">wird nicht geprueft</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-fg-muted">
|
|
||||||
|
<p className="rule-list__description">
|
||||||
{rule.available ? rule.description : rule.unavailable_reason}
|
{rule.available ? rule.description : rule.unavailable_reason}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
{overview.available_rule_count < overview.rules.length ? (
|
|
||||||
<Callout tone="warning" className="mt-4">
|
|
||||||
{overview.rules.length - overview.available_rule_count} Regeln werden
|
|
||||||
nicht ausgewertet. Rechnen Sie sie nicht als „in Ordnung" — sie
|
|
||||||
schweigen, weil ihnen die Datengrundlage fehlt.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
|
||||||
|
function severityClassOf(severity: AlertSeverity): string {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'critical';
|
||||||
|
case 'high':
|
||||||
|
return 'high';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Zugriff auf Meldungen und Benachrichtigungskanäle.
|
* Zugriff auf Meldungen und Benachrichtigungskanaele.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
import { requestApi } from '../../api/client';
|
||||||
@ -12,17 +12,17 @@ export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
|
|||||||
|
|
||||||
/** Eine Meldung. */
|
/** Eine Meldung. */
|
||||||
export interface Alert {
|
export interface Alert {
|
||||||
/** Öffentlicher Bezeichner. */
|
/** Oeffentlicher Bezeichner. */
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
/** Auslösende Regel. */
|
/** Ausloesende Regel. */
|
||||||
readonly rule_name: string;
|
readonly rule_name: string;
|
||||||
/** Schweregrad. */
|
/** Schweregrad. */
|
||||||
readonly severity: AlertSeverity;
|
readonly severity: AlertSeverity;
|
||||||
/** Bearbeitungszustand. */
|
/** Bearbeitungszustand. */
|
||||||
readonly status: AlertStatus;
|
readonly status: AlertStatus;
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Befund und nächste Handlung. */
|
/** Befund und naechste Handlung. */
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
/** Art des betroffenen Gegenstands. */
|
/** Art des betroffenen Gegenstands. */
|
||||||
readonly entity_type?: string;
|
readonly entity_type?: string;
|
||||||
@ -41,11 +41,11 @@ export interface Alert {
|
|||||||
readonly last_seen_at: string;
|
readonly last_seen_at: string;
|
||||||
/** Zeitpunkt der Kenntnisnahme in UTC. */
|
/** Zeitpunkt der Kenntnisnahme in UTC. */
|
||||||
readonly acknowledged_at?: string;
|
readonly acknowledged_at?: string;
|
||||||
/** Bemerkung des Bestätigenden. */
|
/** Bemerkung des Bestaetigenden. */
|
||||||
readonly acknowledgement_note?: string;
|
readonly acknowledgement_note?: string;
|
||||||
/** Zeitpunkt der Auflösung in UTC. */
|
/** Zeitpunkt der Aufloesung in UTC. */
|
||||||
readonly resolved_at?: string;
|
readonly resolved_at?: string;
|
||||||
/** Begründung der Auflösung. */
|
/** Begruendung der Aufloesung. */
|
||||||
readonly resolution_note?: string;
|
readonly resolution_note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,13 +55,13 @@ export interface AlertRule {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
/** Bezeichnung. */
|
/** Bezeichnung. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärung. */
|
/** Erklaerung. */
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
/** Schweregrad ausgelöster Meldungen. */
|
/** Schweregrad ausgeloester Meldungen. */
|
||||||
readonly severity: AlertSeverity;
|
readonly severity: AlertSeverity;
|
||||||
/** Meldet, ob die Regel auslösen kann. */
|
/** Meldet, ob die Regel ausloesen kann. */
|
||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
/** Erklärt eine Regel ohne Datengrundlage. */
|
/** Erklaert eine Regel ohne Datengrundlage. */
|
||||||
readonly unavailable_reason?: string;
|
readonly unavailable_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,17 +79,17 @@ export interface AlertSummary {
|
|||||||
readonly resolved_last_day: number;
|
readonly resolved_last_day: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Antwort der Meldungsübersicht. */
|
/** Antwort der Meldungsuebersicht. */
|
||||||
export interface AlertOverview {
|
export interface AlertOverview {
|
||||||
/** Die Meldungslage. */
|
/** Die Meldungslage. */
|
||||||
readonly summary: AlertSummary;
|
readonly summary: AlertSummary;
|
||||||
/** Alle Regeln. */
|
/** Alle Regeln. */
|
||||||
readonly rules: readonly AlertRule[];
|
readonly rules: readonly AlertRule[];
|
||||||
/** Zahl der auslösbaren Regeln. */
|
/** Zahl der ausloesbaren Regeln. */
|
||||||
readonly available_rule_count: number;
|
readonly available_rule_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt die Meldungen. */
|
/** Laedt die Meldungen. */
|
||||||
export async function fetchAlerts(
|
export async function fetchAlerts(
|
||||||
onlyActive: boolean,
|
onlyActive: boolean,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
@ -106,7 +106,7 @@ export async function fetchAlerts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt die Meldungslage samt Regelwerk. */
|
/** Laedt die Meldungslage samt Regelwerk. */
|
||||||
export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> {
|
export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> {
|
||||||
return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {});
|
return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {});
|
||||||
}
|
}
|
||||||
@ -119,7 +119,7 @@ export async function acknowledgeAlert(alertIdentifier: string, note: string): P
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schließt eine Meldung von Hand. */
|
/** Schliesst eine Meldung von Hand. */
|
||||||
export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> {
|
export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> {
|
||||||
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
|
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
102
apps/web/src/features/auth/LoginPage.css
Normal file
102
apps/web/src/features/auth/LoginPage.css
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/* Darstellung der Anmeldemaske. */
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 22rem;
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__title {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__subtitle {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__input {
|
||||||
|
font: inherit;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Einmalcode wird in Ziffernbreite dargestellt, damit er gut lesbar ist. */
|
||||||
|
.login-form__input--code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__hint {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__error {
|
||||||
|
/* Der farbige Rand betont den Fehler, ohne die Flaeche einzufaerben. */
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--color-text-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-text-primary);
|
||||||
|
color: var(--color-surface-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__secondary {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ function buildResponse(responseBody: unknown, statusCode = 200): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein vollständiger Benutzer, wie ihn die API zurückgibt. */
|
/** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */
|
||||||
const testUser = {
|
const testUser = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
@ -22,7 +22,7 @@ const testUser = {
|
|||||||
permissions: ['users.read'],
|
permissions: ['users.read'],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ein Tokenpaar, wie es die API zurückgibt. */
|
/** Ein Tokenpaar, wie es die API zurueckgibt. */
|
||||||
const testTokens = {
|
const testTokens = {
|
||||||
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
|
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
|
||||||
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
|
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
|
||||||
@ -85,7 +85,7 @@ describe('LoginPage', () => {
|
|||||||
expect(handleAuthenticated).not.toHaveBeenCalled();
|
expect(handleAuthenticated).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('schließt die Anmeldung nach gültigem Code ab', async () => {
|
it('schliesst die Anmeldung nach gueltigem Code ab', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@ -112,14 +112,14 @@ describe('LoginPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(handleAuthenticated).toHaveBeenCalled();
|
expect(handleAuthenticated).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt die Fehlermeldung des Servers verständlich an', async () => {
|
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue(
|
vi.fn().mockResolvedValue(
|
||||||
@ -166,14 +166,14 @@ describe('LoginPage', () => {
|
|||||||
await userEvent.type(passwordField, 'falsch');
|
await userEvent.type(passwordField, 'falsch');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
// Ein stehengebliebenes Passwort wäre auf einem gemeinsam genutzten
|
// Ein stehengebliebenes Passwort waere auf einem gemeinsam genutzten
|
||||||
// Bildschirm sichtbar.
|
// Bildschirm sichtbar.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(passwordField.value).toBe('');
|
expect(passwordField.value).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurück', async () => {
|
it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurueck', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@ -205,9 +205,9 @@ describe('LoginPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
|
||||||
|
|
||||||
// Ohne Rückkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt.
|
// Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
|
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useState } from 'react';
|
|||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { login, verifyMFA } from './authApi';
|
import { login, verifyMFA } from './authApi';
|
||||||
import type { CurrentUser } from '../../types/auth';
|
import type { CurrentUser } from '../../types/auth';
|
||||||
|
import './LoginPage.css';
|
||||||
|
|
||||||
/** Eigenschaften der Anmeldemaske. */
|
/** Eigenschaften der Anmeldemaske. */
|
||||||
export interface LoginPageProps {
|
export interface LoginPageProps {
|
||||||
@ -29,7 +30,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
/** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */
|
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
|
||||||
function describeError(caughtError: unknown): string {
|
function describeError(caughtError: unknown): string {
|
||||||
if (caughtError instanceof ApiError) {
|
if (caughtError instanceof ApiError) {
|
||||||
return caughtError.message;
|
return caughtError.message;
|
||||||
@ -59,7 +60,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag.
|
// Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag.
|
||||||
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
|
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
setErrorMessage(describeError(caughtError));
|
setErrorMessage(describeError(caughtError));
|
||||||
// Das Passwort wird nach einem Fehlversuch geleert.
|
// Das Passwort wird nach einem Fehlversuch geleert.
|
||||||
@ -88,7 +89,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
|
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
setErrorMessage(describeError(caughtError));
|
setErrorMessage(describeError(caughtError));
|
||||||
setMfaCode('');
|
setMfaCode('');
|
||||||
@ -105,20 +106,20 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-page p-4">
|
<div className="login-page">
|
||||||
<main className="w-full max-w-sm border border-line-strong bg-card p-6">
|
<main className="login-card">
|
||||||
<h1 className="text-lg font-semibold text-fg">Syncova</h1>
|
<h1 className="login-card__title">Syncova</h1>
|
||||||
|
|
||||||
{loginStep === 'credentials' ? (
|
{loginStep === 'credentials' ? (
|
||||||
<form className="mt-5 space-y-4" onSubmit={handleCredentialsSubmit}>
|
<form className="login-form" onSubmit={handleCredentialsSubmit}>
|
||||||
<p className="mt-1 text-sm text-fg-muted">Bitte anmelden</p>
|
<p className="login-card__subtitle">Bitte anmelden</p>
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-fg" htmlFor="username">
|
<label className="login-form__label" htmlFor="username">
|
||||||
Benutzername
|
Benutzername
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="login-form__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(changeEvent) => setUsername(changeEvent.target.value)}
|
onChange={(changeEvent) => setUsername(changeEvent.target.value)}
|
||||||
@ -127,12 +128,12 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-fg" htmlFor="password">
|
<label className="login-form__label" htmlFor="password">
|
||||||
Passwort
|
Passwort
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="login-form__input"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(changeEvent) => setPassword(changeEvent.target.value)}
|
onChange={(changeEvent) => setPassword(changeEvent.target.value)}
|
||||||
@ -141,33 +142,33 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
|
<p className="login-form__error" role="alert">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}>
|
<button className="login-form__button" type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Anmeldung läuft …' : 'Anmelden'}
|
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form className="mt-5 space-y-4" onSubmit={handleMFASubmit}>
|
<form className="login-form" onSubmit={handleMFASubmit}>
|
||||||
<p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
|
<p className="login-card__subtitle">Zweiter Faktor</p>
|
||||||
<p className="mt-1.5 text-xs text-fg-muted">
|
<p className="login-form__hint">
|
||||||
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
|
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
|
||||||
Wiederherstellungscode möglich.
|
Wiederherstellungscode moeglich.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
|
<label className="login-form__label" htmlFor="mfa-code">
|
||||||
Code
|
Code
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="mfa-code"
|
id="mfa-code"
|
||||||
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle text-center font-mono text-lg tracking-[0.4em]"
|
className="login-form__input login-form__input--code"
|
||||||
type="text"
|
type="text"
|
||||||
value={mfaCode}
|
value={mfaCode}
|
||||||
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
|
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
|
||||||
// one-time-code lässt Mobilgeräte den Code aus der SMS/App vorschlagen.
|
// one-time-code laesst Mobilgeraete den Code aus der SMS/App vorschlagen.
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
inputMode="text"
|
inputMode="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -175,17 +176,17 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
|
<p className="login-form__error" role="alert">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}>
|
<button className="login-form__button" type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Prüfung läuft …' : 'Bestätigen'}
|
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="w-full rounded-md px-4 py-2 text-sm text-fg-muted transition-colors hover:bg-hover hover:text-fg"
|
className="login-form__secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLoginStep('credentials');
|
setLoginStep('credentials');
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Anmeldefunktionen gegen die Syncova-API.
|
* Anmeldefunktionen gegen die Syncova-API.
|
||||||
*
|
*
|
||||||
* Die Tokens liegen im Arbeitsspeicher **und** im Sitzungsspeicher des Tabs.
|
* Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in
|
||||||
* Warum das eine Abkehr von der ursprünglichen Entscheidung ist und was sie
|
* localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und
|
||||||
* trägt, steht in `sessionStore.ts` — kurz: Ohne Ablage warf jedes Neuladen den
|
* ueberstuenden das Schliessen des Browsers (PROMPT.md §45).
|
||||||
* Betreiber auf die Anmeldemaske, und begrenzt wird die Sitzung ohnehin durch
|
|
||||||
* eine harte Obergrenze und eine Untätigkeitsgrenze.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
import { requestApi } from '../../api/client';
|
||||||
import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
|
import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
|
||||||
import { clearStoredSession, readStoredSession, writeStoredSession } from './sessionStore';
|
|
||||||
|
|
||||||
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
|
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
|
||||||
let currentAccessToken: string | null = null;
|
let currentAccessToken: string | null = null;
|
||||||
@ -18,53 +15,27 @@ let currentAccessToken: string | null = null;
|
|||||||
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
|
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
|
||||||
let currentRefreshToken: string | null = null;
|
let currentRefreshToken: string | null = null;
|
||||||
|
|
||||||
/**
|
/** Liefert das aktuelle Zugriffstoken. */
|
||||||
* Liefert das aktuelle Zugriffstoken.
|
|
||||||
*
|
|
||||||
* Fehlt es im Arbeitsspeicher — der Fall nach einem Neuladen —, wird es aus dem
|
|
||||||
* Sitzungsspeicher geholt. Der liefert nur zurück, was beide Zeitgrenzen noch
|
|
||||||
* einhält.
|
|
||||||
*/
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
if (currentAccessToken !== null) {
|
|
||||||
return currentAccessToken;
|
return currentAccessToken;
|
||||||
}
|
|
||||||
|
|
||||||
const storedSession = readStoredSession();
|
|
||||||
|
|
||||||
if (storedSession === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAccessToken = storedSession.accessToken;
|
|
||||||
currentRefreshToken = storedSession.refreshToken;
|
|
||||||
|
|
||||||
return currentAccessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Meldet, ob eine gültige Sitzung wiederaufgenommen werden kann. */
|
|
||||||
export function hasResumableSession(): boolean {
|
|
||||||
return readStoredSession() !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hinterlegt ein Tokenpaar nach erfolgreicher Anmeldung. */
|
/** Hinterlegt ein Tokenpaar nach erfolgreicher Anmeldung. */
|
||||||
export function storeTokens(tokenPair: TokenPair): void {
|
export function storeTokens(tokenPair: TokenPair): void {
|
||||||
currentAccessToken = tokenPair.access_token;
|
currentAccessToken = tokenPair.access_token;
|
||||||
currentRefreshToken = tokenPair.refresh_token;
|
currentRefreshToken = tokenPair.refresh_token;
|
||||||
writeStoredSession(tokenPair);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verwirft die hinterlegten Tokens. */
|
/** Verwirft die hinterlegten Tokens. */
|
||||||
export function clearTokens(): void {
|
export function clearTokens(): void {
|
||||||
currentAccessToken = null;
|
currentAccessToken = null;
|
||||||
currentRefreshToken = null;
|
currentRefreshToken = null;
|
||||||
clearStoredSession();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meldet einen Benutzer mit Name und Passwort an.
|
* Meldet einen Benutzer mit Name und Passwort an.
|
||||||
*
|
*
|
||||||
* Ist ein zweiter Faktor eingerichtet, enthält das Ergebnis eine
|
* Ist ein zweiter Faktor eingerichtet, enthaelt das Ergebnis eine
|
||||||
* Herausforderung statt der Tokens.
|
* Herausforderung statt der Tokens.
|
||||||
*/
|
*/
|
||||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||||
@ -80,7 +51,7 @@ export async function login(username: string, password: string): Promise<LoginRe
|
|||||||
return loginResult;
|
return loginResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schließt eine Anmeldung mit dem zweiten Faktor ab. */
|
/** Schliesst eine Anmeldung mit dem zweiten Faktor ab. */
|
||||||
export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> {
|
export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> {
|
||||||
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
|
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -105,7 +76,7 @@ export async function logout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt den aktuell angemeldeten Benutzer. */
|
/** Laedt den aktuell angemeldeten Benutzer. */
|
||||||
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||||
return requestApi<CurrentUser>('/me');
|
return requestApi<CurrentUser>('/me');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prüft die beiden Grenzen der Sitzung.
|
|
||||||
*
|
|
||||||
* Beide sind eine Zusage an den Betreiber, und beide lassen sich mit einer
|
|
||||||
* falschen Zeile still aushebeln: Wer beim Vermerken einer Interaktion die
|
|
||||||
* harte Obergrenze mitverschiebt, macht aus „30 Minuten" ein „unbegrenzt,
|
|
||||||
* solange die Maus wackelt".
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
SESSION_IDLE_MS,
|
|
||||||
SESSION_MAXIMUM_MS,
|
|
||||||
clearStoredSession,
|
|
||||||
readStoredSession,
|
|
||||||
remainingSessionMs,
|
|
||||||
touchStoredSession,
|
|
||||||
writeStoredSession,
|
|
||||||
} from './sessionStore';
|
|
||||||
|
|
||||||
const tokenPair = {
|
|
||||||
access_token: 'zugriff',
|
|
||||||
refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis
|
|
||||||
access_expires_at: '',
|
|
||||||
refresh_expires_at: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Sitzungsablage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date('2026-08-18T10:00:00Z'));
|
|
||||||
clearStoredSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('überlebt ein Neuladen innerhalb der Frist', () => {
|
|
||||||
writeStoredSession(tokenPair);
|
|
||||||
|
|
||||||
// Ein Neuladen bedeutet: neuer Arbeitsspeicher, gleiche Ablage.
|
|
||||||
const resumed = readStoredSession();
|
|
||||||
|
|
||||||
expect(resumed).not.toBeNull();
|
|
||||||
expect(resumed?.accessToken).toBe('zugriff');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('endet nach der harten Obergrenze, auch bei ständiger Interaktion', () => {
|
|
||||||
writeStoredSession(tokenPair);
|
|
||||||
|
|
||||||
// Alle fünf Minuten eine Interaktion — sieben Mal, also 35 Minuten.
|
|
||||||
for (let step = 0; step < 7; step += 1) {
|
|
||||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
|
||||||
touchStoredSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Untätigkeitsgrenze wäre nie erreicht worden. Die Obergrenze schon.
|
|
||||||
expect(readStoredSession()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('endet bei Untätigkeit vor der Obergrenze', () => {
|
|
||||||
writeStoredSession(tokenPair);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(SESSION_IDLE_MS + 1000);
|
|
||||||
|
|
||||||
expect(readStoredSession()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verschiebt die Obergrenze durch eine Interaktion nicht', () => {
|
|
||||||
const created = writeStoredSession(tokenPair);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(60 * 1000);
|
|
||||||
const touched = touchStoredSession();
|
|
||||||
|
|
||||||
expect(touched?.absoluteExpiryMs).toBe(created.absoluteExpiryMs);
|
|
||||||
expect(touched?.lastActivityMs).toBeGreaterThan(created.lastActivityMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nennt die frühere der beiden Grenzen als Restzeit', () => {
|
|
||||||
const created = writeStoredSession(tokenPair);
|
|
||||||
|
|
||||||
// Direkt nach der Anmeldung fallen beide Grenzen zusammen.
|
|
||||||
expect(remainingSessionMs(created)).toBe(SESSION_MAXIMUM_MS);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(20 * 60 * 1000);
|
|
||||||
|
|
||||||
const stillValid = readStoredSession();
|
|
||||||
|
|
||||||
expect(stillValid).not.toBeNull();
|
|
||||||
// Zehn Minuten bis zur Obergrenze, dreißig bis zur Untätigkeitsgrenze —
|
|
||||||
// maßgeblich sind die zehn.
|
|
||||||
expect(remainingSessionMs(stillValid!)).toBe(10 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verwirft einen unlesbaren Eintrag, statt ihn zu raten', () => {
|
|
||||||
window.sessionStorage.setItem('syncova.session', '{kein gültiges json');
|
|
||||||
|
|
||||||
expect(readStoredSession()).toBeNull();
|
|
||||||
expect(window.sessionStorage.getItem('syncova.session')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ablage der Sitzung.
|
|
||||||
*
|
|
||||||
* **Diese Datei kehrt eine frühere Entscheidung um, und zwar bewusst.**
|
|
||||||
*
|
|
||||||
* Bisher lagen die Tokens ausschließlich im Arbeitsspeicher. Das war die
|
|
||||||
* sicherste Variante und praktisch unbrauchbar: Jedes Neuladen — F5, ein
|
|
||||||
* versehentlicher Klick auf „Aktualisieren", ein Absturz des Tabs — warf den
|
|
||||||
* Betreiber auf die Anmeldemaske zurück. Mitten in einer Störung ist das kein
|
|
||||||
* Sicherheitsgewinn, sondern ein Hindernis.
|
|
||||||
*
|
|
||||||
* Gewählt ist `sessionStorage`, nicht `localStorage`:
|
|
||||||
*
|
|
||||||
* | | überlebt F5 | überlebt Tab-Schließen | überlebt Neustart |
|
|
||||||
* | --- | --- | --- | --- |
|
|
||||||
* | Arbeitsspeicher | nein | nein | nein |
|
|
||||||
* | **sessionStorage** | **ja** | **nein** | **nein** |
|
|
||||||
* | localStorage | ja | ja | ja |
|
|
||||||
*
|
|
||||||
* Was den Rest trägt, ist nicht der Speicherort, sondern die Begrenzung:
|
|
||||||
*
|
|
||||||
* 1. **Harte Obergrenze** von 30 Minuten ab Anmeldung — unabhängig von jeder
|
|
||||||
* Aktivität.
|
|
||||||
* 2. **Untätigkeitsgrenze**: Wer nichts tut, wird abgemeldet.
|
|
||||||
* 3. **Sofortiger Widerruf serverseitig.** Die Tokens sind opak, kein JWT —
|
|
||||||
* genau deshalb wurden sie so gewählt. Ein gesperrtes Konto ist mit der
|
|
||||||
* nächsten Anfrage draußen, nicht erst nach Ablauf.
|
|
||||||
*
|
|
||||||
* Bleibt das Restrisiko: Ein Skript, das über XSS auf der Seite läuft, kann
|
|
||||||
* `sessionStorage` lesen. Dagegen steht die Content-Security-Policy der
|
|
||||||
* Auslieferung — und die Feststellung, dass ein Angreifer mit Skriptausführung
|
|
||||||
* ohnehin jede Anfrage im Namen des Angemeldeten stellen könnte, auch bei
|
|
||||||
* Tokens im Arbeitsspeicher.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { TokenPair } from '../../types/auth';
|
|
||||||
|
|
||||||
/** Schlüssel der Ablage. */
|
|
||||||
const SESSION_STORAGE_KEY = 'syncova.session';
|
|
||||||
|
|
||||||
/** Harte Obergrenze einer Sitzung ab Anmeldung. */
|
|
||||||
export const SESSION_MAXIMUM_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Grenze der Untätigkeit. */
|
|
||||||
export const SESSION_IDLE_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Der abgelegte Zustand einer Sitzung. */
|
|
||||||
export interface StoredSession {
|
|
||||||
readonly accessToken: string;
|
|
||||||
readonly refreshToken: string;
|
|
||||||
/** Zeitpunkt, zu dem die Sitzung endet — unabhängig von Aktivität. */
|
|
||||||
readonly absoluteExpiryMs: number;
|
|
||||||
/** Zeitpunkt der letzten Interaktion. */
|
|
||||||
readonly lastActivityMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Liest die abgelegte Sitzung; `null`, wenn keine gültige vorliegt. */
|
|
||||||
export function readStoredSession(): StoredSession | null {
|
|
||||||
let rawValue: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
rawValue = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
// Ein Browser mit gesperrtem Speicher ist kein Fehlerfall — die Sitzung
|
|
||||||
// gilt dann eben nur für diese Seitenansicht.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawValue === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedSession: Partial<StoredSession>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsedSession = JSON.parse(rawValue) as Partial<StoredSession>;
|
|
||||||
} catch {
|
|
||||||
// Ein unlesbarer Eintrag wird verworfen, nicht geraten.
|
|
||||||
clearStoredSession();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof parsedSession.accessToken !== 'string' ||
|
|
||||||
typeof parsedSession.refreshToken !== 'string' ||
|
|
||||||
typeof parsedSession.absoluteExpiryMs !== 'number' ||
|
|
||||||
typeof parsedSession.lastActivityMs !== 'number'
|
|
||||||
) {
|
|
||||||
clearStoredSession();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = Date.now();
|
|
||||||
|
|
||||||
// Beide Grenzen werden schon beim Lesen geprüft. Eine abgelaufene Sitzung
|
|
||||||
// darf gar nicht erst in den Arbeitsspeicher zurückkehren.
|
|
||||||
if (
|
|
||||||
currentTime >= parsedSession.absoluteExpiryMs ||
|
|
||||||
currentTime - parsedSession.lastActivityMs >= SESSION_IDLE_MS
|
|
||||||
) {
|
|
||||||
clearStoredSession();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedSession as StoredSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt eine neue Sitzung ab und setzt beide Uhren. */
|
|
||||||
export function writeStoredSession(tokenPair: TokenPair): StoredSession {
|
|
||||||
const currentTime = Date.now();
|
|
||||||
|
|
||||||
const storedSession: StoredSession = {
|
|
||||||
accessToken: tokenPair.access_token,
|
|
||||||
refreshToken: tokenPair.refresh_token,
|
|
||||||
absoluteExpiryMs: currentTime + SESSION_MAXIMUM_MS,
|
|
||||||
lastActivityMs: currentTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
persist(storedSession);
|
|
||||||
|
|
||||||
return storedSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vermerkt eine Interaktion.
|
|
||||||
*
|
|
||||||
* Die harte Obergrenze bleibt unberührt — sonst ließe sich eine Sitzung durch
|
|
||||||
* Mausbewegung endlos verlängern, und „30 Minuten" wäre keine Zusage.
|
|
||||||
*/
|
|
||||||
export function touchStoredSession(): StoredSession | null {
|
|
||||||
const storedSession = readStoredSession();
|
|
||||||
|
|
||||||
if (storedSession === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshedSession: StoredSession = {
|
|
||||||
...storedSession,
|
|
||||||
lastActivityMs: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
persist(refreshedSession);
|
|
||||||
|
|
||||||
return refreshedSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verwirft die abgelegte Sitzung. */
|
|
||||||
export function clearStoredSession(): void {
|
|
||||||
try {
|
|
||||||
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
// Nichts zu tun: Es gibt keinen Speicher, aus dem etwas zu entfernen wäre.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schreibt den Zustand in den Sitzungsspeicher. */
|
|
||||||
function persist(storedSession: StoredSession): void {
|
|
||||||
try {
|
|
||||||
window.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession));
|
|
||||||
} catch {
|
|
||||||
// Ohne Speicher läuft die Sitzung weiter, überlebt aber kein Neuladen.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert die verbleibende Zeit in Millisekunden.
|
|
||||||
*
|
|
||||||
* Maßgeblich ist die **frühere** der beiden Grenzen: Eine Sitzung endet, sobald
|
|
||||||
* eine von beiden erreicht ist.
|
|
||||||
*/
|
|
||||||
export function remainingSessionMs(storedSession: StoredSession): number {
|
|
||||||
const currentTime = Date.now();
|
|
||||||
|
|
||||||
const untilAbsolute = storedSession.absoluteExpiryMs - currentTime;
|
|
||||||
const untilIdle = storedSession.lastActivityMs + SESSION_IDLE_MS - currentTime;
|
|
||||||
|
|
||||||
return Math.max(0, Math.min(untilAbsolute, untilIdle));
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sitzungsuhr und Untätigkeitserkennung.
|
|
||||||
*
|
|
||||||
* Der Haken liefert die verbleibende Zeit sekundengenau für die Anzeige und
|
|
||||||
* meldet das Ende, sobald eine der beiden Grenzen erreicht ist.
|
|
||||||
*
|
|
||||||
* Umgesetzt mit `useSyncExternalStore`, und das ist keine Geschmacksfrage: Die
|
|
||||||
* verbleibende Zeit hängt an zwei Dingen außerhalb von React — der Uhr und dem
|
|
||||||
* Sitzungsspeicher. Sie beim Rendern auszurechnen wäre ein unreiner Aufruf, sie
|
|
||||||
* in einem Effekt in den Zustand zu schreiben eine zweite Renderrunde je
|
|
||||||
* Sekunde. `useSyncExternalStore` ist genau für diesen Fall gebaut.
|
|
||||||
*
|
|
||||||
* Die Interaktionen werden **gedrosselt** vermerkt: Jede Mausbewegung in den
|
|
||||||
* Sitzungsspeicher zu schreiben wären hunderte Schreibvorgänge je Minute, und
|
|
||||||
* `sessionStorage` schreibt synchron. Einmal alle zehn Sekunden genügt — die
|
|
||||||
* Grenze liegt bei dreißig Minuten.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
|
|
||||||
import {
|
|
||||||
readStoredSession,
|
|
||||||
remainingSessionMs,
|
|
||||||
touchStoredSession,
|
|
||||||
} from './sessionStore';
|
|
||||||
|
|
||||||
/** Abstand, in dem eine Interaktion frühestens erneut vermerkt wird. */
|
|
||||||
const ACTIVITY_THROTTLE_MS = 10_000;
|
|
||||||
|
|
||||||
/** Ereignisse, die als Interaktion gelten. */
|
|
||||||
const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
|
|
||||||
|
|
||||||
export interface SessionClock {
|
|
||||||
/** Verbleibende Sekunden bis zum Ende der Sitzung. */
|
|
||||||
readonly remainingSeconds: number;
|
|
||||||
/** Fällt die Sitzung in Kürze aus? */
|
|
||||||
readonly isExpiringSoon: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Angemeldete Beobachter der Sitzungsuhr. */
|
|
||||||
const clockSubscribers = new Set<() => void>();
|
|
||||||
|
|
||||||
/** Zeitgeber, der einmal je Sekunde alle Beobachter weckt. */
|
|
||||||
let clockIntervalHandle: number | null = null;
|
|
||||||
|
|
||||||
/** Meldet einen Beobachter an und startet den Zeitgeber beim ersten. */
|
|
||||||
function subscribeToClock(notify: () => void): () => void {
|
|
||||||
clockSubscribers.add(notify);
|
|
||||||
|
|
||||||
if (clockIntervalHandle === null) {
|
|
||||||
clockIntervalHandle = window.setInterval(() => {
|
|
||||||
for (const subscriber of clockSubscribers) {
|
|
||||||
subscriber();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clockSubscribers.delete(notify);
|
|
||||||
|
|
||||||
// Ohne Beobachter läuft kein Zeitgeber weiter — sonst tickte er nach dem
|
|
||||||
// Abmelden bis zum Schließen des Tabs.
|
|
||||||
if (clockSubscribers.size === 0 && clockIntervalHandle !== null) {
|
|
||||||
window.clearInterval(clockIntervalHandle);
|
|
||||||
clockIntervalHandle = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert die verbleibenden Sekunden.
|
|
||||||
*
|
|
||||||
* Der Wert ist ganzzahlig, damit `useSyncExternalStore` ihn zwischen zwei
|
|
||||||
* Sekunden als unverändert erkennt und kein Rendern auslöst.
|
|
||||||
*/
|
|
||||||
function readRemainingSeconds(): number {
|
|
||||||
const storedSession = readStoredSession();
|
|
||||||
|
|
||||||
if (storedSession === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.ceil(remainingSessionMs(storedSession) / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ohne Anmeldung läuft keine Uhr. */
|
|
||||||
function readZero(): number {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Beobachtet die Sitzung und ruft `onExpired` genau einmal auf.
|
|
||||||
*
|
|
||||||
* @param isActive Nur bei bestehender Anmeldung läuft die Uhr.
|
|
||||||
* @param onExpired Wird beim Ablauf aufgerufen.
|
|
||||||
*/
|
|
||||||
export function useSession(isActive: boolean, onExpired: () => void): SessionClock {
|
|
||||||
const remainingSeconds = useSyncExternalStore(
|
|
||||||
isActive ? subscribeToClock : noopSubscribe,
|
|
||||||
isActive ? readRemainingSeconds : readZero,
|
|
||||||
readZero,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onExpiredReference = useRef(onExpired);
|
|
||||||
const lastActivityWriteReference = useRef(0);
|
|
||||||
const hasExpiredReference = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onExpiredReference.current = onExpired;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Vermerkt eine Interaktion, höchstens alle zehn Sekunden. */
|
|
||||||
const noteActivity = useCallback(() => {
|
|
||||||
const activityTime = Date.now();
|
|
||||||
|
|
||||||
if (activityTime - lastActivityWriteReference.current < ACTIVITY_THROTTLE_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastActivityWriteReference.current = activityTime;
|
|
||||||
touchStoredSession();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive) {
|
|
||||||
hasExpiredReference.current = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const eventName of ACTIVITY_EVENTS) {
|
|
||||||
// `passive` verhindert, dass die Erkennung das Scrollen ausbremst.
|
|
||||||
window.addEventListener(eventName, noteActivity, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
for (const eventName of ACTIVITY_EVENTS) {
|
|
||||||
window.removeEventListener(eventName, noteActivity);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isActive, noteActivity]);
|
|
||||||
|
|
||||||
// Das Ende wird gemeldet, sobald der abgeleitete Wert null erreicht — als
|
|
||||||
// Folge einer Zustandsänderung, nicht als Nebeneffekt des Renderns.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive || remainingSeconds > 0 || hasExpiredReference.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasExpiredReference.current = true;
|
|
||||||
onExpiredReference.current();
|
|
||||||
}, [isActive, remainingSeconds]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
remainingSeconds,
|
|
||||||
// Fünf Minuten vorher wird die Anzeige auffällig. Früher zu warnen
|
|
||||||
// erzeugt Gewöhnung, später lässt keine Zeit zum Speichern.
|
|
||||||
isExpiringSoon: remainingSeconds > 0 && remainingSeconds <= 300,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ohne Anmeldung gibt es nichts zu beobachten. */
|
|
||||||
function noopSubscribe(): () => void {
|
|
||||||
return () => undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formatiert eine Sekundenzahl als `mm:ss`. */
|
|
||||||
export function formatSessionClock(totalSeconds: number): string {
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
|
|
||||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
@ -1,591 +0,0 @@
|
|||||||
/**
|
|
||||||
* Einzelheiten eines Wiederherstellungspunkts.
|
|
||||||
*
|
|
||||||
* Diese Seite beantwortet die einzige Frage, die bei einem Backup zählt:
|
|
||||||
* **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der
|
|
||||||
* die Antwort belastbar wird:
|
|
||||||
*
|
|
||||||
* 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist.
|
|
||||||
* 2. Die fehlenden Messungen sind die Handlungsanweisung.
|
|
||||||
* 3. Die Prüfung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
|
|
||||||
* 4. Der Schutz sagt, ob jemand das alles löschen könnte.
|
|
||||||
*
|
|
||||||
* Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest
|
|
||||||
* sich wie "weitgehend in Ordnung" — bei einem beschädigten Backup wäre das
|
|
||||||
* grob irreführend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
|
|
||||||
* nicht.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BadgeCheck, RotateCcw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
ConfirmDialog,
|
|
||||||
DetailItem,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
SelectInput,
|
|
||||||
StatusBadge,
|
|
||||||
TextArea,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { cn, formatDateTime } from '@/lib/utils';
|
|
||||||
import { RestoreWizard } from '../restores/RestoreWizard';
|
|
||||||
import {
|
|
||||||
VERIFICATION_TYPES,
|
|
||||||
deleteBackup,
|
|
||||||
extendRetention,
|
|
||||||
getAssurance,
|
|
||||||
getProtection,
|
|
||||||
getRansomwareAssessment,
|
|
||||||
releaseLegalHold,
|
|
||||||
setLegalHold,
|
|
||||||
startVerification,
|
|
||||||
} from './backupsApi';
|
|
||||||
|
|
||||||
export function BackupDetailPage({
|
|
||||||
backupIdentifier,
|
|
||||||
grantedPermissions,
|
|
||||||
onBack,
|
|
||||||
onDeleted,
|
|
||||||
}: {
|
|
||||||
readonly backupIdentifier: string;
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly onBack: () => void;
|
|
||||||
readonly onDeleted: () => void;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isRestoreWizardOpen, setIsRestoreWizardOpen] = useState(false);
|
|
||||||
const [isVerifyDialogOpen, setIsVerifyDialogOpen] = useState(false);
|
|
||||||
const [isLegalHoldDialogOpen, setIsLegalHoldDialogOpen] = useState(false);
|
|
||||||
const [isReleaseDialogOpen, setIsReleaseDialogOpen] = useState(false);
|
|
||||||
const [isExtendDialogOpen, setIsExtendDialogOpen] = useState(false);
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const [verificationType, setVerificationType] = useState('chunk_integrity');
|
|
||||||
const [holdReason, setHoldReason] = useState('');
|
|
||||||
const [newRetentionDate, setNewRetentionDate] = useState('');
|
|
||||||
|
|
||||||
const assuranceResource = useApiResource(
|
|
||||||
useCallback(
|
|
||||||
(abortSignal) => getAssurance(backupIdentifier, abortSignal),
|
|
||||||
[backupIdentifier],
|
|
||||||
),
|
|
||||||
backupIdentifier,
|
|
||||||
);
|
|
||||||
const protectionResource = useApiResource(
|
|
||||||
useCallback(
|
|
||||||
(abortSignal) => getProtection(backupIdentifier, abortSignal),
|
|
||||||
[backupIdentifier],
|
|
||||||
),
|
|
||||||
backupIdentifier,
|
|
||||||
);
|
|
||||||
const ransomwareResource = useApiResource(
|
|
||||||
useCallback(
|
|
||||||
(abortSignal) => getRansomwareAssessment(backupIdentifier, abortSignal),
|
|
||||||
[backupIdentifier],
|
|
||||||
),
|
|
||||||
backupIdentifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayVerify =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('verification.write');
|
|
||||||
const mayRestoreTest =
|
|
||||||
grantedPermissions.includes('*') ||
|
|
||||||
grantedPermissions.includes('verification.restore_test');
|
|
||||||
const mayManageImmutability =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('immutability.manage');
|
|
||||||
const mayDelete =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('backups.delete');
|
|
||||||
const mayRestore =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('restores.execute');
|
|
||||||
|
|
||||||
const reloadAll = () => {
|
|
||||||
assuranceResource.reload();
|
|
||||||
protectionResource.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyMutation = useMutation(startVerification, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Prüfung gestartet',
|
|
||||||
'Das Ergebnis erscheint unter „Prüfung“, sobald sie durchgelaufen ist.',
|
|
||||||
);
|
|
||||||
setIsVerifyDialogOpen(false);
|
|
||||||
reloadAll();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Prüfung ließ sich nicht starten', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const legalHoldMutation = useMutation(setLegalHold, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt lässt sich jetzt nicht löschen.');
|
|
||||||
setIsLegalHoldDialogOpen(false);
|
|
||||||
setHoldReason('');
|
|
||||||
reloadAll();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const releaseMutation = useMutation(releaseLegalHold, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Legal Hold aufgehoben');
|
|
||||||
setIsReleaseDialogOpen(false);
|
|
||||||
setHoldReason('');
|
|
||||||
reloadAll();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const extendMutation = useMutation(extendRetention, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Aufbewahrungsfrist verlängert');
|
|
||||||
setIsExtendDialogOpen(false);
|
|
||||||
reloadAll();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation(deleteBackup, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Wiederherstellungspunkt gelöscht',
|
|
||||||
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Blöcke werden von einem anderen Backup gebraucht.',
|
|
||||||
);
|
|
||||||
onDeleted();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (assuranceResource.loadState === 'loading') {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assuranceResource.loadState === 'failed' || !assuranceResource.data) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Wiederherstellungspunkt" onBack={onBack} />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={
|
|
||||||
assuranceResource.loadError
|
|
||||||
? describeApiError(assuranceResource.loadError)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
requestId={assuranceResource.loadError?.requestId}
|
|
||||||
onRetry={assuranceResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assurance = assuranceResource.data;
|
|
||||||
const protection = protectionResource.data;
|
|
||||||
const ransomware = ransomwareResource.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Wiederherstellungspunkt"
|
|
||||||
description={backupIdentifier}
|
|
||||||
onBack={onBack}
|
|
||||||
backLabel="Zu den Wiederherstellungspunkten"
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
{mayRestore ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsRestoreWizardOpen(true)}>
|
|
||||||
<RotateCcw />
|
|
||||||
Wiederherstellen
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{mayVerify ? (
|
|
||||||
<Button onClick={() => setIsVerifyDialogOpen(true)}>
|
|
||||||
<BadgeCheck />
|
|
||||||
Prüfen
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
{/* --- Bewertung --- */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader
|
|
||||||
title="Wiederherstellbarkeit"
|
|
||||||
description={assurance.classification_description}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-4xl font-semibold tabular',
|
|
||||||
assurance.classification === 'corrupted'
|
|
||||||
? 'text-critical'
|
|
||||||
: assurance.classification === 'recoverable'
|
|
||||||
? 'text-healthy'
|
|
||||||
: 'text-fg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{assurance.percentage} %
|
|
||||||
</p>
|
|
||||||
<StatusBadge status={assurance.classification} className="mt-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="min-w-0 flex-1 text-sm text-fg-muted">{assurance.summary}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als
|
|
||||||
Vermutung gekennzeichnet — sonst wird sie zitiert, als wäre
|
|
||||||
sie eine Messung. */}
|
|
||||||
{!assurance.is_trustworthy ? (
|
|
||||||
<Callout tone="warning" title="Diese Zahl ist eine Vermutung" className="mt-4">
|
|
||||||
{assurance.unknown_input_count} Eingangsgrößen wurden nicht
|
|
||||||
gemessen und gehen weder positiv noch negativ ein. Solange das so
|
|
||||||
ist, sagt der Prozentwert wenig.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{assurance.missing_measurements.length > 0 ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Was fehlt
|
|
||||||
</p>
|
|
||||||
<ul className="mt-1.5 space-y-1">
|
|
||||||
{assurance.missing_measurements.map((measurement) => (
|
|
||||||
<li key={measurement} className="text-sm text-fg-muted">
|
|
||||||
• {measurement}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-2 border-t border-line pt-4">
|
|
||||||
{assurance.inputs.map((input) => (
|
|
||||||
<div key={input.name} className="flex items-center gap-3">
|
|
||||||
<span className="w-52 shrink-0 truncate text-sm text-fg">
|
|
||||||
{input.name}
|
|
||||||
</span>
|
|
||||||
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-sunken">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-full rounded-full',
|
|
||||||
input.is_known ? 'bg-accent' : 'bg-neutral',
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: `${input.weight > 0 ? (input.earned_points / input.weight) * 100 : 0}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-20 shrink-0 text-right text-xs tabular text-fg-muted">
|
|
||||||
{input.is_known ? (
|
|
||||||
`${input.earned_points} / ${input.weight}`
|
|
||||||
) : (
|
|
||||||
// Ungemessen ist nicht null Punkte, sondern keine
|
|
||||||
// Aussage. Als 0 zu zeigen bestrafte das Unbekannte.
|
|
||||||
<span className="text-fg-subtle">ungemessen</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="mt-5 grid gap-4 border-t border-line pt-4 sm:grid-cols-2">
|
|
||||||
<DetailItem label="Zuletzt geprüft">
|
|
||||||
{assurance.last_verified_at
|
|
||||||
? formatDateTime(assurance.last_verified_at)
|
|
||||||
: 'Nie'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Letzter Wiederherstellungstest">
|
|
||||||
{assurance.last_restore_test_at ? (
|
|
||||||
formatDateTime(assurance.last_restore_test_at)
|
|
||||||
) : (
|
|
||||||
<span className="text-warning">
|
|
||||||
Nie — die Wiederherstellbarkeit ist unbewiesen
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* --- Schutz --- */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="Löschschutz" />
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{protection ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{protection.is_protected ? (
|
|
||||||
<ShieldCheck className="size-4 text-healthy" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<ShieldOff className="size-4 text-fg-subtle" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-fg">
|
|
||||||
{protection.is_protected ? 'Geschützt' : 'Nicht geschützt'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-fg-muted">{protection.description}</p>
|
|
||||||
|
|
||||||
{protection.immutable_until ? (
|
|
||||||
<DetailItem label="Aufbewahrung bis">
|
|
||||||
{formatDateTime(protection.immutable_until)}
|
|
||||||
</DetailItem>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{protection.legal_hold ? (
|
|
||||||
<Callout tone="info" title="Legal Hold">
|
|
||||||
{protection.legal_hold_reason || 'Ohne Begründung gesetzt.'}
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{protection.enforcement_level ? (
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Durchsetzungsstufe (gemessen)
|
|
||||||
</p>
|
|
||||||
<StatusBadge status={protection.enforcement_level} className="mt-1" />
|
|
||||||
{protection.enforcement_explanation ? (
|
|
||||||
<p className="mt-1.5 text-xs text-fg-muted">
|
|
||||||
{protection.enforcement_explanation}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{mayManageImmutability ? (
|
|
||||||
<div className="flex flex-wrap gap-2 border-t border-line pt-3">
|
|
||||||
{protection.legal_hold ? (
|
|
||||||
<Button size="sm" onClick={() => setIsReleaseDialogOpen(true)}>
|
|
||||||
Legal Hold aufheben
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" onClick={() => setIsLegalHoldDialogOpen(true)}>
|
|
||||||
Legal Hold setzen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={() => setIsExtendDialogOpen(true)}>
|
|
||||||
Frist verlängern
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-fg-muted">Der Schutzstatus wird geladen …</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* --- Ransomware --- */}
|
|
||||||
{ransomware ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="Auffälligkeiten" />
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<StatusBadge status={ransomware.severity} />
|
|
||||||
{ransomware.summary ? (
|
|
||||||
<p className="text-sm text-fg-muted">{ransomware.summary}</p>
|
|
||||||
) : null}
|
|
||||||
{ransomware.severity === 'unknown' ? (
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
Unter fünf Vergleichsläufen gibt es keinen belastbaren
|
|
||||||
Basiswert. Wer nicht messen kann, hat nichts gemessen — eine
|
|
||||||
geratene Schwelle wäre schlechter als keine.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{ransomware.triggered_signal_count !== undefined ? (
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{ransomware.triggered_signal_count} von 6 Signalen auffällig.
|
|
||||||
Diese Einschätzung <strong>meldet nur</strong> und handelt nie.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{mayDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => setIsDeleteDialogOpen(true)}
|
|
||||||
disabled={protection?.is_protected}
|
|
||||||
title={
|
|
||||||
protection?.is_protected
|
|
||||||
? 'Dieser Punkt steht unter Schutz und lässt sich nicht löschen.'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- Dialoge --- */}
|
|
||||||
|
|
||||||
{isRestoreWizardOpen ? (
|
|
||||||
<RestoreWizard
|
|
||||||
backupIdentifier={backupIdentifier}
|
|
||||||
grantedPermissions={grantedPermissions}
|
|
||||||
onClose={() => setIsRestoreWizardOpen(false)}
|
|
||||||
onStarted={() => setIsRestoreWizardOpen(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={isVerifyDialogOpen}
|
|
||||||
onOpenChange={setIsVerifyDialogOpen}
|
|
||||||
title="Prüfung starten"
|
|
||||||
confirmLabel="Prüfung starten"
|
|
||||||
isLoading={verifyMutation.isRunning}
|
|
||||||
onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<SelectInput
|
|
||||||
label="Prüfart"
|
|
||||||
value={verificationType}
|
|
||||||
onChange={(changeEvent) => setVerificationType(changeEvent.target.value)}
|
|
||||||
>
|
|
||||||
{VERIFICATION_TYPES.map((type) => (
|
|
||||||
<option
|
|
||||||
key={type.value}
|
|
||||||
value={type.value}
|
|
||||||
disabled={type.value === 'restore_test' && !mayRestoreTest}
|
|
||||||
>
|
|
||||||
{type.label}
|
|
||||||
{type.value === 'restore_test' && !mayRestoreTest ? ' (Recht fehlt)' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</SelectInput>
|
|
||||||
|
|
||||||
<p className="text-sm text-fg-muted">
|
|
||||||
{VERIFICATION_TYPES.find((type) => type.value === verificationType)?.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{verificationType === 'restore_test' ? (
|
|
||||||
<Callout tone="info">
|
|
||||||
Nur diese Prüfung ist ein <strong>Nachweis</strong>. Sie hebt die
|
|
||||||
Einstufung auf „wiederherstellbar“ — alles andere sind Indizien.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={isLegalHoldDialogOpen}
|
|
||||||
onOpenChange={setIsLegalHoldDialogOpen}
|
|
||||||
title="Legal Hold setzen"
|
|
||||||
description="Der Punkt lässt sich danach nicht mehr löschen — unbefristet."
|
|
||||||
confirmLabel="Legal Hold setzen"
|
|
||||||
isLoading={legalHoldMutation.isRunning}
|
|
||||||
onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
label="Begründung"
|
|
||||||
required
|
|
||||||
value={holdReason}
|
|
||||||
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
|
|
||||||
hint="Pflichtangabe. Ohne sie traut sich später niemand, den Schutz wieder aufzuheben."
|
|
||||||
placeholder="Rechtsstreit Az. 4 O 128/26, angeordnet am 18.08.2026"
|
|
||||||
/>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={isReleaseDialogOpen}
|
|
||||||
onOpenChange={setIsReleaseDialogOpen}
|
|
||||||
title="Legal Hold aufheben"
|
|
||||||
confirmLabel="Aufheben"
|
|
||||||
isDestructive
|
|
||||||
isLoading={releaseMutation.isRunning}
|
|
||||||
onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
label="Begründung"
|
|
||||||
required
|
|
||||||
value={holdReason}
|
|
||||||
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
|
|
||||||
hint="Auch das Aufheben wird protokolliert."
|
|
||||||
/>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={isExtendDialogOpen}
|
|
||||||
onOpenChange={setIsExtendDialogOpen}
|
|
||||||
title="Aufbewahrungsfrist verlängern"
|
|
||||||
confirmLabel="Verlängern"
|
|
||||||
isLoading={extendMutation.isRunning}
|
|
||||||
onConfirm={() =>
|
|
||||||
void extendMutation.run(
|
|
||||||
backupIdentifier,
|
|
||||||
new Date(newRetentionDate).toISOString(),
|
|
||||||
holdReason,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<TextInput
|
|
||||||
label="Neues Fristende"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
value={newRetentionDate}
|
|
||||||
onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<TextArea
|
|
||||||
label="Begründung"
|
|
||||||
value={holdReason}
|
|
||||||
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<Callout tone="info">
|
|
||||||
Verlängern ja, <strong>verkürzen nie</strong> — auch nicht für
|
|
||||||
Administratoren. Ein früheres Datum wird abgelehnt.
|
|
||||||
</Callout>
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
|
||||||
title="Wiederherstellungspunkt löschen"
|
|
||||||
confirmLabel="Endgültig löschen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteMutation.isRunning}
|
|
||||||
confirmationWord={backupIdentifier}
|
|
||||||
confirmationHint={
|
|
||||||
<>
|
|
||||||
Tippen Sie zur Bestätigung die Kennung:{' '}
|
|
||||||
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
{backupIdentifier}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={() =>
|
|
||||||
void deleteMutation.run(backupIdentifier, backupIdentifier, holdReason)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Callout tone="warning">
|
|
||||||
Wird danach kein Speicher frei, ist das <strong>kein Fehler</strong>,
|
|
||||||
sondern Deduplizierung: Die Blöcke werden von einem anderen Backup
|
|
||||||
gebraucht.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prüfungen — Übersicht.
|
|
||||||
*
|
|
||||||
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
|
|
||||||
* nebeneinander und wird gern verwechselt:
|
|
||||||
*
|
|
||||||
* - **Zustand** (`status`): Ist die Prüfung durchgelaufen?
|
|
||||||
* - **Ergebnis** (`result`): Was hat sie gefunden?
|
|
||||||
*
|
|
||||||
* Eine gescheiterte Prüfung ist **kein Befund am Backup**. Repository nicht
|
|
||||||
* erreichbar heißt: Auftrag `failed`, Einstufung unberührt. Wer beides
|
|
||||||
* vermengt, baut ein Prüfwerkzeug, das grundlos Alarm schlägt — und das wird
|
|
||||||
* bald nicht mehr ernst genommen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ban, RefreshCw } from 'lucide-react';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes, formatDuration, formatRelativeTime } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
VERIFICATION_TYPES,
|
|
||||||
cancelVerification,
|
|
||||||
listVerifications,
|
|
||||||
type VerificationJob,
|
|
||||||
} from './backupsApi';
|
|
||||||
|
|
||||||
export function VerificationPage({
|
|
||||||
grantedPermissions,
|
|
||||||
onOpenBackup,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly onOpenBackup: (backupIdentifier: string) => void;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
const verificationsResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listVerifications(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('verification.write');
|
|
||||||
|
|
||||||
const cancelMutation = useMutation(cancelVerification, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Prüfung abgebrochen');
|
|
||||||
verificationsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Abbruch schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobs = verificationsResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
header: 'Prüfart',
|
|
||||||
render: (job) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-fg">
|
|
||||||
{VERIFICATION_TYPES.find((type) => type.value === job.verification_type)?.label ??
|
|
||||||
job.verification_type}
|
|
||||||
</p>
|
|
||||||
{job.verification_type === 'restore_test' ? (
|
|
||||||
<p className="text-xs text-healthy">Der einzige echte Nachweis</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (job) => <StatusBadge status={job.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'result',
|
|
||||||
header: 'Ergebnis',
|
|
||||||
render: (job) =>
|
|
||||||
job.result ? (
|
|
||||||
<StatusBadge status={job.result} />
|
|
||||||
) : (
|
|
||||||
// Kein Ergebnis heißt: Die Prüfung lief nicht durch. Das ist etwas
|
|
||||||
// anderes als "ohne Befund" und darf nicht danach aussehen.
|
|
||||||
<span className="text-xs text-fg-muted">—</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'chunks',
|
|
||||||
header: 'Blöcke',
|
|
||||||
numeric: true,
|
|
||||||
render: (job) => (
|
|
||||||
<span>
|
|
||||||
{job.chunks_checked.toLocaleString('de-DE')}
|
|
||||||
{job.chunks_missing > 0 || job.chunks_corrupted > 0 ? (
|
|
||||||
<span className="ml-1 text-critical">
|
|
||||||
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschädigt)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bytes_read',
|
|
||||||
header: 'Gelesen',
|
|
||||||
numeric: true,
|
|
||||||
render: (job) => formatBytes(job.bytes_read),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'duration',
|
|
||||||
header: 'Dauer',
|
|
||||||
numeric: true,
|
|
||||||
render: (job) => formatDuration(job.duration_seconds),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'started',
|
|
||||||
header: 'Begonnen',
|
|
||||||
render: (job) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{formatRelativeTime(job.started_at ?? job.created_at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (job) =>
|
|
||||||
mayWrite && (job.status === 'running' || job.status === 'queued') ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(clickEvent) => {
|
|
||||||
clickEvent.stopPropagation();
|
|
||||||
void cancelMutation.run(job.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ban />
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Prüfung" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(verificationsResource.loadError)}
|
|
||||||
requestId={verificationsResource.loadError.requestId}
|
|
||||||
onRetry={verificationsResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Prüfung"
|
|
||||||
description="Nachweise über die Wiederherstellbarkeit."
|
|
||||||
actions={
|
|
||||||
<Button variant="secondary" onClick={verificationsResource.reload}>
|
|
||||||
<RefreshCw />
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
|
|
||||||
Eine gescheiterte Prüfung ist kein Befund am Backup.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={jobs}
|
|
||||||
getRowKey={(job) => job.id}
|
|
||||||
onRowClick={(job) => onOpenBackup(job.backup_id)}
|
|
||||||
isLoading={verificationsResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Noch keine Prüfung"
|
|
||||||
emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* API-Anbindung der Wiederherstellungspunkte: Bewertung, Schutz, Prüfung.
|
|
||||||
*
|
|
||||||
* Diese drei Dinge gehören zusammen, weil sie dieselbe Frage aus drei
|
|
||||||
* Richtungen beantworten: **Kann ich mich auf dieses Backup verlassen?**
|
|
||||||
*
|
|
||||||
* - Die Bewertung sagt, wie gut die Auskunft belegt ist.
|
|
||||||
* - Der Schutz sagt, ob es jemand löschen könnte.
|
|
||||||
* - Die Prüfung erzeugt den Beleg.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bewertung (Phase 10)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Eine Eingangsgröße der Bewertung. */
|
|
||||||
export interface AssuranceInput {
|
|
||||||
name: string;
|
|
||||||
/** Höchstpunktzahl dieser Größe. */
|
|
||||||
weight: number;
|
|
||||||
earned_points: number;
|
|
||||||
/**
|
|
||||||
* Meldet, ob die Größe gemessen wurde.
|
|
||||||
*
|
|
||||||
* Unbekannt zählt **nie** als gut. Eine nicht gemessene Größe wohlwollend
|
|
||||||
* zu schätzen wäre die bequeme und falsche Entscheidung.
|
|
||||||
*/
|
|
||||||
is_known: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bewertung der Wiederherstellbarkeit. */
|
|
||||||
export interface AssuranceReport {
|
|
||||||
backup_id: string;
|
|
||||||
/** unverified, verified, recoverable oder corrupted. */
|
|
||||||
classification: string;
|
|
||||||
classification_description: string;
|
|
||||||
percentage: number;
|
|
||||||
unknown_input_count: number;
|
|
||||||
/**
|
|
||||||
* Meldet, ob die Zahl belastbar ist.
|
|
||||||
*
|
|
||||||
* Bei zu vielen ungemessenen Größen ist die Prozentzahl eine Vermutung,
|
|
||||||
* keine Aussage — und die Oberfläche muss das sagen.
|
|
||||||
*/
|
|
||||||
is_trustworthy: boolean;
|
|
||||||
summary: string;
|
|
||||||
/** Was gemessen werden müsste. Das ist die Handlungsanweisung. */
|
|
||||||
missing_measurements: string[];
|
|
||||||
inputs: AssuranceInput[];
|
|
||||||
last_verified_at?: string;
|
|
||||||
last_restore_test_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Bewertung eines Wiederherstellungspunkts. */
|
|
||||||
export async function getAssurance(
|
|
||||||
backupIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<AssuranceReport> {
|
|
||||||
return requestApi<AssuranceReport>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/assurance`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Schutz (Phase 11)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Schutzstatus eines Wiederherstellungspunkts. */
|
|
||||||
export interface ProtectionStatus {
|
|
||||||
backup_id: string;
|
|
||||||
backup_id_in_repository: string;
|
|
||||||
/** Ende der Aufbewahrungspflicht. */
|
|
||||||
immutable_until?: string;
|
|
||||||
legal_hold: boolean;
|
|
||||||
legal_hold_reason?: string;
|
|
||||||
is_protected: boolean;
|
|
||||||
description: string;
|
|
||||||
/**
|
|
||||||
* Gemessene Durchsetzungsstufe: filesystem, storage, advisory oder none.
|
|
||||||
*
|
|
||||||
* Sie wird **gemessen**, nicht behauptet: `advisory` bedeutet, dass der
|
|
||||||
* Schutz allein von dieser Software durchgesetzt wird.
|
|
||||||
*/
|
|
||||||
enforcement_level?: string;
|
|
||||||
enforcement_explanation?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt den Schutzstatus. */
|
|
||||||
export async function getProtection(
|
|
||||||
backupIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<ProtectionStatus> {
|
|
||||||
return requestApi<ProtectionStatus>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/protection`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setzt einen Legal Hold.
|
|
||||||
*
|
|
||||||
* Die Begründung ist Pflicht. Ohne sie traut sich später niemand, ihn wieder
|
|
||||||
* aufzuheben — und ein Schutz, den niemand mehr aufhebt, ist ein Datenfriedhof.
|
|
||||||
*/
|
|
||||||
export async function setLegalHold(
|
|
||||||
backupIdentifier: string,
|
|
||||||
reason: string,
|
|
||||||
): Promise<ProtectionStatus> {
|
|
||||||
return requestApi<ProtectionStatus>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/legal-hold`,
|
|
||||||
{ method: 'POST', body: { reason }, idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hebt einen Legal Hold auf. */
|
|
||||||
export async function releaseLegalHold(
|
|
||||||
backupIdentifier: string,
|
|
||||||
reason: string,
|
|
||||||
): Promise<ProtectionStatus> {
|
|
||||||
return requestApi<ProtectionStatus>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/legal-hold`,
|
|
||||||
{ method: 'DELETE', body: { reason }, idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verlängert die Aufbewahrungsfrist.
|
|
||||||
*
|
|
||||||
* Verlängern ja, verkürzen nie — auch nicht für Administratoren. Der Server
|
|
||||||
* lehnt ein früheres Datum ab; die Oberfläche sagt das vorher.
|
|
||||||
*/
|
|
||||||
export async function extendRetention(
|
|
||||||
backupIdentifier: string,
|
|
||||||
immutableUntil: string,
|
|
||||||
reason: string,
|
|
||||||
): Promise<ProtectionStatus> {
|
|
||||||
return requestApi<ProtectionStatus>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/retention/extend`,
|
|
||||||
{ method: 'POST', body: { immutable_until: immutableUntil, reason }, idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Löscht einen Wiederherstellungspunkt.
|
|
||||||
*
|
|
||||||
* `confirm_backup_id` wiederholt die Kennung wörtlich — dieselbe Hürde wie
|
|
||||||
* beim Überschreiben einer Wiederherstellung.
|
|
||||||
*/
|
|
||||||
export async function deleteBackup(
|
|
||||||
backupIdentifier: string,
|
|
||||||
confirmBackupId: string,
|
|
||||||
reason?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return requestApi<void>(`/backups/${encodeURIComponent(backupIdentifier)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: { confirm_backup_id: confirmBackupId, ...(reason ? { reason } : {}) },
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ransomware-Einschätzung (Phase 16)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Einschätzung auf Auffälligkeiten. */
|
|
||||||
export interface RansomwareAssessment {
|
|
||||||
backup_id: string;
|
|
||||||
/** none, elevated, high oder unknown. */
|
|
||||||
severity: string;
|
|
||||||
/**
|
|
||||||
* Zahl der auffälligen Signale von sechs.
|
|
||||||
*
|
|
||||||
* Zwei auffällige ergeben `high`, nicht eines: Ein einzelnes hat viele
|
|
||||||
* harmlose Ursachen; zwei zugleich sind das Muster massenhafter
|
|
||||||
* Verschlüsselung.
|
|
||||||
*/
|
|
||||||
triggered_signal_count?: number;
|
|
||||||
signals?: Array<{
|
|
||||||
name: string;
|
|
||||||
triggered: boolean;
|
|
||||||
explanation: string;
|
|
||||||
}>;
|
|
||||||
summary?: string;
|
|
||||||
baseline_sample_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Ransomware-Einschätzung. */
|
|
||||||
export async function getRansomwareAssessment(
|
|
||||||
backupIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<RansomwareAssessment> {
|
|
||||||
return requestApi<RansomwareAssessment>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/ransomware-assessment`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Prüfung (Phase 10)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Die fünf Prüfarten. */
|
|
||||||
export const VERIFICATION_TYPES = [
|
|
||||||
{
|
|
||||||
value: 'manifest',
|
|
||||||
label: 'Manifest',
|
|
||||||
description: 'Prüft, ob das Manifest lesbar und in sich stimmig ist. Schnell, aber nur ein Indiz.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'chunk_integrity',
|
|
||||||
label: 'Blockprüfung',
|
|
||||||
description:
|
|
||||||
'Prüft jeden Block gegen seine gespeicherte Prüfsumme. Läuft ohne Datenschlüssel — die Kennung beschreibt den Klartext, geprüft wird die abgelegte Form.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'chain',
|
|
||||||
label: 'Kette',
|
|
||||||
description: 'Prüft die Zusatzsicherungen gegen ihre Elternbackups.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'metadata',
|
|
||||||
label: 'Metadaten',
|
|
||||||
description: 'Prüft die Angaben zu Größe, Zeitpunkt und Umfang.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'restore_test',
|
|
||||||
label: 'Wiederherstellungstest',
|
|
||||||
description:
|
|
||||||
'Liest das gesamte Backup und schreibt es versuchsweise zurück. Nur diese Prüfung ist ein Nachweis — alles andere sind Indizien. Sie verlangt ein eigenes Recht.',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Ein Prüfauftrag. */
|
|
||||||
export interface VerificationJob {
|
|
||||||
id: string;
|
|
||||||
backup_id: string;
|
|
||||||
verification_type: string;
|
|
||||||
status: string;
|
|
||||||
/** clean oder findings. */
|
|
||||||
result?: string;
|
|
||||||
chunks_checked: number;
|
|
||||||
chunks_missing: number;
|
|
||||||
chunks_corrupted: number;
|
|
||||||
bytes_read: number;
|
|
||||||
started_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
duration_seconds?: number;
|
|
||||||
error_message?: string;
|
|
||||||
summary?: string;
|
|
||||||
correlation_id: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Prüfaufträge. */
|
|
||||||
export async function listVerifications(
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<VerificationJob[]> {
|
|
||||||
return requestApi<VerificationJob[]>(
|
|
||||||
'/verification?page_size=50',
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Startet eine Prüfung. */
|
|
||||||
export async function startVerification(
|
|
||||||
backupIdentifier: string,
|
|
||||||
verificationType: string,
|
|
||||||
): Promise<VerificationJob> {
|
|
||||||
return requestApi<VerificationJob>('/verification', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { backup_id: backupIdentifier, verification_type: verificationType },
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bricht eine laufende Prüfung ab. */
|
|
||||||
export async function cancelVerification(
|
|
||||||
verificationIdentifier: string,
|
|
||||||
): Promise<VerificationJob> {
|
|
||||||
return requestApi<VerificationJob>(
|
|
||||||
`/verification/${encodeURIComponent(verificationIdentifier)}/cancel`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Tests der Übersicht.
|
* Tests der Uebersicht.
|
||||||
*
|
*
|
||||||
* Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf
|
* Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf
|
||||||
* niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken,
|
* niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken,
|
||||||
@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { DashboardPage } from './DashboardPage';
|
import { DashboardPage } from './DashboardPage';
|
||||||
import type { Dashboard } from './dashboardApi';
|
import type { Dashboard } from './dashboardApi';
|
||||||
|
|
||||||
/** Baut eine Antwort mit den übergebenen Kennzahlen. */
|
/** Baut eine Antwort mit den uebergebenen Kennzahlen. */
|
||||||
function buildDashboardResponse(dashboard: Dashboard): Response {
|
function buildDashboardResponse(dashboard: Dashboard): Response {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -21,7 +21,7 @@ function buildDashboardResponse(dashboard: Dashboard): Response {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Übersicht', () => {
|
describe('Uebersicht', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
});
|
});
|
||||||
@ -30,7 +30,7 @@ describe('Übersicht', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt eine Kennzahl samt Erläuterung', async () => {
|
it('zeigt eine Kennzahl samt Erlaeuterung', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValue(
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
buildDashboardResponse({
|
buildDashboardResponse({
|
||||||
available_count: 1,
|
available_count: 1,
|
||||||
@ -42,7 +42,7 @@ describe('Übersicht', () => {
|
|||||||
available: true,
|
available: true,
|
||||||
value: 87.5,
|
value: 87.5,
|
||||||
unit: '%',
|
unit: '%',
|
||||||
detail: '7 von 8 Läufen vollständig erfolgreich.',
|
detail: '7 von 8 Laeufen vollstaendig erfolgreich.',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -53,7 +53,7 @@ describe('Übersicht', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument();
|
expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('87,5')).toBeInTheDocument();
|
expect(screen.getByText('87,5')).toBeInTheDocument();
|
||||||
expect(screen.getByText('7 von 8 Läufen vollständig erfolgreich.')).toBeInTheDocument();
|
expect(screen.getByText('7 von 8 Laeufen vollstaendig erfolgreich.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => {
|
it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => {
|
||||||
@ -78,10 +78,10 @@ describe('Übersicht', () => {
|
|||||||
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
|
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
|
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
|
||||||
|
|
||||||
// Der entscheidende Teil: keine Null. Eine Null hieße „keine Probleme" und
|
// Der entscheidende Teil: keine Null. Eine Null hiesse „keine Probleme" und
|
||||||
// würde bedeuten „es wird nicht geprüft".
|
// wuerde bedeuten „es wird nicht geprueft".
|
||||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('noch nicht verfügbar')).toBeInTheDocument();
|
expect(screen.getByText('noch nicht verfuegbar')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('meldet eine nicht bezifferbare Kennzahl als solche', async () => {
|
it('meldet eine nicht bezifferbare Kennzahl als solche', async () => {
|
||||||
@ -94,7 +94,7 @@ describe('Übersicht', () => {
|
|||||||
key: 'storage',
|
key: 'storage',
|
||||||
title: 'Speicherbelegung',
|
title: 'Speicherbelegung',
|
||||||
available: true,
|
available: true,
|
||||||
detail: 'Die Gesamtkapazität ist nicht hinterlegt.',
|
detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.',
|
||||||
severity: 'information',
|
severity: 'information',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -103,7 +103,7 @@ describe('Übersicht', () => {
|
|||||||
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
|
|
||||||
// Verfügbar, aber ohne Wert: Auch hier darf keine Null stehen.
|
// Verfuegbar, aber ohne Wert: Auch hier darf keine Null stehen.
|
||||||
expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument();
|
expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,159 +1,114 @@
|
|||||||
/**
|
/**
|
||||||
* Übersicht der Anlage.
|
* Uebersicht der Anlage.
|
||||||
*
|
*
|
||||||
* Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
|
* Der Plan (§14) nennt zehn Kennzahlen. Sieben haben eine Datengrundlage, drei
|
||||||
* Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht
|
* nicht — und die drei erscheinen trotzdem, mit der Angabe, was fehlt. Ein
|
||||||
* aus wie ein vergessenes; ein mit einer Null gefülltes wäre eine erfundene
|
* weggelassenes Feld sieht aus wie ein vergessenes; ein mit einer Null
|
||||||
* Statistik (PROMPT.md §139).
|
* gefuelltes waere eine erfundene Statistik (PROMPT.md §139).
|
||||||
*
|
|
||||||
* Der Schweregrad färbt nur die Randlinie, nicht die Kachel. Zehn farbige
|
|
||||||
* Flächen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
|
|
||||||
* untergeht — und genau die soll auffallen.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
import { describeApiError } from '@/api/useMutation';
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { Card, ErrorState, LoadingState } from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { QuickActions } from './QuickActions';
|
|
||||||
import { fetchDashboard } from './dashboardApi';
|
import { fetchDashboard } from './dashboardApi';
|
||||||
import type { Dashboard, DashboardWidget } from './dashboardApi';
|
import type { Dashboard, DashboardWidget } from './dashboardApi';
|
||||||
|
|
||||||
export function DashboardPage({
|
/** Zeigt die Uebersicht. */
|
||||||
grantedPermissions = [],
|
export function DashboardPage(): React.JSX.Element {
|
||||||
onNavigate,
|
const loadDashboard = useCallback((abortSignal: AbortSignal) => fetchDashboard(abortSignal), []);
|
||||||
}: {
|
|
||||||
readonly grantedPermissions?: readonly string[] | undefined;
|
|
||||||
/** Fehlt der Rückruf, entfallen die Schnellzugriffe. */
|
|
||||||
readonly onNavigate?: ((pageIdentifier: string) => void) | undefined;
|
|
||||||
} = {}): React.JSX.Element {
|
|
||||||
const loadDashboard = useCallback(
|
|
||||||
(abortSignal: AbortSignal) => fetchDashboard(abortSignal),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const { loadState, data, loadError, reload } = useApiResource<Dashboard>(loadDashboard);
|
const { loadState, data, loadError, reload } = useApiResource<Dashboard>(loadDashboard);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="page">
|
||||||
<PageHeader
|
<header className="page__header">
|
||||||
title="Übersicht"
|
<h1 className="page__title">Uebersicht</h1>
|
||||||
description={
|
|
||||||
data !== null
|
|
||||||
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{onNavigate ? (
|
{data !== null && (
|
||||||
<QuickActions grantedPermissions={grantedPermissions} onNavigate={onNavigate} />
|
<span className="page__meta">
|
||||||
) : null}
|
{data.available_count} von {data.widgets.length} Kennzahlen haben eine Datengrundlage
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
{loadState === 'loading' ? (
|
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />}
|
||||||
<Card>
|
{loadState === 'failed' && loadError !== null && (
|
||||||
<LoadingState label="Die Kennzahlen werden geladen …" />
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
</Card>
|
)}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'failed' && loadError !== null ? (
|
{loadState === 'loaded' && data !== null && (
|
||||||
<Card>
|
<div className="widget-grid">
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(loadError)}
|
|
||||||
requestId={loadError.requestId}
|
|
||||||
onRetry={reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null ? (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{data.widgets.map((dashboardWidget) => (
|
{data.widgets.map((dashboardWidget) => (
|
||||||
<WidgetCard key={dashboardWidget.key} widget={dashboardWidget} />
|
<WidgetCard key={dashboardWidget.key} widget={dashboardWidget} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Randfarbe je Schweregrad. Nur die Linie, nicht die Fläche. */
|
/** Eigenschaften einer Kennzahlkachel. */
|
||||||
const severityBorderClasses: Readonly<Record<string, string>> = {
|
interface WidgetCardProperties {
|
||||||
healthy: 'border-l-healthy',
|
/** Die dargestellte Kennzahl. */
|
||||||
info: 'border-l-info',
|
readonly widget: DashboardWidget;
|
||||||
warning: 'border-l-warning',
|
}
|
||||||
high: 'border-l-high',
|
|
||||||
critical: 'border-l-critical',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Zeigt eine einzelne Kennzahl. */
|
/** Zeigt eine einzelne Kennzahl. */
|
||||||
function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX.Element {
|
function WidgetCard({ widget }: WidgetCardProperties): React.JSX.Element {
|
||||||
if (!widget.available) {
|
if (!widget.available) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-l-4 border-l-neutral p-4">
|
<article className="widget widget--unavailable">
|
||||||
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
|
<h2 className="widget__title">{widget.title}</h2>
|
||||||
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfügbar</p>
|
<p className="widget__unavailable">noch nicht verfuegbar</p>
|
||||||
<p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
|
<p className="widget__detail">{widget.unavailable_reason}</p>
|
||||||
</Card>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<article className={`widget widget--${widget.severity}`}>
|
||||||
className={cn(
|
<h2 className="widget__title">{widget.title}</h2>
|
||||||
'border-l-4 p-4',
|
|
||||||
severityBorderClasses[widget.severity] ?? 'border-l-neutral',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
|
|
||||||
|
|
||||||
<p className="mt-2 flex items-baseline gap-1.5">
|
<p className="widget__value">
|
||||||
{widget.value === undefined ? (
|
{widget.value === undefined ? (
|
||||||
// Kein Wert heißt nicht null. Eine Kennzahl, die sich nicht bilden
|
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
|
||||||
// lässt, sagt das — der Grund steht im Detailtext darunter.
|
// laesst, sagt das — der Grund steht im Detailtext darunter.
|
||||||
<span className="text-sm text-fg-muted">nicht bezifferbar</span>
|
<span className="widget__value-missing">nicht bezifferbar</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-2xl font-semibold tabular text-fg">
|
|
||||||
{formatWidgetValue(widget.value)}
|
{formatWidgetValue(widget.value)}
|
||||||
</span>
|
{widget.unit !== undefined && <span className="widget__unit"> {widget.unit}</span>}
|
||||||
{widget.unit !== undefined ? (
|
|
||||||
<span className="text-sm text-fg-muted">{widget.unit}</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{widget.detail !== undefined ? (
|
{widget.detail !== undefined && <p className="widget__detail">{widget.detail}</p>}
|
||||||
<p className="mt-1 text-xs text-fg-muted">{widget.detail}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{widget.breakdown !== undefined ? (
|
{widget.breakdown !== undefined && (
|
||||||
<dl className="mt-3 space-y-1 border-t border-line pt-2">
|
<dl className="widget__breakdown">
|
||||||
{Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => (
|
{Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => (
|
||||||
<div className="flex justify-between gap-2 text-xs" key={breakdownKey}>
|
<div className="widget__breakdown-item" key={breakdownKey}>
|
||||||
<dt className="truncate text-fg-muted">{formatBreakdownLabel(breakdownKey)}</dt>
|
<dt>{formatBreakdownLabel(breakdownKey)}</dt>
|
||||||
<dd className="tabular text-fg">{breakdownValue.toLocaleString('de-DE')}</dd>
|
<dd>{breakdownValue.toLocaleString('de-DE')}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
) : null}
|
)}
|
||||||
</Card>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formatiert die Hauptzahl einer Kennzahl. */
|
/** Formatiert die Hauptzahl einer Kennzahl. */
|
||||||
function formatWidgetValue(widgetValue: number): string {
|
function formatWidgetValue(widgetValue: number): string {
|
||||||
// Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest
|
// Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest
|
||||||
// sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Überlegung wie
|
// sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Ueberlegung wie
|
||||||
// bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie
|
// bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie
|
||||||
// weiß, und auch nicht weniger.
|
// weiss, und auch nicht weniger.
|
||||||
if (widgetValue > 0 && widgetValue < 0.1) {
|
if (widgetValue > 0 && widgetValue < 0.1) {
|
||||||
return '< 0,1';
|
return '< 0,1';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt
|
// Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt
|
||||||
// mehr als „100 %", wenn drei von tausend Läufen scheiterten.
|
// mehr als „100 %", wenn drei von tausend Laeufen scheiterten.
|
||||||
if (Number.isInteger(widgetValue)) {
|
if (Number.isInteger(widgetValue)) {
|
||||||
return widgetValue.toLocaleString('de-DE');
|
return widgetValue.toLocaleString('de-DE');
|
||||||
}
|
}
|
||||||
@ -161,7 +116,7 @@ function formatWidgetValue(widgetValue: number): string {
|
|||||||
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
|
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Macht aus einem Schlüssel der Aufschlüsselung eine Beschriftung. */
|
/** Macht aus einem Schluessel der Aufschluesselung eine Beschriftung. */
|
||||||
function formatBreakdownLabel(breakdownKey: string): string {
|
function formatBreakdownLabel(breakdownKey: string): string {
|
||||||
const readableLabel = breakdownKey.replace(/_/g, ' ');
|
const readableLabel = breakdownKey.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* Schnellzugriffe auf der Übersicht.
|
|
||||||
*
|
|
||||||
* Bisher endete die Übersicht bei Kennzahlen — wer etwas anlegen wollte, musste
|
|
||||||
* erst wissen, unter welchem Menüpunkt es steckt. Die Kacheln nennen die
|
|
||||||
* Handlung, nicht den Bereich: „Auftrag anlegen" statt „Sicherungsaufträge".
|
|
||||||
*
|
|
||||||
* Gezeigt wird nur, was die Rolle auch darf. Eine Kachel, die in eine
|
|
||||||
* Fehlermeldung führt, ist schlimmer als keine.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Archive,
|
|
||||||
BadgeCheck,
|
|
||||||
Clock,
|
|
||||||
Database,
|
|
||||||
MonitorCog,
|
|
||||||
RotateCcw,
|
|
||||||
Server,
|
|
||||||
UserPlus,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card } from '@/components/ui';
|
|
||||||
|
|
||||||
interface QuickAction {
|
|
||||||
readonly label: string;
|
|
||||||
readonly hint: string;
|
|
||||||
readonly icon: LucideIcon;
|
|
||||||
readonly targetPageId: string;
|
|
||||||
/** Berechtigung, die die Handlung verlangt. */
|
|
||||||
readonly permission: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUICK_ACTIONS: readonly QuickAction[] = [
|
|
||||||
{
|
|
||||||
label: 'Auftrag anlegen',
|
|
||||||
hint: 'Was wann wohin',
|
|
||||||
icon: Clock,
|
|
||||||
targetPageId: 'jobs',
|
|
||||||
permission: 'jobs.write',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Wiederherstellen',
|
|
||||||
hint: 'Aus einem Punkt zurück',
|
|
||||||
icon: RotateCcw,
|
|
||||||
targetPageId: 'recovery-points',
|
|
||||||
permission: 'restores.execute',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Repository übernehmen',
|
|
||||||
hint: 'Vorhandene Ablage',
|
|
||||||
icon: Database,
|
|
||||||
targetPageId: 'repositories',
|
|
||||||
permission: 'repositories.write',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Prüfung starten',
|
|
||||||
hint: 'Nachweis erzeugen',
|
|
||||||
icon: BadgeCheck,
|
|
||||||
targetPageId: 'recovery-points',
|
|
||||||
permission: 'verification.write',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Agent aufnehmen',
|
|
||||||
hint: 'Token erzeugen',
|
|
||||||
icon: MonitorCog,
|
|
||||||
targetPageId: 'agents',
|
|
||||||
permission: 'agents.enroll',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Proxmox verbinden',
|
|
||||||
hint: 'Verbund einrichten',
|
|
||||||
icon: Server,
|
|
||||||
targetPageId: 'proxmox',
|
|
||||||
permission: 'providers.write',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Aufbewahrung',
|
|
||||||
hint: 'Regel anlegen',
|
|
||||||
icon: Archive,
|
|
||||||
targetPageId: 'retention',
|
|
||||||
permission: 'retention.write',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Benutzer anlegen',
|
|
||||||
hint: 'Konto und Rollen',
|
|
||||||
icon: UserPlus,
|
|
||||||
targetPageId: 'users',
|
|
||||||
permission: 'users.write',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function QuickActions({
|
|
||||||
grantedPermissions,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly onNavigate: (pageIdentifier: string) => void;
|
|
||||||
}) {
|
|
||||||
const isAllowed = (permission: string): boolean =>
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes(permission);
|
|
||||||
|
|
||||||
const visibleActions = QUICK_ACTIONS.filter((action) => isAllowed(action.permission));
|
|
||||||
|
|
||||||
if (visibleActions.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{visibleActions.map((action) => {
|
|
||||||
const IconComponent = action.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={action.label}
|
|
||||||
asChild
|
|
||||||
className="transition-colors hover:border-accent hover:bg-hover"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onNavigate(action.targetPageId)}
|
|
||||||
className="flex w-full items-center gap-3 p-3 text-left"
|
|
||||||
>
|
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent">
|
|
||||||
<IconComponent className="size-4" aria-hidden />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-medium text-fg">
|
|
||||||
{action.label}
|
|
||||||
</span>
|
|
||||||
<span className="block truncate text-xs text-fg-muted">{action.hint}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,58 +4,35 @@
|
|||||||
* Die zentrale Auskunft der Anlage. Sie beantwortet nicht „welche Backups gibt
|
* Die zentrale Auskunft der Anlage. Sie beantwortet nicht „welche Backups gibt
|
||||||
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
|
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
|
||||||
* Einstufung in jeder Zeile und nicht in einem Detailfenster.
|
* Einstufung in jeder Zeile und nicht in einem Detailfenster.
|
||||||
*
|
|
||||||
* Der Filter „Ungeprüft" heißt serverseitig `successful`. Das ist kein
|
|
||||||
* Übersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
|
|
||||||
* Nachweis der Wiederherstellbarkeit.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ShieldCheck } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
import { describeApiError } from '@/api/useMutation';
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CheckboxField,
|
|
||||||
DataTable,
|
|
||||||
ErrorState,
|
|
||||||
SelectInput,
|
|
||||||
StatusBadge,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes, formatDateTime, formatRelativeTime } from '@/lib/utils';
|
|
||||||
import { fetchRecoveryPoints } from './dashboardApi';
|
import { fetchRecoveryPoints } from './dashboardApi';
|
||||||
import type { RecoveryPoint } from './dashboardApi';
|
import type { RecoveryPoint } from './dashboardApi';
|
||||||
|
|
||||||
/** Auswahlmöglichkeiten des Einstufungsfilters. */
|
/** Auswahlmoeglichkeiten des Einstufungsfilters. */
|
||||||
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
||||||
{ value: '', label: 'Alle Einstufungen' },
|
{ value: '', label: 'Alle Einstufungen' },
|
||||||
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
|
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
|
||||||
{ value: 'verified', label: 'Geprüft' },
|
{ value: 'verified', label: 'Geprueft' },
|
||||||
{ value: 'successful', label: 'Ungeprüft' },
|
{ value: 'successful', label: 'Ungeprueft' },
|
||||||
{ value: 'corrupted', label: 'Beschädigt' },
|
{ value: 'corrupted', label: 'Beschaedigt' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecoveryPointsPage({
|
/** Zeigt die Wiederherstellungspunkte. */
|
||||||
onOpenBackup,
|
export function RecoveryPointsPage(): React.JSX.Element {
|
||||||
}: {
|
|
||||||
/** Öffnet die Einzelheiten eines Punktes. */
|
|
||||||
readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined;
|
|
||||||
} = {}): React.JSX.Element {
|
|
||||||
const [classificationFilter, setClassificationFilter] = useState('');
|
const [classificationFilter, setClassificationFilter] = useState('');
|
||||||
const [includeDeleted, setIncludeDeleted] = useState(false);
|
const [includeDeleted, setIncludeDeleted] = useState(false);
|
||||||
const [onlyProtected, setOnlyProtected] = useState(false);
|
const [onlyProtected, setOnlyProtected] = useState(false);
|
||||||
|
|
||||||
// Der Schlüssel bildet die Filter ab: Ändert er sich, wird neu geladen.
|
// Der Schluessel bildet die Filter ab: Aendert er sich, wird neu geladen.
|
||||||
const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`;
|
const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`;
|
||||||
|
|
||||||
const loadRecoveryPoints = useCallback(
|
const loadRecoveryPoints = useCallback(
|
||||||
(abortSignal: AbortSignal) =>
|
(abortSignal: AbortSignal) =>
|
||||||
fetchRecoveryPoints(
|
fetchRecoveryPoints({ classification: classificationFilter, includeDeleted, onlyProtected }, abortSignal),
|
||||||
{ classification: classificationFilter, includeDeleted, onlyProtected },
|
|
||||||
abortSignal,
|
|
||||||
),
|
|
||||||
[classificationFilter, includeDeleted, onlyProtected],
|
[classificationFilter, includeDeleted, onlyProtected],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -64,150 +41,200 @@ export function RecoveryPointsPage({
|
|||||||
filterKey,
|
filterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<RecoveryPoint>> = [
|
|
||||||
{
|
|
||||||
key: 'completed_at',
|
|
||||||
header: 'Zeitpunkt',
|
|
||||||
render: (recoveryPoint) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm text-fg">{formatDateTime(recoveryPoint.completed_at)}</p>
|
|
||||||
<p className="truncate text-xs text-fg-muted">
|
|
||||||
{formatRelativeTime(recoveryPoint.completed_at)} · {recoveryPoint.repository_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'job',
|
|
||||||
header: 'Auftrag',
|
|
||||||
render: (recoveryPoint) => (
|
|
||||||
<span className="text-sm text-fg">{recoveryPoint.job_name ?? '—'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'backup_type',
|
|
||||||
header: 'Art',
|
|
||||||
render: (recoveryPoint) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'logical_bytes',
|
|
||||||
header: 'Umfang',
|
|
||||||
numeric: true,
|
|
||||||
render: (recoveryPoint) => formatBytes(recoveryPoint.logical_bytes),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'classification',
|
|
||||||
header: 'Einstufung',
|
|
||||||
render: (recoveryPoint) => (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<StatusBadge status={recoveryPoint.classification} />
|
|
||||||
{recoveryPoint.assurance_score !== undefined ? (
|
|
||||||
<span className="text-xs tabular text-fg-muted">
|
|
||||||
{recoveryPoint.assurance_score} %
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'protection',
|
|
||||||
header: 'Schutz',
|
|
||||||
render: (recoveryPoint) =>
|
|
||||||
recoveryPoint.is_protected ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-healthy">
|
|
||||||
<ShieldCheck className="size-3.5" aria-hidden />
|
|
||||||
{recoveryPoint.legal_hold ? 'Legal Hold' : 'Aufbewahrung'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-fg-muted">—</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deleted',
|
|
||||||
header: '',
|
|
||||||
render: (recoveryPoint) =>
|
|
||||||
recoveryPoint.deleted_at !== undefined ? (
|
|
||||||
// Ein gelöschter Punkt bleibt sichtbar, damit die Historie
|
|
||||||
// nachvollziehbar bleibt — er wird gekennzeichnet, nicht versteckt.
|
|
||||||
<span className="text-xs text-fg-subtle">gelöscht</span>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="page">
|
||||||
<PageHeader
|
<header className="page__header">
|
||||||
title="Wiederherstellungspunkte"
|
<h1 className="page__title">Wiederherstellungspunkte</h1>
|
||||||
description={
|
|
||||||
data !== null
|
|
||||||
? `${data.length} Punkte. Nur ein durchgeführter Wiederherstellungstest hebt auf „wiederherstellbar“.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
{data !== null && <span className="page__meta">{data.length} Punkte</span>}
|
||||||
<div className="flex flex-wrap items-end gap-4 border-b border-line px-5 py-3">
|
</header>
|
||||||
<SelectInput
|
|
||||||
label="Einstufung"
|
<div className="filter-bar">
|
||||||
className="w-64"
|
<label className="filter-bar__field">
|
||||||
|
<span className="filter-bar__label">Einstufung</span>
|
||||||
|
<select
|
||||||
|
className="filter-bar__select"
|
||||||
value={classificationFilter}
|
value={classificationFilter}
|
||||||
onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)}
|
onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)}
|
||||||
>
|
>
|
||||||
{CLASSIFICATION_OPTIONS.map((option) => (
|
{CLASSIFICATION_OPTIONS.map((filterOption) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={filterOption.value} value={filterOption.value}>
|
||||||
{option.label}
|
{filterOption.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 pb-2">
|
<label className="filter-bar__checkbox">
|
||||||
<CheckboxField
|
<input
|
||||||
checked={includeDeleted}
|
type="checkbox"
|
||||||
onCheckedChange={setIncludeDeleted}
|
|
||||||
label="Gelöschte einschließen"
|
|
||||||
/>
|
|
||||||
<CheckboxField
|
|
||||||
checked={onlyProtected}
|
checked={onlyProtected}
|
||||||
onCheckedChange={setOnlyProtected}
|
onChange={(changeEvent) => setOnlyProtected(changeEvent.target.checked)}
|
||||||
label="Nur geschützte"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
Nur geschuetzte
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Geloeschte Punkte erscheinen nur auf Nachfrage — die Frage „warum ist
|
||||||
|
das Backup von vorletzter Woche weg?" muss aber beantwortbar sein. */}
|
||||||
|
<label className="filter-bar__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeDeleted}
|
||||||
|
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)}
|
||||||
|
/>
|
||||||
|
Geloeschte einbeziehen
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadState === 'failed' && loadError !== null ? (
|
{loadState === 'loading' && <LoadingState what="Die Wiederherstellungspunkte" />}
|
||||||
<ErrorState
|
{loadState === 'failed' && loadError !== null && (
|
||||||
message={describeApiError(loadError)}
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
requestId={loadError.requestId}
|
|
||||||
onRetry={reload}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={data ?? []}
|
|
||||||
getRowKey={(recoveryPoint) => recoveryPoint.id}
|
|
||||||
onRowClick={
|
|
||||||
onOpenBackup
|
|
||||||
? (recoveryPoint) => onOpenBackup(recoveryPoint.id)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
isLoading={loadState === 'loading'}
|
|
||||||
emptyTitle={
|
|
||||||
classificationFilter || onlyProtected
|
|
||||||
? 'Kein Punkt passt zum Filter'
|
|
||||||
: 'Noch kein Wiederherstellungspunkt'
|
|
||||||
}
|
|
||||||
emptyDescription={
|
|
||||||
classificationFilter || onlyProtected
|
|
||||||
? 'Lockern Sie den Filter, um mehr zu sehen.'
|
|
||||||
: 'Sobald ein Sicherungsauftrag durchgelaufen ist, erscheint hier ein Punkt.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
</>
|
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
||||||
|
<EmptyState message="Zu diesen Filtern gibt es keinen Wiederherstellungspunkt." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Zeitpunkt</th>
|
||||||
|
<th scope="col">Auftrag</th>
|
||||||
|
<th scope="col">Art</th>
|
||||||
|
<th scope="col">Groesse</th>
|
||||||
|
<th scope="col">Einstufung</th>
|
||||||
|
<th scope="col">Bewertung</th>
|
||||||
|
<th scope="col">Schutz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{data.map((recoveryPoint) => (
|
||||||
|
<RecoveryPointRow key={recoveryPoint.id} recoveryPoint={recoveryPoint} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Tabellenzeile. */
|
||||||
|
interface RecoveryPointRowProperties {
|
||||||
|
/** Der dargestellte Punkt. */
|
||||||
|
readonly recoveryPoint: RecoveryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
|
||||||
|
function RecoveryPointRow({ recoveryPoint }: RecoveryPointRowProperties): React.JSX.Element {
|
||||||
|
const isDeleted = recoveryPoint.deleted_at !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={isDeleted ? 'data-table__row--deleted' : undefined}>
|
||||||
|
<td>
|
||||||
|
{formatTimestamp(recoveryPoint.completed_at)}
|
||||||
|
<span className="data-table__secondary">{recoveryPoint.repository_name}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{recoveryPoint.job_name ?? '—'}</td>
|
||||||
|
|
||||||
|
<td>{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">{formatBytes(recoveryPoint.logical_bytes)}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<ClassificationBadge classification={recoveryPoint.classification} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">
|
||||||
|
{/* Keine Bewertung heisst nicht null Prozent. Die Unterscheidung ist der
|
||||||
|
Kern der Recovery Assurance (Phase 10). */}
|
||||||
|
{recoveryPoint.assurance_score === undefined ? (
|
||||||
|
<span className="data-table__unknown">nicht berechnet</span>
|
||||||
|
) : (
|
||||||
|
`${recoveryPoint.assurance_score} %`
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{isDeleted ? (
|
||||||
|
<span className="badge badge--neutral" title={recoveryPoint.deletion_reason}>
|
||||||
|
geloescht
|
||||||
|
</span>
|
||||||
|
) : recoveryPoint.legal_hold ? (
|
||||||
|
<span className="badge badge--high">Legal Hold</span>
|
||||||
|
) : recoveryPoint.is_protected ? (
|
||||||
|
<span className="badge badge--info" title={formatTimestamp(recoveryPoint.immutable_until)}>
|
||||||
|
geschuetzt
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="data-table__unknown">frei</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Einstufungsanzeige. */
|
||||||
|
interface ClassificationBadgeProperties {
|
||||||
|
/** Die Einstufung; fehlt bei ungeprueften Punkten. */
|
||||||
|
readonly classification: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Einstufung eines Punktes. */
|
||||||
|
function ClassificationBadge({ classification }: ClassificationBadgeProperties): React.JSX.Element {
|
||||||
|
switch (classification) {
|
||||||
|
case 'recoverable':
|
||||||
|
return <span className="badge badge--healthy">wiederherstellbar</span>;
|
||||||
|
case 'verified':
|
||||||
|
return <span className="badge badge--info">geprueft</span>;
|
||||||
|
case 'corrupted':
|
||||||
|
return <span className="badge badge--critical">beschaedigt</span>;
|
||||||
|
case 'failed':
|
||||||
|
return <span className="badge badge--critical">gescheitert</span>;
|
||||||
|
default:
|
||||||
|
// „Ungeprueft" ist eine Aussage, keine Luecke: Der Punkt wurde nie
|
||||||
|
// zurueckgeschrieben, und das soll man sehen.
|
||||||
|
return <span className="badge badge--warning">ungeprueft</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||||
|
const BYTE_UNIT_STEP = 1024;
|
||||||
|
|
||||||
|
/** Schreibt eine Datenmenge lesbar. */
|
||||||
|
function formatBytes(byteCount: number): string {
|
||||||
|
if (byteCount < BYTE_UNIT_STEP) {
|
||||||
|
return `${byteCount} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let remainingValue = byteCount;
|
||||||
|
let chosenUnit = unitNames[0];
|
||||||
|
|
||||||
|
for (const unitName of unitNames) {
|
||||||
|
remainingValue /= BYTE_UNIT_STEP;
|
||||||
|
chosenUnit = unitName;
|
||||||
|
|
||||||
|
if (remainingValue < BYTE_UNIT_STEP) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Zugriff auf die Übersicht und die Wiederherstellungspunkte.
|
* Zugriff auf die Uebersicht und die Wiederherstellungspunkte.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
import { requestApi } from '../../api/client';
|
||||||
@ -7,29 +7,29 @@ import { requestApi } from '../../api/client';
|
|||||||
/** Statusfarbe einer Kennzahl. */
|
/** Statusfarbe einer Kennzahl. */
|
||||||
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
|
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
|
||||||
|
|
||||||
/** Eine Kennzahl der Übersicht. */
|
/** Eine Kennzahl der Uebersicht. */
|
||||||
export interface DashboardWidget {
|
export interface DashboardWidget {
|
||||||
/** Maschinenlesbarer Bezeichner. */
|
/** Maschinenlesbarer Bezeichner. */
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Meldet, ob es eine Datengrundlage gibt. */
|
/** Meldet, ob es eine Datengrundlage gibt. */
|
||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
/** Erklärt eine fehlende Datengrundlage. */
|
/** Erklaert eine fehlende Datengrundlage. */
|
||||||
readonly unavailable_reason?: string;
|
readonly unavailable_reason?: string;
|
||||||
/** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */
|
/** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */
|
||||||
readonly value?: number;
|
readonly value?: number;
|
||||||
/** Einheit der Hauptzahl. */
|
/** Einheit der Hauptzahl. */
|
||||||
readonly unit?: string;
|
readonly unit?: string;
|
||||||
/** Erläuterung in einem Satz. */
|
/** Erlaeuterung in einem Satz. */
|
||||||
readonly detail?: string;
|
readonly detail?: string;
|
||||||
/** Statusfarbe. */
|
/** Statusfarbe. */
|
||||||
readonly severity: WidgetSeverity;
|
readonly severity: WidgetSeverity;
|
||||||
/** Ergänzende Einzelwerte. */
|
/** Ergaenzende Einzelwerte. */
|
||||||
readonly breakdown?: Record<string, number>;
|
readonly breakdown?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die Übersicht der Anlage. */
|
/** Die Uebersicht der Anlage. */
|
||||||
export interface Dashboard {
|
export interface Dashboard {
|
||||||
/** Kennzahlen in Anzeigereihenfolge. */
|
/** Kennzahlen in Anzeigereihenfolge. */
|
||||||
readonly widgets: readonly DashboardWidget[];
|
readonly widgets: readonly DashboardWidget[];
|
||||||
@ -39,9 +39,9 @@ export interface Dashboard {
|
|||||||
readonly generated_at: string;
|
readonly generated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Wiederherstellungspunkt in der Übersicht. */
|
/** Ein Wiederherstellungspunkt in der Uebersicht. */
|
||||||
export interface RecoveryPoint {
|
export interface RecoveryPoint {
|
||||||
/** Öffentlicher Bezeichner. */
|
/** Oeffentlicher Bezeichner. */
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
/** Repository des Punktes. */
|
/** Repository des Punktes. */
|
||||||
readonly repository_id: string;
|
readonly repository_id: string;
|
||||||
@ -67,7 +67,7 @@ export interface RecoveryPoint {
|
|||||||
readonly completed_at?: string;
|
readonly completed_at?: string;
|
||||||
/** Objektive Einstufung. */
|
/** Objektive Einstufung. */
|
||||||
readonly classification?: string;
|
readonly classification?: string;
|
||||||
/** Letzte Integritätsprüfung in UTC. */
|
/** Letzte Integritaetspruefung in UTC. */
|
||||||
readonly last_verified_at?: string;
|
readonly last_verified_at?: string;
|
||||||
/** Letzter Wiederherstellungstest in UTC. */
|
/** Letzter Wiederherstellungstest in UTC. */
|
||||||
readonly last_restore_test_at?: string;
|
readonly last_restore_test_at?: string;
|
||||||
@ -77,34 +77,34 @@ export interface RecoveryPoint {
|
|||||||
readonly immutable_until?: string;
|
readonly immutable_until?: string;
|
||||||
/** Meldet einen unbefristeten Schutz. */
|
/** Meldet einen unbefristeten Schutz. */
|
||||||
readonly legal_hold: boolean;
|
readonly legal_hold: boolean;
|
||||||
/** Meldet, ob eine Löschung derzeit unzulässig ist. */
|
/** Meldet, ob eine Loeschung derzeit unzulaessig ist. */
|
||||||
readonly is_protected: boolean;
|
readonly is_protected: boolean;
|
||||||
/** Zeitpunkt der Löschung in UTC. */
|
/** Zeitpunkt der Loeschung in UTC. */
|
||||||
readonly deleted_at?: string;
|
readonly deleted_at?: string;
|
||||||
/** Begründung der Löschung. */
|
/** Begruendung der Loeschung. */
|
||||||
readonly deletion_reason?: string;
|
readonly deletion_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filter der Liste von Wiederherstellungspunkten. */
|
/** Filter der Liste von Wiederherstellungspunkten. */
|
||||||
export interface RecoveryPointFilter {
|
export interface RecoveryPointFilter {
|
||||||
/** Beschränkung auf ein Repository. */
|
/** Beschraenkung auf ein Repository. */
|
||||||
readonly repositoryId?: string;
|
readonly repositoryId?: string;
|
||||||
/** Beschränkung auf eine Einstufung. */
|
/** Beschraenkung auf eine Einstufung. */
|
||||||
readonly classification?: string;
|
readonly classification?: string;
|
||||||
/** Nimmt gelöschte Punkte auf. */
|
/** Nimmt geloeschte Punkte auf. */
|
||||||
readonly includeDeleted?: boolean;
|
readonly includeDeleted?: boolean;
|
||||||
/** Beschränkt auf geschützte Punkte. */
|
/** Beschraenkt auf geschuetzte Punkte. */
|
||||||
readonly onlyProtected?: boolean;
|
readonly onlyProtected?: boolean;
|
||||||
/** Angeforderte Seite. */
|
/** Angeforderte Seite. */
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt die Übersicht. */
|
/** Laedt die Uebersicht. */
|
||||||
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
|
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
|
||||||
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
|
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt eine Seite von Wiederherstellungspunkten. */
|
/** Laedt eine Seite von Wiederherstellungspunkten. */
|
||||||
export async function fetchRecoveryPoints(
|
export async function fetchRecoveryPoints(
|
||||||
filter: RecoveryPointFilter = {},
|
filter: RecoveryPointFilter = {},
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
|
|||||||
111
apps/web/src/features/health/SystemHealthPanel.css
Normal file
111
apps/web/src/features/health/SystemHealthPanel.css
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/* Darstellung der Systemzustandsanzeige. */
|
||||||
|
|
||||||
|
.health-panel {
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__title {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__overall {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__version {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error {
|
||||||
|
/* Der farbige Rand links betont die Meldung, ohne die Flaeche einzufaerben. */
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error-message {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error-detail {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error-detail code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__button:hover {
|
||||||
|
border-color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__caption {
|
||||||
|
/* Die Beschriftung dient Screenreadern und bleibt visuell verborgen. */
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__table th,
|
||||||
|
.health-panel__table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: var(--border-width) solid var(--color-border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__table thead th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__component-message {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__latency {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
/* Rechtsbuendig, damit Zahlen vergleichbar untereinander stehen. */
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@ -39,7 +39,7 @@ describe('SystemHealthPanel', () => {
|
|||||||
expect(screen.getByText('database')).toBeInTheDocument();
|
expect(screen.getByText('database')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Der Zustand wird zusätzlich zur Farbe als Text ausgegeben (PROMPT.md §107).
|
// Der Zustand wird zusaetzlich zur Farbe als Text ausgegeben (PROMPT.md §107).
|
||||||
expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument();
|
expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -72,7 +72,7 @@ describe('SystemHealthPanel', () => {
|
|||||||
|
|
||||||
render(<SystemHealthPanel />);
|
render(<SystemHealthPanel />);
|
||||||
|
|
||||||
// Ein Problem darf niemals beschönigt werden (PROMPT.md §140).
|
// Ein Problem darf niemals beschoenigt werden (PROMPT.md §140).
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
|
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
|
||||||
@ -82,7 +82,7 @@ describe('SystemHealthPanel', () => {
|
|||||||
expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt eine verständliche Meldung, wenn die API nicht erreichbar ist', async () => {
|
it('zeigt eine verstaendliche Meldung, wenn die API nicht erreichbar ist', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||||
|
|
||||||
render(<SystemHealthPanel />);
|
render(<SystemHealthPanel />);
|
||||||
@ -91,13 +91,13 @@ describe('SystemHealthPanel', () => {
|
|||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Statt eines rohen Fehlercodes erscheint eine erklärende Meldung (PROMPT.md §124).
|
// Statt eines rohen Fehlercodes erscheint eine erklaerende Meldung (PROMPT.md §124).
|
||||||
expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument();
|
expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => {
|
it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => {
|
||||||
// Eine Anfrage, die nie antwortet, hält die Komponente im Ladezustand.
|
// Eine Anfrage, die nie antwortet, haelt die Komponente im Ladezustand.
|
||||||
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
|
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
|
||||||
|
|
||||||
render(<SystemHealthPanel />);
|
render(<SystemHealthPanel />);
|
||||||
|
|||||||
@ -2,133 +2,96 @@
|
|||||||
* Anzeige des Systemzustands.
|
* Anzeige des Systemzustands.
|
||||||
*
|
*
|
||||||
* Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes
|
* Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes
|
||||||
* erhält der Anwender eine verständliche Erklärung und eine
|
* erhaelt der Anwender eine verstaendliche Erklaerung und eine Handlungsempfehlung.
|
||||||
* Handlungsempfehlung.
|
|
||||||
*
|
|
||||||
* Dass eine kritische Komponente hier mit vollständigem Bericht erscheint,
|
|
||||||
* obwohl der Endpunkt dabei mit 503 antwortet, ist Absicht: Das Monitoring
|
|
||||||
* schlägt an, und die Oberfläche kann trotzdem zeigen, **was** kaputt ist.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { StatusIndicator } from '../../components/StatusIndicator';
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
DataTable,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
StatusBadge,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { useSystemHealth } from './useSystemHealth';
|
import { useSystemHealth } from './useSystemHealth';
|
||||||
|
import './SystemHealthPanel.css';
|
||||||
|
|
||||||
/** Abstand der automatischen Aktualisierung in Millisekunden. */
|
/** Abstand der automatischen Aktualisierung in Millisekunden. */
|
||||||
const REFRESH_INTERVAL_MS = 15_000;
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
/** Ein Eintrag der Komponententabelle. */
|
/** Zeigt den Zustand des Backends und seiner Komponenten. */
|
||||||
interface ComponentRow {
|
|
||||||
readonly name: string;
|
|
||||||
readonly status: string;
|
|
||||||
readonly message?: string | undefined;
|
|
||||||
readonly latencyMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SystemHealthPanel(): React.JSX.Element {
|
export function SystemHealthPanel(): React.JSX.Element {
|
||||||
const { loadState, systemHealth, loadError, reload } = useSystemHealth(REFRESH_INTERVAL_MS);
|
const { loadState, systemHealth, loadError, reload } = useSystemHealth(REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
const componentColumns: ReadonlyArray<TableColumn<ComponentRow>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Komponente',
|
|
||||||
render: (component) => <span className="font-medium text-fg">{component.name}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (component) => (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<StatusBadge status={component.status} />
|
|
||||||
{component.message ? (
|
|
||||||
<span className="text-xs text-fg-muted">{component.message}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'latency',
|
|
||||||
header: 'Antwortzeit',
|
|
||||||
numeric: true,
|
|
||||||
render: (component) => `${component.latencyMs.toFixed(1)} ms`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const buildRows = useCallback((): ComponentRow[] => {
|
|
||||||
if (systemHealth === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(systemHealth.components).map(([componentName, componentHealth]) => ({
|
|
||||||
name: componentName,
|
|
||||||
status: componentHealth.status,
|
|
||||||
message: componentHealth.message,
|
|
||||||
latencyMs: componentHealth.latency_ms,
|
|
||||||
}));
|
|
||||||
}, [systemHealth]);
|
|
||||||
|
|
||||||
if (loadState === 'loading') {
|
if (loadState === 'loading') {
|
||||||
return (
|
return (
|
||||||
<Card aria-busy="true">
|
<section className="health-panel" aria-busy="true">
|
||||||
<CardHeader title="Systemzustand" />
|
<h2 className="health-panel__title">Systemzustand</h2>
|
||||||
<LoadingState label="Der Systemzustand wird ermittelt …" />
|
<p className="health-panel__hint">Der Systemzustand wird ermittelt …</p>
|
||||||
</Card>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadState === 'failed' || systemHealth === null) {
|
if (loadState === 'failed' || systemHealth === null) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="health-panel">
|
||||||
<CardHeader title="Systemzustand" />
|
<h2 className="health-panel__title">Systemzustand</h2>
|
||||||
<ErrorState
|
|
||||||
title="Der Systemzustand konnte nicht ermittelt werden."
|
{/* role="alert" sorgt dafuer, dass Screenreader den Fehler ansagen. */}
|
||||||
message={loadError ? describeApiError(loadError) : undefined}
|
<div className="health-panel__error" role="alert">
|
||||||
requestId={loadError && loadError.requestId !== '' ? loadError.requestId : undefined}
|
<p className="health-panel__error-message">
|
||||||
onRetry={reload}
|
{loadError?.message ?? 'Der Systemzustand konnte nicht ermittelt werden.'}
|
||||||
/>
|
</p>
|
||||||
</Card>
|
|
||||||
|
{/* Die technischen Angaben helfen beim Melden eines Vorfalls. */}
|
||||||
|
{loadError && loadError.requestId !== '' && (
|
||||||
|
<p className="health-panel__error-detail">
|
||||||
|
Fehlercode: <code>{loadError.code}</code> · Request-ID: <code>{loadError.requestId}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="health-panel__button" onClick={reload}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentRows = buildRows();
|
const componentEntries = Object.entries(systemHealth.components);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="health-panel">
|
||||||
<CardHeader
|
<h2 className="health-panel__title">Systemzustand</h2>
|
||||||
title="Systemzustand"
|
|
||||||
description={`Version ${systemHealth.version} · Umgebung ${systemHealth.environment}`}
|
|
||||||
action={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={systemHealth.status} />
|
|
||||||
<Button variant="ghost" size="sm" onClick={reload}>
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{componentRows.length === 0 ? (
|
<div className="health-panel__overall">
|
||||||
<CardContent>
|
<StatusIndicator status={systemHealth.status} />
|
||||||
<p className="text-sm text-fg-muted">Es sind keine Komponenten registriert.</p>
|
<span className="health-panel__version">
|
||||||
</CardContent>
|
Version {systemHealth.version} · Umgebung {systemHealth.environment}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{componentEntries.length === 0 ? (
|
||||||
|
<p className="health-panel__hint">Es sind keine Komponenten registriert.</p>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<table className="health-panel__table">
|
||||||
columns={componentColumns}
|
<caption className="health-panel__caption">Zustand der einzelnen Komponenten</caption>
|
||||||
rows={componentRows}
|
<thead>
|
||||||
getRowKey={(component) => component.name}
|
<tr>
|
||||||
/>
|
<th scope="col">Komponente</th>
|
||||||
|
<th scope="col">Zustand</th>
|
||||||
|
<th scope="col">Antwortzeit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{componentEntries.map(([componentName, componentHealth]) => (
|
||||||
|
<tr key={componentName}>
|
||||||
|
<th scope="row">{componentName}</th>
|
||||||
|
<td>
|
||||||
|
<StatusIndicator status={componentHealth.status} />
|
||||||
|
{componentHealth.message && (
|
||||||
|
<span className="health-panel__component-message">{componentHealth.message}</span>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</td>
|
||||||
|
<td className="health-panel__latency">{componentHealth.latency_ms.toFixed(1)} ms</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Hook zum Laden des Systemzustands.
|
* Hook zum Laden des Systemzustands.
|
||||||
*
|
*
|
||||||
* Der Hook zeigt niemals Platzhalter- oder Demo-Werte: solange kein echtes
|
* Der Hook zeigt niemals Platzhalter- oder Demo-Werte: solange kein echtes
|
||||||
* Ergebnis vorliegt, bleibt der Zustand ausdrücklich "lädt" oder "Fehler"
|
* Ergebnis vorliegt, bleibt der Zustand ausdruecklich "laedt" oder "Fehler"
|
||||||
* (PROMPT.md §138/§139).
|
* (PROMPT.md §138/§139).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ import type { SystemHealth } from '../../types/api';
|
|||||||
/** Ladezustand der Systemzustandsabfrage. */
|
/** Ladezustand der Systemzustandsabfrage. */
|
||||||
export type HealthLoadState = 'loading' | 'loaded' | 'failed';
|
export type HealthLoadState = 'loading' | 'loaded' | 'failed';
|
||||||
|
|
||||||
/** Rückgabewert des Hooks. */
|
/** Rueckgabewert des Hooks. */
|
||||||
export interface UseSystemHealthResult {
|
export interface UseSystemHealthResult {
|
||||||
/** Aktueller Ladezustand. */
|
/** Aktueller Ladezustand. */
|
||||||
loadState: HealthLoadState;
|
loadState: HealthLoadState;
|
||||||
@ -21,12 +21,12 @@ export interface UseSystemHealthResult {
|
|||||||
systemHealth: SystemHealth | null;
|
systemHealth: SystemHealth | null;
|
||||||
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
|
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
|
||||||
loadError: ApiError | null;
|
loadError: ApiError | null;
|
||||||
/** Lädt den Systemzustand erneut. */
|
/** Laedt den Systemzustand erneut. */
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt den Systemzustand von GET /api/v1/health.
|
* Laedt den Systemzustand von GET /api/v1/health.
|
||||||
*
|
*
|
||||||
* @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden.
|
* @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden.
|
||||||
* Ein Wert von 0 deaktiviert die automatische Aktualisierung.
|
* Ein Wert von 0 deaktiviert die automatische Aktualisierung.
|
||||||
@ -57,13 +57,13 @@ export function useSystemHealth(refreshIntervalMs = 0): UseSystemHealthResult {
|
|||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
setLoadState('loaded');
|
setLoadState('loaded');
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
// Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens.
|
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
|
||||||
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige
|
// Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige
|
||||||
// als aktuellen Zustand auszugeben wäre irreführend.
|
// als aktuellen Zustand auszugeben waere irrefuehrend.
|
||||||
setSystemHealth(null);
|
setSystemHealth(null);
|
||||||
setLoadError(
|
setLoadError(
|
||||||
caughtError instanceof ApiError
|
caughtError instanceof ApiError
|
||||||
|
|||||||
164
apps/web/src/features/identity/IdentityPages.tsx
Normal file
164
apps/web/src/features/identity/IdentityPages.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Benutzer und Rollen.
|
||||||
|
*
|
||||||
|
* Beide Seiten zeigen ausschliesslich an. Anlegen und Aendern gehen weiterhin
|
||||||
|
* ueber die API: Eine Maske, die Rollen bearbeitet, muss die Sonderfaelle
|
||||||
|
* beherrschen — den letzten Administrator, mitgelieferte unveraenderliche Rollen
|
||||||
|
* — und die gehoeren geprueft, nicht nebenbei gebaut.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
|
||||||
|
/** Ein Benutzerkonto. */
|
||||||
|
interface UserAccount {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Anmeldename. */
|
||||||
|
readonly username: string;
|
||||||
|
/** Postanschrift. */
|
||||||
|
readonly email?: string;
|
||||||
|
/** Zustand des Kontos. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Meldet einen eingerichteten zweiten Faktor. */
|
||||||
|
readonly mfa_enabled: boolean;
|
||||||
|
/** Zugewiesene Rollen. */
|
||||||
|
readonly roles?: readonly string[];
|
||||||
|
/** Letzte Anmeldung in UTC. */
|
||||||
|
readonly last_login_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Rolle. */
|
||||||
|
interface RoleDefinition {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Name der Rolle. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Beschreibung. */
|
||||||
|
readonly description?: string;
|
||||||
|
/** Zugeordnete Berechtigungen. */
|
||||||
|
readonly permissions?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Benutzerkonten. */
|
||||||
|
export function UsersPage(): React.JSX.Element {
|
||||||
|
const loadUsers = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => requestApi<readonly UserAccount[]>('/users', { signal: abortSignal }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly UserAccount[]>(loadUsers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Benutzer</h1>
|
||||||
|
{data !== null && <span className="page__meta">{data.length} Konten</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Benutzerkonten" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && <ErrorState error={loadError} onRetry={reload} />}
|
||||||
|
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
||||||
|
<EmptyState message="Es ist kein Konto vorhanden." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Anmeldename</th>
|
||||||
|
<th scope="col">Rollen</th>
|
||||||
|
<th scope="col">Zustand</th>
|
||||||
|
<th scope="col">Zweiter Faktor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{data.map((userAccount) => (
|
||||||
|
<tr key={userAccount.id}>
|
||||||
|
<td>
|
||||||
|
{userAccount.username}
|
||||||
|
{userAccount.email !== undefined && (
|
||||||
|
<span className="data-table__secondary">{userAccount.email}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{userAccount.roles?.join(', ') ?? '—'}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
userAccount.status === 'active' ? 'badge badge--healthy' : 'badge badge--neutral'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userAccount.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und
|
||||||
|
wird benannt, statt ihn zu verschweigen (PROMPT.md §90). */}
|
||||||
|
{userAccount.mfa_enabled ? (
|
||||||
|
<span className="badge badge--healthy">eingerichtet</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge--warning">fehlt</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Rollen samt Berechtigungen. */
|
||||||
|
export function RolesPage(): React.JSX.Element {
|
||||||
|
const loadRoles = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => requestApi<readonly RoleDefinition[]>('/roles', { signal: abortSignal }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly RoleDefinition[]>(loadRoles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Rollen</h1>
|
||||||
|
{data !== null && <span className="page__meta">{data.length} Rollen</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Rollen" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && <ErrorState error={loadError} onRetry={reload} />}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{data.map((roleDefinition) => (
|
||||||
|
<li className="card" key={roleDefinition.id}>
|
||||||
|
<h2 className="card__title">{roleDefinition.name}</h2>
|
||||||
|
|
||||||
|
{roleDefinition.description !== undefined && (
|
||||||
|
<p className="card__text">{roleDefinition.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Die Berechtigungen stehen ausgeschrieben da: „Backup Operator"
|
||||||
|
sagt einem Betreiber wenig, „backups.delete" alles. */}
|
||||||
|
<ul className="permission-list">
|
||||||
|
{(roleDefinition.permissions ?? []).map((permissionName) => (
|
||||||
|
<li className="permission-list__item" key={permissionName}>
|
||||||
|
{permissionName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,520 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agenten — Aufnahme und Verwaltung.
|
|
||||||
*
|
|
||||||
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
|
|
||||||
* und danach nur noch als Hash gespeichert. Die Oberfläche sagt das
|
|
||||||
* ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu
|
|
||||||
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
|
|
||||||
*
|
|
||||||
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
|
|
||||||
* könnte er sich als ein anderes System ausgeben.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
createEnrollmentToken,
|
|
||||||
listAgents,
|
|
||||||
revokeAgent,
|
|
||||||
rotateAgentCredentials,
|
|
||||||
type Agent,
|
|
||||||
type EnrollmentToken,
|
|
||||||
} from './infrastructureApi';
|
|
||||||
|
|
||||||
export function AgentsPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isEnrollDialogOpen, setIsEnrollDialogOpen] = useState(false);
|
|
||||||
const [issuedToken, setIssuedToken] = useState<EnrollmentToken | null>(null);
|
|
||||||
const [agentPendingRevoke, setAgentPendingRevoke] = useState<Agent | null>(null);
|
|
||||||
const [draftAgentName, setDraftAgentName] = useState('');
|
|
||||||
|
|
||||||
const agentsResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listAgents(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayEnroll =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('agents.enroll');
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('agents.write');
|
|
||||||
|
|
||||||
const enrollMutation = useMutation(createEnrollmentToken, {
|
|
||||||
onSuccess: (token) => {
|
|
||||||
setIssuedToken(token);
|
|
||||||
setIsEnrollDialogOpen(false);
|
|
||||||
setDraftAgentName('');
|
|
||||||
agentsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Das Aufnahme-Token ließ sich nicht erzeugen',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeMutation = useMutation(revokeAgent, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Agent gesperrt',
|
|
||||||
'Sein Betriebstoken gilt sofort nicht mehr — nicht erst nach Ablauf.',
|
|
||||||
);
|
|
||||||
setAgentPendingRevoke(null);
|
|
||||||
agentsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Sperre schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rotateMutation = useMutation(rotateAgentCredentials, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Betriebstoken erneuert',
|
|
||||||
'Der Agent muss mit dem neuen Token versorgt werden, sonst meldet er sich nicht mehr.',
|
|
||||||
);
|
|
||||||
agentsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const agents = agentsResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<Agent>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Agent',
|
|
||||||
render: (agent) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{agent.name}</p>
|
|
||||||
<p className="truncate text-xs text-fg-muted">
|
|
||||||
{agent.hostname ?? '—'}
|
|
||||||
{agent.platform ? ` · ${agent.platform}` : ''}
|
|
||||||
{agent.architecture ? `/${agent.architecture}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (agent) => <StatusBadge status={agent.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'version',
|
|
||||||
header: 'Fassung',
|
|
||||||
render: (agent) => (
|
|
||||||
<span className="font-mono text-xs text-fg-muted">{agent.version ?? '—'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'heartbeat',
|
|
||||||
header: 'Letzte Meldung',
|
|
||||||
render: (agent) =>
|
|
||||||
agent.last_heartbeat_at ? (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{formatRelativeTime(agent.last_heartbeat_at)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-warning">Noch nie</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (agent) =>
|
|
||||||
mayWrite ? (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-1"
|
|
||||||
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
||||||
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void rotateMutation.run(agent.id)}
|
|
||||||
title="Betriebstoken erneuern"
|
|
||||||
>
|
|
||||||
<RefreshCw />
|
|
||||||
</Button>
|
|
||||||
{agent.status !== 'revoked' ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAgentPendingRevoke(agent)}
|
|
||||||
title="Agent sperren"
|
|
||||||
>
|
|
||||||
<Ban />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Agenten" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(agentsResource.loadError)}
|
|
||||||
requestId={agentsResource.loadError.requestId}
|
|
||||||
onRetry={agentsResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Agenten"
|
|
||||||
description="Systeme, die sich selbst sichern."
|
|
||||||
actions={
|
|
||||||
mayEnroll ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Agent aufnehmen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout tone="info" className="mb-4">
|
|
||||||
Der Agent holt seine Aufträge ab; der Server drückt sie nicht.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={agents}
|
|
||||||
getRowKey={(agent) => agent.id}
|
|
||||||
isLoading={agentsResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Agent aufgenommen"
|
|
||||||
emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde."
|
|
||||||
emptyAction={
|
|
||||||
mayEnroll ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Ersten Agenten aufnehmen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* --- Aufnahme --- */}
|
|
||||||
<DialogRoot open={isEnrollDialogOpen} onOpenChange={setIsEnrollDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader
|
|
||||||
title="Agent aufnehmen"
|
|
||||||
description="Erzeugt ein einmalig gültiges Aufnahme-Token."
|
|
||||||
/>
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="Name des Agenten"
|
|
||||||
required
|
|
||||||
value={draftAgentName}
|
|
||||||
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
|
|
||||||
placeholder="dateiserver-01"
|
|
||||||
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst könnte er sich als ein anderes System ausgeben."
|
|
||||||
/>
|
|
||||||
<Callout tone="info">
|
|
||||||
Gilt eine Stunde und nur zur Registrierung.
|
|
||||||
</Callout>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsEnrollDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={enrollMutation.isRunning}
|
|
||||||
disabled={!draftAgentName.trim()}
|
|
||||||
onClick={() => void enrollMutation.run(draftAgentName.trim(), 60)}
|
|
||||||
>
|
|
||||||
Token erzeugen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
{/* --- Einmalige Anzeige des Tokens --- */}
|
|
||||||
{issuedToken ? (
|
|
||||||
<IssuedTokenDialog token={issuedToken} onClose={() => setIssuedToken(null)} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={agentPendingRevoke !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setAgentPendingRevoke(null)}
|
|
||||||
title="Agent sperren"
|
|
||||||
description={agentPendingRevoke?.name}
|
|
||||||
confirmLabel="Sperren"
|
|
||||||
isDestructive
|
|
||||||
isLoading={revokeMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (agentPendingRevoke) {
|
|
||||||
void revokeMutation.run(agentPendingRevoke.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="warning">
|
|
||||||
Das Betriebstoken gilt sofort nicht mehr. Geplante Sicherungen fallen aus.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zeigt das Aufnahme-Token einmalig.
|
|
||||||
*
|
|
||||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
|
||||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme.
|
|
||||||
*
|
|
||||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
|
||||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
|
||||||
*
|
|
||||||
* Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token
|
|
||||||
* eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
|
||||||
* jemand `<token>` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die
|
|
||||||
* nichts mit seinem Problem zu tun hat.
|
|
||||||
*/
|
|
||||||
function IssuedTokenDialog({
|
|
||||||
token,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
readonly token: EnrollmentToken;
|
|
||||||
readonly onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
|
||||||
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
|
|
||||||
|
|
||||||
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der
|
|
||||||
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
|
|
||||||
const serverAddress = window.location.origin;
|
|
||||||
|
|
||||||
const copyText = async (textToCopy: string, entryKey: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
|
||||||
setCopiedKey(entryKey);
|
|
||||||
window.setTimeout(() => setCopiedKey(null), 2000);
|
|
||||||
} catch {
|
|
||||||
toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const linuxSteps = [
|
|
||||||
{
|
|
||||||
key: 'linux-paket',
|
|
||||||
title: '1. Paket auspacken',
|
|
||||||
command: `sudo mkdir -p /opt/syncova-agent
|
|
||||||
sudo tar -xzf syncova-*-linux-amd64.tar.gz -C /tmp
|
|
||||||
sudo cp /tmp/syncova-*/bin/syncova-agent /opt/syncova-agent/`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'linux-konto',
|
|
||||||
title: '2. Dienstkonto und Verzeichnisse',
|
|
||||||
command: `sudo useradd --system --no-create-home --shell /usr/sbin/nologin syncova-agent
|
|
||||||
sudo install -d -o syncova-agent -g syncova-agent /var/lib/syncova-agent`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'linux-enroll',
|
|
||||||
title: '3. Aufnehmen',
|
|
||||||
command: `sudo -u syncova-agent /opt/syncova-agent/syncova-agent enroll \\
|
|
||||||
--server ${serverAddress} \\
|
|
||||||
--token ${token.token} \\
|
|
||||||
--state /var/lib/syncova-agent/state.json`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'linux-dienst',
|
|
||||||
title: '4. Als Dienst einrichten',
|
|
||||||
command: `sudo tee /etc/systemd/system/syncova-agent.service >/dev/null <<'EOF'
|
|
||||||
[Unit]
|
|
||||||
Description=Syncova Agent
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=syncova-agent
|
|
||||||
ExecStart=/opt/syncova-agent/syncova-agent run --state /var/lib/syncova-agent/state.json
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/var/lib/syncova-agent
|
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
||||||
EOF
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now syncova-agent`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const windowsSteps = [
|
|
||||||
{
|
|
||||||
key: 'win-paket',
|
|
||||||
title: '1. Paket auspacken',
|
|
||||||
command: `New-Item -ItemType Directory -Force "C:\\Program Files\\Syncova Agent"
|
|
||||||
Expand-Archive syncova-*-windows-amd64.zip -DestinationPath $env:TEMP\\syncova
|
|
||||||
Copy-Item $env:TEMP\\syncova\\*\\bin\\syncova-agent.exe "C:\\Program Files\\Syncova Agent\\"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'win-enroll',
|
|
||||||
title: '2. Aufnehmen',
|
|
||||||
command: `New-Item -ItemType Directory -Force "C:\\ProgramData\\Syncova"
|
|
||||||
& "C:\\Program Files\\Syncova Agent\\syncova-agent.exe" enroll \`
|
|
||||||
--server ${serverAddress} \`
|
|
||||||
--token ${token.token} \`
|
|
||||||
--state "C:\\ProgramData\\Syncova\\state.json"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'win-dienst',
|
|
||||||
title: '3. Als Dienst einrichten',
|
|
||||||
command: `New-Service -Name SyncovaAgent \`
|
|
||||||
-DisplayName "Syncova Agent" \`
|
|
||||||
-BinaryPathName '"C:\\Program Files\\Syncova Agent\\syncova-agent.exe" run --state "C:\\ProgramData\\Syncova\\state.json"' \`
|
|
||||||
-StartupType Automatic
|
|
||||||
Start-Service SyncovaAgent`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeSteps = platform === 'linux' ? linuxSteps : windowsSteps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogRoot open onOpenChange={() => undefined}>
|
|
||||||
<DialogContent size="xl">
|
|
||||||
<DialogHeader
|
|
||||||
title="Agent aufnehmen"
|
|
||||||
description={`Für „${token.agent_name}"`}
|
|
||||||
/>
|
|
||||||
<DialogBody>
|
|
||||||
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
|
||||||
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
|
|
||||||
wenn der Agent aufgenommen ist.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<div className="border border-line bg-sunken p-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<code className="min-w-0 break-all font-mono text-sm text-fg">
|
|
||||||
{token.token}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => void copyText(token.token, 'token')}
|
|
||||||
>
|
|
||||||
{copiedKey === 'token' ? <KeyRound /> : <Copy />}
|
|
||||||
{copiedKey === 'token' ? 'Kopiert' : 'Kopieren'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{token.expires_at ? (
|
|
||||||
<p className="mt-2 text-xs text-fg-muted">
|
|
||||||
Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Systemwahl */}
|
|
||||||
<div className="flex gap-2 border-b border-line pb-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={platform === 'linux' ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setPlatform('linux')}
|
|
||||||
>
|
|
||||||
<Terminal />
|
|
||||||
Linux
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={platform === 'windows' ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setPlatform('windows')}
|
|
||||||
>
|
|
||||||
<MonitorCog />
|
|
||||||
Windows (PowerShell)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{platform === 'windows' ? (
|
|
||||||
<Callout tone="warning">
|
|
||||||
Der Windows-Dienst ist gebaut und übersetzt, aber{' '}
|
|
||||||
<strong>nie auf echter Hardware gefahren</strong>. Der
|
|
||||||
Kommandozeilenweg ist nachgewiesen.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeSteps.map((step) => (
|
|
||||||
<div key={step.key}>
|
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
||||||
<p className="text-sm font-medium text-fg">{step.title}</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => void copyText(step.command, step.key)}
|
|
||||||
>
|
|
||||||
{copiedKey === step.key ? <KeyRound /> : <Copy />}
|
|
||||||
{copiedKey === step.key ? 'Kopiert' : 'Kopieren'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto border border-line bg-sunken p-2.5 font-mono text-xs text-fg">
|
|
||||||
{step.command}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Callout tone="info" title="Zwei Stolperstellen">
|
|
||||||
<ul className="mt-1 space-y-1 text-xs">
|
|
||||||
<li>
|
|
||||||
<code className="font-mono">--state</code> erwartet eine{' '}
|
|
||||||
<strong>Datei</strong>, kein Verzeichnis. Mit einem Verzeichnis
|
|
||||||
hält sich der Agent für registriert und läuft ohne Token.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Der Agent braucht <strong>Schreibzugriff auf das Repository</strong>.
|
|
||||||
Auf einem gemeinsamen Server ist das der lokale Pfad, bei
|
|
||||||
getrennten Maschinen eine Freigabe.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Callout>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="primary" onClick={onClose}>
|
|
||||||
Der Agent ist aufgenommen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* Geschützte Systeme — die verbundsübergreifende Bestandsliste.
|
|
||||||
*
|
|
||||||
* Sie beantwortet die Frage, die keine der Einzelseiten beantwortet: **Was
|
|
||||||
* genau ist eigentlich geschützt?** Agenten und virtuelle Gäste stehen
|
|
||||||
* nebeneinander, weil sie aus Sicht eines Betreibers dasselbe sind — ein
|
|
||||||
* System, von dem es Sicherungen geben soll.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
DataTable,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes, formatRelativeTime } from '@/lib/utils';
|
|
||||||
import { listAgents, listVirtualMachines, type Agent, type VirtualMachine } from './infrastructureApi';
|
|
||||||
|
|
||||||
export function ProtectedSystemsPage() {
|
|
||||||
const agentsResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listAgents(abortSignal), []),
|
|
||||||
);
|
|
||||||
const machinesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listVirtualMachines(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentColumns: ReadonlyArray<TableColumn<Agent>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'System',
|
|
||||||
render: (agent) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{agent.name}</p>
|
|
||||||
<p className="truncate text-xs text-fg-muted">
|
|
||||||
{agent.hostname ?? '—'}
|
|
||||||
{agent.platform ? ` · ${agent.platform}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'status', header: 'Zustand', render: (agent) => <StatusBadge status={agent.status} /> },
|
|
||||||
{
|
|
||||||
key: 'heartbeat',
|
|
||||||
header: 'Letzte Meldung',
|
|
||||||
render: (agent) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{agent.last_heartbeat_at ? formatRelativeTime(agent.last_heartbeat_at) : 'Noch nie'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const machineColumns: ReadonlyArray<TableColumn<VirtualMachine>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Gast',
|
|
||||||
render: (machine) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{machine.name}</p>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{machine.guest_kind} · {machine.provider_vm_id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (machine) =>
|
|
||||||
machine.missing_since ? (
|
|
||||||
<span className="text-xs text-warning">
|
|
||||||
Fehlt seit {formatRelativeTime(machine.missing_since)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<StatusBadge status={machine.status} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'resources',
|
|
||||||
header: 'Ausstattung',
|
|
||||||
render: (machine) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{machine.cpu_count ?? '—'} CPU · {formatBytes(machine.memory_bytes)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'disks',
|
|
||||||
header: 'Platten',
|
|
||||||
numeric: true,
|
|
||||||
render: (machine) => (
|
|
||||||
<span>
|
|
||||||
{machine.disk_count}
|
|
||||||
{machine.excluded_disk_count > 0 ? (
|
|
||||||
<span className="ml-1 text-warning">
|
|
||||||
({machine.excluded_disk_count} ausgenommen)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Geschützte Systeme" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(agentsResource.loadError)}
|
|
||||||
requestId={agentsResource.loadError.requestId}
|
|
||||||
onRetry={agentsResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Geschützte Systeme"
|
|
||||||
description="Was gesichert wird — Agenten und virtuelle Gäste nebeneinander."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout tone="info" className="mb-4">
|
|
||||||
Bestand, nicht Sicherungsstand — Aufträge stehen unter „Sicherungsaufträge“.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader title="Agenten" />
|
|
||||||
<DataTable
|
|
||||||
columns={agentColumns}
|
|
||||||
rows={agentsResource.data ?? []}
|
|
||||||
getRowKey={(agent) => agent.id}
|
|
||||||
isLoading={agentsResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Agent aufgenommen"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="Virtuelle Gäste" />
|
|
||||||
<DataTable
|
|
||||||
columns={machineColumns}
|
|
||||||
rows={machinesResource.data ?? []}
|
|
||||||
getRowKey={(machine) => machine.id}
|
|
||||||
isLoading={machinesResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Gast erfasst"
|
|
||||||
emptyDescription="Richten Sie unter „Proxmox“ einen Verbund ein und erfassen Sie dessen Bestand."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,611 +0,0 @@
|
|||||||
/**
|
|
||||||
* Proxmox-Verbünde.
|
|
||||||
*
|
|
||||||
* Dieser Bereich hatte bisher gar keine Oberfläche — neun Endpunkte, die sich
|
|
||||||
* nur über die API bedienen ließen.
|
|
||||||
*
|
|
||||||
* Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausführlich
|
|
||||||
* beschriftet:
|
|
||||||
*
|
|
||||||
* - **`http://` wird abgelehnt.** Über diese Verbindung geht ein API-Token.
|
|
||||||
* - **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter
|
|
||||||
* „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt,
|
|
||||||
* macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte
|
|
||||||
* dann das Archiv, das Syncova für ein Backup hält.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CheckboxField,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DetailItem,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
SelectInput,
|
|
||||||
StatusBadge,
|
|
||||||
TextArea,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes, formatRelativeTime } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
createCluster,
|
|
||||||
deleteCluster,
|
|
||||||
discoverCluster,
|
|
||||||
listClusterMachines,
|
|
||||||
listClusters,
|
|
||||||
testCluster,
|
|
||||||
type Cluster,
|
|
||||||
type TransportKind,
|
|
||||||
type VirtualMachine,
|
|
||||||
} from './infrastructureApi';
|
|
||||||
|
|
||||||
export function ProxmoxPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
||||||
const [selectedCluster, setSelectedCluster] = useState<Cluster | null>(null);
|
|
||||||
const [clusterPendingDeletion, setClusterPendingDeletion] = useState<Cluster | null>(null);
|
|
||||||
|
|
||||||
// Entwurf des neuen Verbunds.
|
|
||||||
const [draftName, setDraftName] = useState('');
|
|
||||||
const [draftEndpoint, setDraftEndpoint] = useState('https://');
|
|
||||||
const [draftTokenId, setDraftTokenId] = useState('');
|
|
||||||
const [draftTokenSecret, setDraftTokenSecret] = useState('');
|
|
||||||
const [draftFingerprint, setDraftFingerprint] = useState('');
|
|
||||||
const [draftStorageId, setDraftStorageId] = useState('local');
|
|
||||||
const [draftTransport, setDraftTransport] = useState<TransportKind>('local');
|
|
||||||
const [draftMountRoot, setDraftMountRoot] = useState('');
|
|
||||||
const [draftSshUser, setDraftSshUser] = useState('root');
|
|
||||||
const [draftSshKey, setDraftSshKey] = useState('');
|
|
||||||
const [draftSshFingerprint, setDraftSshFingerprint] = useState('');
|
|
||||||
const [draftKeepArchive, setDraftKeepArchive] = useState(false);
|
|
||||||
|
|
||||||
const clustersResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listClusters(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('providers.write');
|
|
||||||
|
|
||||||
const createMutation = useMutation(createCluster, {
|
|
||||||
onSuccess: (cluster) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Verbund angelegt',
|
|
||||||
`${cluster.name} — prüfen Sie als Nächstes die Verbindung.`,
|
|
||||||
);
|
|
||||||
setIsCreateDialogOpen(false);
|
|
||||||
setDraftTokenSecret('');
|
|
||||||
setDraftSshKey('');
|
|
||||||
clustersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Verbund ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const testMutation = useMutation(testCluster, {
|
|
||||||
onSuccess: (result) =>
|
|
||||||
toast.showSuccess(
|
|
||||||
'Verbindung steht',
|
|
||||||
result.version ? `Proxmox VE ${result.version}` : result.message,
|
|
||||||
),
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
|
|
||||||
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit völlig
|
|
||||||
// verschiedener Abhilfe — sie dürfen nicht dieselbe Meldung ergeben.
|
|
||||||
apiError.statusCode === 503
|
|
||||||
? `${describeApiError(apiError)} — prüfen Sie Netz, Adresse und Zertifikat.`
|
|
||||||
: `${describeApiError(apiError)} — prüfen Sie Token-Kennung und Geheimnis.`,
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const discoverMutation = useMutation(discoverCluster, {
|
|
||||||
onSuccess: (result) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Bestand erfasst',
|
|
||||||
result.summary ??
|
|
||||||
`${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gäste.`,
|
|
||||||
);
|
|
||||||
clustersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Erfassung schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation(deleteCluster, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Verbund entfernt');
|
|
||||||
setClusterPendingDeletion(null);
|
|
||||||
setSelectedCluster(null);
|
|
||||||
clustersResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Der Verbund ließ sich nicht entfernen',
|
|
||||||
apiError.statusCode === 409
|
|
||||||
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
|
|
||||||
: describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const clusters = clustersResource.data ?? [];
|
|
||||||
|
|
||||||
const endpointIsSecure = draftEndpoint.trim().toLowerCase().startsWith('https://');
|
|
||||||
const sshTransportComplete =
|
|
||||||
draftTransport !== 'ssh' || (draftSshKey.trim() !== '' && draftSshFingerprint.trim() !== '');
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<Cluster>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Verbund',
|
|
||||||
render: (cluster) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{cluster.name}</p>
|
|
||||||
<p className="truncate font-mono text-xs text-fg-muted">{cluster.api_endpoint}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (cluster) => <StatusBadge status={cluster.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transport',
|
|
||||||
header: 'Archivzugriff',
|
|
||||||
render: (cluster) => (
|
|
||||||
<span className="text-sm">
|
|
||||||
{cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'last_seen',
|
|
||||||
header: 'Zuletzt erreicht',
|
|
||||||
render: (cluster) =>
|
|
||||||
cluster.last_seen_at ? (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{formatRelativeTime(cluster.last_seen_at)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
|
|
||||||
// Versuch zu setzen machte daraus ein "zuletzt versucht", und ein
|
|
||||||
// seit Wochen toter Verbund sähe frisch aus.
|
|
||||||
<span className="text-xs text-warning">Nie erreicht</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (clustersResource.loadState === 'failed' && clustersResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Proxmox" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(clustersResource.loadError)}
|
|
||||||
requestId={clustersResource.loadError.requestId}
|
|
||||||
onRetry={clustersResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Proxmox"
|
|
||||||
description="Virtualisierungsverbünde und ihre Gäste."
|
|
||||||
actions={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Verbund anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
|
|
||||||
Ob eine wiederhergestellte Maschine startet, ist ungeprüft.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={clusters}
|
|
||||||
getRowKey={(cluster) => cluster.id}
|
|
||||||
onRowClick={(cluster) => setSelectedCluster(cluster)}
|
|
||||||
isLoading={clustersResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Verbund eingerichtet"
|
|
||||||
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten."
|
|
||||||
emptyAction={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Verbund anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedCluster ? (
|
|
||||||
<ClusterDetailCard
|
|
||||||
cluster={selectedCluster}
|
|
||||||
mayWrite={mayWrite}
|
|
||||||
isTesting={testMutation.isRunning}
|
|
||||||
isDiscovering={discoverMutation.isRunning}
|
|
||||||
onTest={() => void testMutation.run(selectedCluster.id)}
|
|
||||||
onDiscover={() => void discoverMutation.run(selectedCluster.id)}
|
|
||||||
onDelete={() => setClusterPendingDeletion(selectedCluster)}
|
|
||||||
onClose={() => setSelectedCluster(null)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* --- Verbund anlegen --- */}
|
|
||||||
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
||||||
<DialogContent size="lg">
|
|
||||||
<DialogHeader
|
|
||||||
title="Proxmox-Verbund anlegen"
|
|
||||||
description="Zugangsdaten werden verschlüsselt abgelegt und nie wieder ausgeliefert."
|
|
||||||
/>
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="Bezeichnung"
|
|
||||||
required
|
|
||||||
value={draftName}
|
|
||||||
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
|
|
||||||
placeholder="Rechenzentrum Nord"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="API-Adresse"
|
|
||||||
required
|
|
||||||
className="font-mono"
|
|
||||||
value={draftEndpoint}
|
|
||||||
onChange={(changeEvent) => setDraftEndpoint(changeEvent.target.value)}
|
|
||||||
placeholder="https://pve.example:8006"
|
|
||||||
error={
|
|
||||||
draftEndpoint.trim().length > 8 && !endpointIsSecure
|
|
||||||
? 'Nur https. Über diese Verbindung geht ein API-Token.'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<TextInput
|
|
||||||
label="Token-Kennung"
|
|
||||||
required
|
|
||||||
className="font-mono"
|
|
||||||
value={draftTokenId}
|
|
||||||
onChange={(changeEvent) => setDraftTokenId(changeEvent.target.value)}
|
|
||||||
placeholder="syncova@pve!backup"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Token-Geheimnis"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={draftTokenSecret}
|
|
||||||
onChange={(changeEvent) => setDraftTokenSecret(changeEvent.target.value)}
|
|
||||||
hint="Wird nach dem Anlegen nie wieder angezeigt."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="TLS-Fingerabdruck (bei selbstsigniertem Zertifikat)"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
value={draftFingerprint}
|
|
||||||
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
|
|
||||||
placeholder="AB:CD:EF:…"
|
|
||||||
hint="Fingerabdruckbindung ist strenger als eine CA-Prüfung: Es gilt genau ein Zertifikat."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Speicherkennung für Sicherungen"
|
|
||||||
required
|
|
||||||
className="font-mono"
|
|
||||||
value={draftStorageId}
|
|
||||||
onChange={(changeEvent) => setDraftStorageId(changeEvent.target.value)}
|
|
||||||
placeholder="local"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t border-line pt-4">
|
|
||||||
<SelectInput
|
|
||||||
label="Zugriff auf die Sicherungsarchive"
|
|
||||||
value={draftTransport}
|
|
||||||
onChange={(changeEvent) =>
|
|
||||||
setDraftTransport(changeEvent.target.value as TransportKind)
|
|
||||||
}
|
|
||||||
hint="Proxmox kann eine Sicherung anstoßen, die entstandene Datei aber nicht über die API herausgeben. Deshalb braucht es einen zweiten Weg."
|
|
||||||
>
|
|
||||||
<option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option>
|
|
||||||
<option value="ssh">SSH zum Knoten</option>
|
|
||||||
</SelectInput>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draftTransport === 'local' ? (
|
|
||||||
<TextInput
|
|
||||||
label="Pfad der Ablage auf diesem Server"
|
|
||||||
className="font-mono"
|
|
||||||
value={draftMountRoot}
|
|
||||||
onChange={(changeEvent) => setDraftMountRoot(changeEvent.target.value)}
|
|
||||||
placeholder="/mnt/pve-dump"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<TextInput
|
|
||||||
label="SSH-Benutzer"
|
|
||||||
value={draftSshUser}
|
|
||||||
onChange={(changeEvent) => setDraftSshUser(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextArea
|
|
||||||
label="Privater Schlüssel (PEM)"
|
|
||||||
required
|
|
||||||
className="font-mono text-xs"
|
|
||||||
value={draftSshKey}
|
|
||||||
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
|
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schlüssel */
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Wirtsschlüssel-Fingerabdruck"
|
|
||||||
required
|
|
||||||
className="font-mono text-xs"
|
|
||||||
value={draftSshFingerprint}
|
|
||||||
onChange={(changeEvent) => setDraftSshFingerprint(changeEvent.target.value)}
|
|
||||||
placeholder="SHA256:…"
|
|
||||||
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
|
|
||||||
/>
|
|
||||||
<Callout tone="warning">
|
|
||||||
Einen Schalter „Wirtsschlüssel egal“ gibt es nicht.
|
|
||||||
</Callout>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CheckboxField
|
|
||||||
checked={draftKeepArchive}
|
|
||||||
onCheckedChange={setDraftKeepArchive}
|
|
||||||
label="Archiv auf dem Knoten behalten"
|
|
||||||
hint="Normalerweise wird es nach dem Einlesen entfernt. Behält man es, füllt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, für die keine Aufbewahrungsregel gilt."
|
|
||||||
/>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={createMutation.isRunning}
|
|
||||||
disabled={
|
|
||||||
!draftName.trim() ||
|
|
||||||
!endpointIsSecure ||
|
|
||||||
!draftTokenId.trim() ||
|
|
||||||
!draftTokenSecret ||
|
|
||||||
!draftStorageId.trim() ||
|
|
||||||
!sshTransportComplete
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void createMutation.run({
|
|
||||||
name: draftName.trim(),
|
|
||||||
api_endpoint: draftEndpoint.trim(),
|
|
||||||
api_token_id: draftTokenId.trim(),
|
|
||||||
api_token_secret: draftTokenSecret,
|
|
||||||
backup_storage_id: draftStorageId.trim(),
|
|
||||||
archive_transport: draftTransport,
|
|
||||||
...(draftFingerprint.trim()
|
|
||||||
? { tls_fingerprint: draftFingerprint.trim() }
|
|
||||||
: {}),
|
|
||||||
...(draftTransport === 'local' && draftMountRoot.trim()
|
|
||||||
? { archive_mount_roots: { [draftStorageId.trim()]: draftMountRoot.trim() } }
|
|
||||||
: {}),
|
|
||||||
...(draftTransport === 'ssh'
|
|
||||||
? {
|
|
||||||
ssh_username: draftSshUser.trim(),
|
|
||||||
ssh_private_key_pem: draftSshKey,
|
|
||||||
ssh_host_fingerprints: { '*': draftSshFingerprint.trim() },
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
keep_archive_on_node: draftKeepArchive,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Anlegen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={clusterPendingDeletion !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setClusterPendingDeletion(null)}
|
|
||||||
title="Verbund entfernen"
|
|
||||||
description={clusterPendingDeletion?.name}
|
|
||||||
confirmLabel="Entfernen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteMutation.isRunning}
|
|
||||||
confirmationWord={clusterPendingDeletion?.name}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (clusterPendingDeletion) {
|
|
||||||
void deleteMutation.run(clusterPendingDeletion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="info">
|
|
||||||
Die Sicherungen der Gäste bleiben im Repository.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Einzelheiten eines Verbunds samt Gastliste. */
|
|
||||||
function ClusterDetailCard({
|
|
||||||
cluster,
|
|
||||||
mayWrite,
|
|
||||||
isTesting,
|
|
||||||
isDiscovering,
|
|
||||||
onTest,
|
|
||||||
onDiscover,
|
|
||||||
onDelete,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
readonly cluster: Cluster;
|
|
||||||
readonly mayWrite: boolean;
|
|
||||||
readonly isTesting: boolean;
|
|
||||||
readonly isDiscovering: boolean;
|
|
||||||
readonly onTest: () => void;
|
|
||||||
readonly onDiscover: () => void;
|
|
||||||
readonly onDelete: () => void;
|
|
||||||
readonly onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const machinesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listClusterMachines(cluster.id, abortSignal), [cluster.id]),
|
|
||||||
cluster.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const machines = machinesResource.data ?? [];
|
|
||||||
|
|
||||||
const machineColumns: ReadonlyArray<TableColumn<VirtualMachine>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Gast',
|
|
||||||
render: (machine) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{machine.name}</p>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{machine.guest_kind} · {machine.provider_vm_id}
|
|
||||||
{machine.node_name ? ` · ${machine.node_name}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (machine) =>
|
|
||||||
machine.missing_since ? (
|
|
||||||
// Ein fehlender Gast wird vermerkt, nie gelöscht — eine gelöschte
|
|
||||||
// Zeile nähme die Zuordnung zu vorhandenen Backups mit.
|
|
||||||
<span className="text-xs text-warning">
|
|
||||||
Fehlt seit {formatRelativeTime(machine.missing_since)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<StatusBadge status={machine.status} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'resources',
|
|
||||||
header: 'Ausstattung',
|
|
||||||
render: (machine) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{machine.cpu_count ?? '—'} CPU · {formatBytes(machine.memory_bytes)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'disks',
|
|
||||||
header: 'Platten',
|
|
||||||
numeric: true,
|
|
||||||
render: (machine) => (
|
|
||||||
<span>
|
|
||||||
{machine.disk_count}
|
|
||||||
{machine.excluded_disk_count > 0 ? (
|
|
||||||
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
|
|
||||||
// Wiederherstellung liefert sonst eine unvollständige Maschine,
|
|
||||||
// die jemand für vollständig hält.
|
|
||||||
<span className="ml-1 text-warning">
|
|
||||||
({machine.excluded_disk_count} ausgenommen)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader
|
|
||||||
title={cluster.name}
|
|
||||||
description={cluster.api_endpoint}
|
|
||||||
action={
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
Schließen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<DetailItem label="Zustand">
|
|
||||||
<StatusBadge status={cluster.status} />
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Token-Kennung">
|
|
||||||
<code className="font-mono text-xs">{cluster.api_token_id}</code>
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Archivzugriff">
|
|
||||||
{cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Speicher">{cluster.backup_storage_id}</DetailItem>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{mayWrite ? (
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
|
|
||||||
<Button size="sm" isLoading={isTesting} onClick={onTest}>
|
|
||||||
<Network />
|
|
||||||
Verbindung prüfen
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
|
|
||||||
<Search />
|
|
||||||
Bestand erfassen
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={machinesResource.reload}>
|
|
||||||
<RefreshCw />
|
|
||||||
Liste aktualisieren
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="destructive" className="ml-auto" onClick={onDelete}>
|
|
||||||
<Trash2 />
|
|
||||||
Entfernen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-5 border-t border-line pt-4">
|
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Gäste
|
|
||||||
</p>
|
|
||||||
<DataTable
|
|
||||||
columns={machineColumns}
|
|
||||||
rows={machines}
|
|
||||||
getRowKey={(machine) => machine.id}
|
|
||||||
isLoading={machinesResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Gast erfasst"
|
|
||||||
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gäste dieses Verbunds zu sehen."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
/**
|
|
||||||
* API-Anbindung von Virtualisierungsverbünden und Agenten.
|
|
||||||
*
|
|
||||||
* Beide teilen eine Eigenschaft, die die Masken prägt: **Geheimnisse gehen nur
|
|
||||||
* hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das
|
|
||||||
* Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen —
|
|
||||||
* die API gibt sie nicht zurück, und das ist kein Mangel, sondern der Grund,
|
|
||||||
* warum ein Lesezugriff auf die Konfiguration ungefährlich ist.
|
|
||||||
*
|
|
||||||
* Die Oberfläche muss das sagen, sonst schließt jemand das Fenster mit dem
|
|
||||||
* einmalig angezeigten Aufnahme-Token.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Proxmox-Verbünde
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Zugriffsweg auf die Sicherungsarchive. */
|
|
||||||
export type TransportKind = 'local' | 'ssh';
|
|
||||||
|
|
||||||
/** Ein eingerichteter Verbund. */
|
|
||||||
export interface Cluster {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
api_endpoint: string;
|
|
||||||
api_token_id: string;
|
|
||||||
/** Fingerabdruck bei selbstsigniertem Zertifikat. */
|
|
||||||
tls_fingerprint?: string;
|
|
||||||
backup_storage_id: string;
|
|
||||||
archive_transport: TransportKind;
|
|
||||||
archive_mount_roots?: Record<string, string>;
|
|
||||||
ssh_username?: string;
|
|
||||||
ssh_port?: number;
|
|
||||||
ssh_host_fingerprints?: Record<string, string>;
|
|
||||||
keep_archive_on_node: boolean;
|
|
||||||
status: string;
|
|
||||||
last_seen_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Anfrage zum Anlegen eines Verbunds. */
|
|
||||||
export interface ClusterInput {
|
|
||||||
name: string;
|
|
||||||
api_endpoint: string;
|
|
||||||
api_token_id: string;
|
|
||||||
/** Wird verschlüsselt abgelegt und nie wieder ausgeliefert. */
|
|
||||||
api_token_secret: string;
|
|
||||||
tls_fingerprint?: string;
|
|
||||||
backup_storage_id: string;
|
|
||||||
archive_transport: TransportKind;
|
|
||||||
archive_mount_roots?: Record<string, string>;
|
|
||||||
ssh_username?: string;
|
|
||||||
ssh_port?: number;
|
|
||||||
ssh_private_key_pem?: string;
|
|
||||||
/** Ohne hinterlegten Wirtsschlüssel kommt keine SSH-Verbindung zustande. */
|
|
||||||
ssh_host_fingerprints?: Record<string, string>;
|
|
||||||
keep_archive_on_node?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Knoten des Verbunds. */
|
|
||||||
export interface ClusterHost {
|
|
||||||
id: string;
|
|
||||||
cluster_id: string;
|
|
||||||
node_name: string;
|
|
||||||
status?: string;
|
|
||||||
cpu_count?: number;
|
|
||||||
memory_bytes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Gast. */
|
|
||||||
export interface VirtualMachine {
|
|
||||||
id: string;
|
|
||||||
cluster_id: string;
|
|
||||||
provider_vm_id: string;
|
|
||||||
name: string;
|
|
||||||
guest_kind: string;
|
|
||||||
status?: string;
|
|
||||||
cpu_count?: number;
|
|
||||||
memory_bytes?: number;
|
|
||||||
node_name?: string;
|
|
||||||
disk_count: number;
|
|
||||||
/**
|
|
||||||
* Platten mit `backup=0`.
|
|
||||||
*
|
|
||||||
* Größer als null macht einen Sicherungslauf zum Teilfehler — nicht weil
|
|
||||||
* etwas schiefging, sondern weil die Wiederherstellung sonst eine
|
|
||||||
* unvollständige Maschine liefert, die jemand für vollständig hält.
|
|
||||||
*/
|
|
||||||
excluded_disk_count: number;
|
|
||||||
/**
|
|
||||||
* Zeitpunkt, seit dem der Gast im Bestand fehlt.
|
|
||||||
*
|
|
||||||
* Ein fehlender Gast wird nie gelöscht: Er könnte abgeschaltet oder
|
|
||||||
* verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu
|
|
||||||
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
|
|
||||||
* weg ist.
|
|
||||||
*/
|
|
||||||
missing_since?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Verbünde. */
|
|
||||||
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
|
|
||||||
return requestApi<Cluster[]>(
|
|
||||||
'/proxmox/clusters',
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt einen Verbund an. */
|
|
||||||
export async function createCluster(clusterInput: ClusterInput): Promise<Cluster> {
|
|
||||||
return requestApi<Cluster>('/proxmox/clusters', {
|
|
||||||
method: 'POST',
|
|
||||||
body: clusterInput,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Löscht einen Verbund. */
|
|
||||||
export async function deleteCluster(clusterIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft die Verbindung.
|
|
||||||
*
|
|
||||||
* Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist
|
|
||||||
* von `unreachable` getrennt, weil die Abhilfe eine völlig andere ist.
|
|
||||||
*/
|
|
||||||
export async function testCluster(
|
|
||||||
clusterIdentifier: string,
|
|
||||||
): Promise<{ status: string; message?: string; version?: string }> {
|
|
||||||
return requestApi(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/test`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Erfasst den Bestand neu. */
|
|
||||||
export async function discoverCluster(
|
|
||||||
clusterIdentifier: string,
|
|
||||||
): Promise<{ hosts_found?: number; guests_found?: number; summary?: string }> {
|
|
||||||
return requestApi(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/discover`, {
|
|
||||||
method: 'POST',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Knoten eines Verbunds. */
|
|
||||||
export async function listClusterHosts(
|
|
||||||
clusterIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<ClusterHost[]> {
|
|
||||||
return requestApi<ClusterHost[]>(
|
|
||||||
`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/hosts`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Gäste eines Verbunds. */
|
|
||||||
export async function listClusterMachines(
|
|
||||||
clusterIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<VirtualMachine[]> {
|
|
||||||
return requestApi<VirtualMachine[]>(
|
|
||||||
`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/vms`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt alle bekannten Gäste, verbundsübergreifend. */
|
|
||||||
export async function listVirtualMachines(
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<VirtualMachine[]> {
|
|
||||||
return requestApi<VirtualMachine[]>(
|
|
||||||
'/virtual-machines',
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Agenten
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Ein aufgenommener Agent. */
|
|
||||||
export interface Agent {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
hostname?: string;
|
|
||||||
platform?: string;
|
|
||||||
architecture?: string;
|
|
||||||
version?: string;
|
|
||||||
status: string;
|
|
||||||
last_heartbeat_at?: string;
|
|
||||||
enrolled_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Antwort auf die Erzeugung eines Aufnahme-Tokens.
|
|
||||||
*
|
|
||||||
* Das Feld heißt **`token`**, nicht `enrollment_token` — Letzteres ist der Name
|
|
||||||
* im *Anfrage*körper der Registrierung. Die Verwechslung ließ die Oberfläche
|
|
||||||
* „undefined" anzeigen, und der Betreiber hatte kein Token, obwohl der Server
|
|
||||||
* eines ausgestellt hatte.
|
|
||||||
*/
|
|
||||||
export interface EnrollmentToken {
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Das Token im Klartext — **einmalig**.
|
|
||||||
*
|
|
||||||
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen.
|
|
||||||
*/
|
|
||||||
token: string;
|
|
||||||
agent_name: string;
|
|
||||||
expires_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Agenten. */
|
|
||||||
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
|
|
||||||
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Erzeugt ein Aufnahme-Token.
|
|
||||||
*
|
|
||||||
* Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst könnte er
|
|
||||||
* sich als ein anderes System ausgeben.
|
|
||||||
*/
|
|
||||||
export async function createEnrollmentToken(
|
|
||||||
agentName: string,
|
|
||||||
validityMinutes = 60,
|
|
||||||
): Promise<EnrollmentToken> {
|
|
||||||
return requestApi<EnrollmentToken>('/agents/enrollment-tokens', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { agent_name: agentName, validity_minutes: validityMinutes },
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sperrt einen Agenten. Sein Betriebstoken gilt sofort nicht mehr. */
|
|
||||||
export async function revokeAgent(agentIdentifier: string): Promise<Agent> {
|
|
||||||
return requestApi<Agent>(`/agents/${encodeURIComponent(agentIdentifier)}/revoke`, {
|
|
||||||
method: 'POST',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Erneuert das Betriebstoken eines Agenten. */
|
|
||||||
export async function rotateAgentCredentials(
|
|
||||||
agentIdentifier: string,
|
|
||||||
): Promise<{ operational_token?: string }> {
|
|
||||||
return requestApi(
|
|
||||||
`/agents/${encodeURIComponent(agentIdentifier)}/rotate-credentials`,
|
|
||||||
{ method: 'POST', idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt den Gesundheitszustand eines Agenten. */
|
|
||||||
export async function getAgentHealth(
|
|
||||||
agentIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<{ status: string; message?: string; last_heartbeat_at?: string }> {
|
|
||||||
return requestApi(
|
|
||||||
`/agents/${encodeURIComponent(agentIdentifier)}/health`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
357
apps/web/src/features/inventory/InventoryPages.tsx
Normal file
357
apps/web/src/features/inventory/InventoryPages.tsx
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* Bestandsseiten: Repositories, Agenten, Wiederherstellungen, Ereignisse.
|
||||||
|
*
|
||||||
|
* Vier Tabellen mit demselben Aufbau. Sie stehen zusammen, weil sie sich nur in
|
||||||
|
* Spalten und Ladefunktion unterscheiden — vier Dateien mit derselben Struktur
|
||||||
|
* waeren vier Orte, an denen die Fehlerbehandlung auseinanderlaufen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
import {
|
||||||
|
fetchAgents,
|
||||||
|
fetchAuditEvents,
|
||||||
|
fetchRepositories,
|
||||||
|
fetchRestores,
|
||||||
|
} from './inventoryApi';
|
||||||
|
import type { Agent, AuditEvent, Repository, RestoreJob } from './inventoryApi';
|
||||||
|
|
||||||
|
/** Zeigt die Repositories. */
|
||||||
|
export function RepositoriesPage(): React.JSX.Element {
|
||||||
|
const loadRepositories = useCallback((abortSignal: AbortSignal) => fetchRepositories(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly Repository[]>(loadRepositories);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Repositories"
|
||||||
|
what="Die Repositories"
|
||||||
|
emptyMessage="Es ist kein Repository eingerichtet. Ohne Repository kann nichts gesichert werden."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Name', 'Ablage', 'Zustand', 'Loeschschutz']}
|
||||||
|
>
|
||||||
|
{data?.map((repository) => (
|
||||||
|
<tr key={repository.id}>
|
||||||
|
<td>
|
||||||
|
{repository.name}
|
||||||
|
<span className="data-table__secondary">{repository.location}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{repository.repository_type}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={repository.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<EnforcementCell repository={repository} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Loeschschutzanzeige. */
|
||||||
|
interface EnforcementCellProperties {
|
||||||
|
/** Das dargestellte Repository. */
|
||||||
|
readonly repository: Repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt den gemessenen Loeschschutz eines Repositorys.
|
||||||
|
*
|
||||||
|
* Der Unterschied zwischen „nie gemessen" und „nur diese Software" ist der Kern
|
||||||
|
* von Phase 11: Beides bedeutet, dass sich niemand auf den Schutz verlassen
|
||||||
|
* sollte — aber aus verschiedenen Gruenden.
|
||||||
|
*/
|
||||||
|
function EnforcementCell({ repository }: EnforcementCellProperties): React.JSX.Element {
|
||||||
|
if (repository.enforcement_level === undefined || repository.enforcement_level === '') {
|
||||||
|
return (
|
||||||
|
<span className="data-table__unknown" title="Es wurde nie geprueft, was dieser Speicher verhindert.">
|
||||||
|
{repository.hardened ? 'gehaertet, ungemessen' : 'nicht gemessen'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (repository.enforcement_level) {
|
||||||
|
case 'filesystem':
|
||||||
|
return (
|
||||||
|
<span className="badge badge--healthy" title="Das Dateisystem verweigert die Loeschung.">
|
||||||
|
Dateisystem
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'storage':
|
||||||
|
return <span className="badge badge--healthy">Speicherebene</span>;
|
||||||
|
case 'advisory':
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="badge badge--warning"
|
||||||
|
title="Nur diese Software haelt sich daran. Wer Dateizugriff hat, kann loeschen."
|
||||||
|
>
|
||||||
|
nur Software
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="badge badge--critical">kein Schutz</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Agenten. */
|
||||||
|
export function AgentsPage(): React.JSX.Element {
|
||||||
|
const loadAgents = useCallback((abortSignal: AbortSignal) => fetchAgents(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly Agent[]>(loadAgents);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Agenten"
|
||||||
|
what="Die Agenten"
|
||||||
|
emptyMessage="Es ist kein Agent aufgenommen."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Name', 'System', 'Version', 'Zustand', 'Zuletzt gesehen']}
|
||||||
|
>
|
||||||
|
{data?.map((agent) => (
|
||||||
|
<tr key={agent.id}>
|
||||||
|
<td>{agent.name}</td>
|
||||||
|
<td>{agent.operating_system ?? '—'}</td>
|
||||||
|
<td>{agent.agent_version ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
</td>
|
||||||
|
<td>{formatTimestamp(agent.last_seen_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Wiederherstellungen. */
|
||||||
|
export function RestoresPage(): React.JSX.Element {
|
||||||
|
const loadRestores = useCallback((abortSignal: AbortSignal) => fetchRestores(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly RestoreJob[]>(loadRestores);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Wiederherstellungen"
|
||||||
|
what="Die Wiederherstellungen"
|
||||||
|
emptyMessage="Es wurde noch keine Wiederherstellung angefordert."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Ziel', 'Zustand', 'Objekte', 'Datenmenge', 'Ende']}
|
||||||
|
>
|
||||||
|
{data?.map((restoreJob) => (
|
||||||
|
<tr key={restoreJob.id}>
|
||||||
|
<td>
|
||||||
|
{restoreJob.target_path}
|
||||||
|
{restoreJob.overwrite_existing && (
|
||||||
|
<span className="data-table__secondary">ueberschreibend</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={restoreJob.status} />
|
||||||
|
{restoreJob.error_message !== undefined && restoreJob.error_message !== '' && (
|
||||||
|
<span className="data-table__secondary">{restoreJob.error_message}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">
|
||||||
|
{restoreJob.files_restored.toLocaleString('de-DE')}
|
||||||
|
{/* Uebergangene Objekte machen einen Lauf zum Teilfehler. Sie
|
||||||
|
stehen deshalb neben der Zahl, nicht in einem Detailfenster. */}
|
||||||
|
{restoreJob.files_skipped > 0 && (
|
||||||
|
<span className="data-table__secondary data-table__secondary--warning">
|
||||||
|
{restoreJob.files_skipped} uebergangen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">{formatBytes(restoreJob.bytes_restored)}</td>
|
||||||
|
<td>{formatTimestamp(restoreJob.completed_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt das Auditprotokoll. */
|
||||||
|
export function EventsPage(): React.JSX.Element {
|
||||||
|
const loadEvents = useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly AuditEvent[]>(loadEvents);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Ereignisse"
|
||||||
|
what="Die Ereignisse"
|
||||||
|
emptyMessage="Das Auditprotokoll ist leer."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Zeitpunkt', 'Handlung', 'Benutzer', 'Gegenstand', 'Ausgang']}
|
||||||
|
>
|
||||||
|
{data?.map((auditEvent) => (
|
||||||
|
<tr key={auditEvent.id}>
|
||||||
|
<td>{formatTimestamp(auditEvent.created_at)}</td>
|
||||||
|
<td className="data-table__code">{auditEvent.action}</td>
|
||||||
|
<td>{auditEvent.actor_username ?? 'System'}</td>
|
||||||
|
<td>{auditEvent.entity_type ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={auditEvent.result} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Tabellenseite. */
|
||||||
|
interface TablePageProperties {
|
||||||
|
/** Ueberschrift der Seite. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Was geladen wird, fuer die Ladeanzeige. */
|
||||||
|
readonly what: string;
|
||||||
|
/** Text bei leerer Liste. */
|
||||||
|
readonly emptyMessage: string;
|
||||||
|
/** Ladezustand. */
|
||||||
|
readonly loadState: 'loading' | 'loaded' | 'failed';
|
||||||
|
/** Aufgetretener Fehler. */
|
||||||
|
readonly loadError: import('../../api/client').ApiError | null;
|
||||||
|
/** Laedt erneut. */
|
||||||
|
readonly onRetry: () => void;
|
||||||
|
/** Zahl der geladenen Eintraege. */
|
||||||
|
readonly itemCount: number | undefined;
|
||||||
|
/** Spaltenueberschriften. */
|
||||||
|
readonly columns: readonly string[];
|
||||||
|
/** Die Tabellenzeilen. */
|
||||||
|
readonly children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine Seite mit einer Tabelle. */
|
||||||
|
function TablePage({
|
||||||
|
title,
|
||||||
|
what,
|
||||||
|
emptyMessage,
|
||||||
|
loadState,
|
||||||
|
loadError,
|
||||||
|
onRetry,
|
||||||
|
itemCount,
|
||||||
|
columns,
|
||||||
|
children,
|
||||||
|
}: TablePageProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">{title}</h1>
|
||||||
|
{itemCount !== undefined && <span className="page__meta">{itemCount} Eintraege</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what={what} />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={onRetry} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && itemCount === 0 && <EmptyState message={emptyMessage} />}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && itemCount !== undefined && itemCount > 0 && (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((columnLabel) => (
|
||||||
|
<th scope="col" key={columnLabel}>
|
||||||
|
{columnLabel}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Statusanzeige. */
|
||||||
|
interface StatusBadgeProperties {
|
||||||
|
/** Der darzustellende Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen Zustand mit semantischer Farbe.
|
||||||
|
*
|
||||||
|
* Farbe ausschliesslich fuer Status, nie zur Dekoration (PROMPT.md §28). Ein
|
||||||
|
* unbekannter Zustand bekommt deshalb keine Farbe — und nicht etwa gruen.
|
||||||
|
*/
|
||||||
|
function StatusBadge({ status }: StatusBadgeProperties): React.JSX.Element {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
case 'succeeded':
|
||||||
|
case 'success':
|
||||||
|
case 'completed':
|
||||||
|
return <span className="badge badge--healthy">{status}</span>;
|
||||||
|
case 'degraded':
|
||||||
|
case 'partial_failure':
|
||||||
|
case 'paused':
|
||||||
|
return <span className="badge badge--high">{status}</span>;
|
||||||
|
case 'offline':
|
||||||
|
case 'failed':
|
||||||
|
case 'failure':
|
||||||
|
case 'denied':
|
||||||
|
case 'revoked':
|
||||||
|
return <span className="badge badge--critical">{status}</span>;
|
||||||
|
case 'running':
|
||||||
|
case 'queued':
|
||||||
|
return <span className="badge badge--info">{status}</span>;
|
||||||
|
default:
|
||||||
|
return <span className="badge badge--neutral">{status}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||||
|
const BYTE_UNIT_STEP = 1024;
|
||||||
|
|
||||||
|
/** Schreibt eine Datenmenge lesbar. */
|
||||||
|
function formatBytes(byteCount: number): string {
|
||||||
|
if (byteCount < BYTE_UNIT_STEP) {
|
||||||
|
return `${byteCount} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let remainingValue = byteCount;
|
||||||
|
let chosenUnit = unitNames[0];
|
||||||
|
|
||||||
|
for (const unitName of unitNames) {
|
||||||
|
remainingValue /= BYTE_UNIT_STEP;
|
||||||
|
chosenUnit = unitName;
|
||||||
|
|
||||||
|
if (remainingValue < BYTE_UNIT_STEP) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
|
||||||
|
}
|
||||||
113
apps/web/src/features/inventory/inventoryApi.ts
Normal file
113
apps/web/src/features/inventory/inventoryApi.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Zugriff auf Repositories, Agenten, Wiederherstellungen und Ereignisse.
|
||||||
|
*
|
||||||
|
* Vier kleine Bestandslisten in einer Datei: Jede besteht aus einem Typ und
|
||||||
|
* einem Aufruf. Vier Dateien mit je zwanzig Zeilen brachten hier keine bessere
|
||||||
|
* Ordnung, nur mehr Wege.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
|
||||||
|
/** Ein Repository der Anlage. */
|
||||||
|
export interface Repository {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Sprechende Bezeichnung. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Ablageart. */
|
||||||
|
readonly repository_type: string;
|
||||||
|
/** Pfad oder Adresse. */
|
||||||
|
readonly location: string;
|
||||||
|
/** Betriebszustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Meldet den gehaerteten Modus. */
|
||||||
|
readonly hardened: boolean;
|
||||||
|
/** Zuletzt gemessene Durchsetzungsstufe des Loeschschutzes. */
|
||||||
|
readonly enforcement_level?: string;
|
||||||
|
/** Zeitpunkt der Messung in UTC. */
|
||||||
|
readonly enforcement_measured_at?: string;
|
||||||
|
/** Meldet, ob das Repository Sicherungen annimmt. */
|
||||||
|
readonly accepts_backups?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein aufgenommener Agent. */
|
||||||
|
export interface Agent {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Sprechender Name. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Betriebssystem. */
|
||||||
|
readonly operating_system?: string;
|
||||||
|
/** Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Programmversion. */
|
||||||
|
readonly agent_version?: string;
|
||||||
|
/** Letzte Lebendmeldung in UTC. */
|
||||||
|
readonly last_seen_at?: string;
|
||||||
|
/** Aufnahmezeitpunkt in UTC. */
|
||||||
|
readonly enrolled_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Wiederherstellungsauftrag. */
|
||||||
|
export interface RestoreJob {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Wiederherzustellendes Backup. */
|
||||||
|
readonly backup_id: string;
|
||||||
|
/** Zielverzeichnis. */
|
||||||
|
readonly target_path: string;
|
||||||
|
/** Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Meldet das Ueberschreiben vorhandener Daten. */
|
||||||
|
readonly overwrite_existing: boolean;
|
||||||
|
/** Beginn in UTC. */
|
||||||
|
readonly started_at?: string;
|
||||||
|
/** Ende in UTC. */
|
||||||
|
readonly completed_at?: string;
|
||||||
|
/** Zurueckgeschriebene Datenmenge. */
|
||||||
|
readonly bytes_restored: number;
|
||||||
|
/** Zahl zurueckgeschriebener Objekte. */
|
||||||
|
readonly files_restored: number;
|
||||||
|
/** Zahl uebergangener Objekte. */
|
||||||
|
readonly files_skipped: number;
|
||||||
|
/** Verstaendliche Fehlermeldung. */
|
||||||
|
readonly error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Eintrag des Auditprotokolls. */
|
||||||
|
export interface AuditEvent {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Protokollierte Handlung. */
|
||||||
|
readonly action: string;
|
||||||
|
/** Anmeldename des Handelnden. */
|
||||||
|
readonly actor_username?: string;
|
||||||
|
/** Art des betroffenen Gegenstands. */
|
||||||
|
readonly entity_type?: string;
|
||||||
|
/** Ausgang der Handlung. */
|
||||||
|
readonly result: string;
|
||||||
|
/** Absenderadresse. */
|
||||||
|
readonly ip_address?: string;
|
||||||
|
/** Zeitpunkt in UTC. */
|
||||||
|
readonly created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt alle Repositories. */
|
||||||
|
export async function fetchRepositories(abortSignal?: AbortSignal): Promise<readonly Repository[]> {
|
||||||
|
return requestApi<readonly Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt alle Agenten. */
|
||||||
|
export async function fetchAgents(abortSignal?: AbortSignal): Promise<readonly Agent[]> {
|
||||||
|
return requestApi<readonly Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die Wiederherstellungsauftraege. */
|
||||||
|
export async function fetchRestores(abortSignal?: AbortSignal): Promise<readonly RestoreJob[]> {
|
||||||
|
return requestApi<readonly RestoreJob[]>('/restores?page_size=50', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die juengsten Auditereignisse. */
|
||||||
|
export async function fetchAuditEvents(abortSignal?: AbortSignal): Promise<readonly AuditEvent[]> {
|
||||||
|
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=50', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
324
apps/web/src/features/jobs/BackupWizard.css
Normal file
324
apps/web/src/features/jobs/BackupWizard.css
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* Darstellung des Backup-Assistenten.
|
||||||
|
*
|
||||||
|
* Semantische Farben stehen ausschliesslich fuer Zustand (PROMPT.md §28):
|
||||||
|
* Rot fuer Fehler, Gelb fuer nicht verfuegbare Faehigkeiten, Blau fuer
|
||||||
|
* Hinweise. Alles Uebrige bleibt neutral - Farbe als Schmuck macht die
|
||||||
|
* Statusfarben bedeutungslos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.wizard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14rem 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf schmalen Fenstern liegt die Schrittleiste ueber dem Inhalt statt daneben. */
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.wizard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
|
||||||
|
Prüfung überspringen, die der Assistent gerade führen soll. */
|
||||||
|
.wizard__step:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step--current {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step--done {
|
||||||
|
color: var(--color-status-healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: var(--border-width) solid currentColor;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__panel {
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__heading {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__input--area {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__input--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__preview {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-info);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__checkbox,
|
||||||
|
.wizard__radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-detail {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-warning {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein nicht umgesetzter Schritt wird als solcher gekennzeichnet, statt Werte
|
||||||
|
abzufragen, die niemand auswertet (PROMPT.md §138). */
|
||||||
|
.wizard__unavailable {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-left: 3px solid var(--color-status-warning);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__unavailable-badge {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__unavailable-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__notice {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-healthy);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__problems {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12rem 1fr;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary dt {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Was nicht eingerichtet ist, wird in der Uebersicht als solches benannt.
|
||||||
|
Eine leere Zeile liesse offen, ob nichts eingestellt oder nichts moeglich ist. */
|
||||||
|
.wizard__summary-unavailable {
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary-detail {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: var(--border-width) solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__navigation {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button--primary {
|
||||||
|
border-color: var(--color-text-primary);
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
color: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button--small {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Tests des Backup-Assistenten.
|
* Tests des Backup-Assistenten.
|
||||||
*
|
*
|
||||||
* Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte
|
* Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte
|
||||||
* aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine
|
* aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine
|
||||||
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
|
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -40,15 +40,15 @@ beforeEach(() => {
|
|||||||
vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]);
|
vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Führt den Assistenten bis zu einem Schritt und füllt das Nötige aus. */
|
/** Fuehrt den Assistenten bis zu einem Schritt und fuellt das Noetige aus. */
|
||||||
async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> {
|
async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> {
|
||||||
await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung');
|
||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
// Zeitplan: der Standard "täglich 02:00" genügt.
|
// Zeitplan: der Standard "taeglich 02:00" genuegt.
|
||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ describe('Backup-Assistent', () => {
|
|||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
// Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand
|
// Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand
|
||||||
// erst nach dem Test, und React meldet eine Aktualisierung außerhalb von act().
|
// erst nach dem Test, und React meldet eine Aktualisierung ausserhalb von act().
|
||||||
await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled());
|
await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled());
|
||||||
|
|
||||||
for (const stepTitle of [
|
for (const stepTitle of [
|
||||||
@ -67,9 +67,9 @@ describe('Backup-Assistent', () => {
|
|||||||
'Repository',
|
'Repository',
|
||||||
'Aufbewahrung',
|
'Aufbewahrung',
|
||||||
'Sicherheit',
|
'Sicherheit',
|
||||||
'Prüfung',
|
'Pruefung',
|
||||||
'Benachrichtigung',
|
'Benachrichtigung',
|
||||||
'Übersicht',
|
'Uebersicht',
|
||||||
'Anlegen',
|
'Anlegen',
|
||||||
]) {
|
]) {
|
||||||
expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument();
|
||||||
@ -77,8 +77,8 @@ describe('Backup-Assistent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an
|
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an
|
||||||
// zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
|
// zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
|
||||||
it('hält bei einem leeren Namen auf und erklärt warum', async () => {
|
it('haelt bei einem leeren Namen auf und erklaert warum', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ describe('Backup-Assistent', () => {
|
|||||||
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
|
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hält ohne Quelle auf', async () => {
|
it('haelt ohne Quelle auf', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
@ -104,17 +104,17 @@ describe('Backup-Assistent', () => {
|
|||||||
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
|
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre
|
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst waere
|
||||||
// jeder Blick zurück ein Datenverlust.
|
// jeder Blick zurueck ein Datenverlust.
|
||||||
it('bewahrt die Eingaben beim Zurückgehen', async () => {
|
it('bewahrt die Eingaben beim Zurueckgehen', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung');
|
||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
await user.click(screen.getByRole('button', { name: 'Zurück' }));
|
await user.click(screen.getByRole('button', { name: 'Zurueck' }));
|
||||||
|
|
||||||
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Nächtliche Sicherung');
|
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Naechtliche Sicherung');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt eine Vorschau des Zeitplans', async () => {
|
it('zeigt eine Vorschau des Zeitplans', async () => {
|
||||||
@ -126,12 +126,12 @@ describe('Backup-Assistent', () => {
|
|||||||
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
expect(screen.getByText(/Ergibt: täglich um 02:00 Uhr/)).toBeInTheDocument();
|
expect(screen.getByText(/Ergibt: taeglich um 02:00 Uhr/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend
|
// Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend
|
||||||
// weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet.
|
// weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet.
|
||||||
it('zeigt gesperrte Repositories, lässt sie aber nicht wählen', async () => {
|
it('zeigt gesperrte Repositories, laesst sie aber nicht waehlen', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
@ -148,7 +148,7 @@ describe('Backup-Assistent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Eine Maske, die Werte sammelt, die niemand auswertet, ist ein
|
// Eine Maske, die Werte sammelt, die niemand auswertet, ist ein
|
||||||
// vorgetäuschtes Funktionsversprechen (PROMPT.md §138).
|
// vorgetaeuschtes Funktionsversprechen (PROMPT.md §138).
|
||||||
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
|
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
@ -160,15 +160,15 @@ describe('Backup-Assistent', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
// Schritt 5: Aufbewahrung.
|
// Schritt 5: Aufbewahrung.
|
||||||
expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument();
|
expect(screen.getByText('Noch nicht verfuegbar')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument();
|
expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('legt den Auftrag an und meldet ihn nach oben', async () => {
|
it('legt den Auftrag an und meldet ihn nach oben', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const createdJob = { id: 'abc', name: 'Nächtliche Sicherung' } as jobsApi.BackupJob;
|
const createdJob = { id: 'abc', name: 'Naechtliche Sicherung' } as jobsApi.BackupJob;
|
||||||
const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob);
|
const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob);
|
||||||
const handleCreated = vi.fn();
|
const handleCreated = vi.fn();
|
||||||
|
|
||||||
@ -190,20 +190,20 @@ describe('Backup-Assistent', () => {
|
|||||||
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
|
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
|
||||||
|
|
||||||
const sentRequest = createSpy.mock.calls[0]?.[0];
|
const sentRequest = createSpy.mock.calls[0]?.[0];
|
||||||
expect(sentRequest?.name).toBe('Nächtliche Sicherung');
|
expect(sentRequest?.name).toBe('Naechtliche Sicherung');
|
||||||
expect(sentRequest?.sources[0]?.id).toBe('/daten');
|
expect(sentRequest?.sources[0]?.id).toBe('/daten');
|
||||||
expect(sentRequest?.repository_id).toBe(writableRepository.id);
|
expect(sentRequest?.repository_id).toBe(writableRepository.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
|
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
|
||||||
// eigentlichen Grund, den die Oberfläche nicht kennen kann.
|
// eigentlichen Grund, den die Oberflaeche nicht kennen kann.
|
||||||
it('zeigt eine Fehlermeldung des Servers', async () => {
|
it('zeigt eine Fehlermeldung des Servers', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
|
vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
|
||||||
new ApiError({
|
new ApiError({
|
||||||
code: 'VALIDATION_FAILED',
|
code: 'VALIDATION_FAILED',
|
||||||
message: 'der zeitplan läuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten',
|
message: 'der zeitplan laeuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten',
|
||||||
statusCode: 422,
|
statusCode: 422,
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
}),
|
}),
|
||||||
@ -225,8 +225,8 @@ describe('Backup-Assistent', () => {
|
|||||||
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
|
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Der Assistent bleibt stehen, damit der Anwender zurückgehen und den
|
// Der Assistent bleibt stehen, damit der Anwender zurueckgehen und den
|
||||||
// Zeitplan ändern kann.
|
// Zeitplan aendern kann.
|
||||||
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
|
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -251,15 +251,15 @@ describe('Backup-Assistent', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erlaubt den Sprung zurück über die Schrittleiste, nicht nach vorn', async () => {
|
it('erlaubt den Sprung zurueck ueber die Schrittleiste, nicht nach vorn', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
||||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine
|
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
|
||||||
// Prüfung überspringen, die der Assistent gerade führen soll.
|
// Pruefung ueberspringen, die der Assistent gerade fuehren soll.
|
||||||
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /1\s*Name/ }));
|
await user.click(screen.getByRole('button', { name: /1\s*Name/ }));
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10).
|
* Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10).
|
||||||
*
|
*
|
||||||
* Der Assistent führt von Name bis Anlegen. Die Prüfung geschieht je Schritt,
|
* Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt,
|
||||||
* damit ein Fehler dort auffällt, wo er entsteht - nicht erst nach dem letzten
|
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten
|
||||||
* Schritt. Rückwärts geht es ohne Datenverlust: Der Entwurf lebt in einem
|
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem
|
||||||
* Zustand, nicht in den Eingabefeldern.
|
* Zustand, nicht in den Eingabefeldern.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi';
|
import { createJob, listRepositories } from './jobsApi';
|
||||||
import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
|
import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
|
||||||
import {
|
import {
|
||||||
buildCreateRequest,
|
buildCreateRequest,
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
WIZARD_STEPS,
|
WIZARD_STEPS,
|
||||||
} from './wizardModel';
|
} from './wizardModel';
|
||||||
import type { DraftSource, JobDraft, WizardStep } from './wizardModel';
|
import type { DraftSource, JobDraft, WizardStep } from './wizardModel';
|
||||||
|
import './BackupWizard.css';
|
||||||
|
|
||||||
/** Eigenschaften des Assistenten. */
|
/** Eigenschaften des Assistenten. */
|
||||||
export interface BackupWizardProps {
|
export interface BackupWizardProps {
|
||||||
@ -58,7 +59,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
const stepValidation = validateStep(currentStep, jobDraft);
|
const stepValidation = validateStep(currentStep, jobDraft);
|
||||||
|
|
||||||
// Die Ziele werden einmal geladen. Ein Abbruchsignal verhindert, dass eine
|
// Die Ziele werden einmal geladen. Ein Abbruchsignal verhindert, dass eine
|
||||||
// Antwort nach dem Schließen des Assistenten noch Zustand setzt.
|
// Antwort nach dem Schliessen des Assistenten noch Zustand setzt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
@ -75,17 +76,17 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Ändert ein Feld des Entwurfs. */
|
/** Aendert ein Feld des Entwurfs. */
|
||||||
function updateDraft(changedFields: Partial<JobDraft>): void {
|
function updateDraft(changedFields: Partial<JobDraft>): void {
|
||||||
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
|
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
|
||||||
setShowProblems(false);
|
setShowProblems(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Geht einen Schritt vor, sofern der aktuelle vollständig ist. */
|
/** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */
|
||||||
function goToNextStep(): void {
|
function goToNextStep(): void {
|
||||||
if (!stepValidation.isComplete) {
|
if (!stepValidation.isComplete) {
|
||||||
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang
|
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang
|
||||||
// an zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
|
// an zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
|
||||||
setShowProblems(true);
|
setShowProblems(true);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -95,7 +96,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
|
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Geht einen Schritt zurück. */
|
/** Geht einen Schritt zurueck. */
|
||||||
function goToPreviousStep(): void {
|
function goToPreviousStep(): void {
|
||||||
setShowProblems(false);
|
setShowProblems(false);
|
||||||
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
|
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
|
||||||
@ -131,7 +132,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="wizard" aria-label="Backup-Assistent">
|
<section className="wizard" aria-label="Backup-Assistent">
|
||||||
<ol className="flex flex-wrap items-center gap-1 border-b border-line px-5 py-3">
|
<ol className="wizard__steps">
|
||||||
{WIZARD_STEPS.map((wizardStep, stepIndex) => (
|
{WIZARD_STEPS.map((wizardStep, stepIndex) => (
|
||||||
<li key={wizardStep}>
|
<li key={wizardStep}>
|
||||||
<button
|
<button
|
||||||
@ -141,48 +142,48 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
disabled={stepIndex > currentStepIndex}
|
disabled={stepIndex > currentStepIndex}
|
||||||
aria-current={stepIndex === currentStepIndex ? 'step' : undefined}
|
aria-current={stepIndex === currentStepIndex ? 'step' : undefined}
|
||||||
>
|
>
|
||||||
<span className="tabular">{stepIndex + 1}</span>
|
<span className="wizard__step-number">{stepIndex + 1}</span>
|
||||||
<span className="">{STEP_TITLES[wizardStep]}</span>
|
<span className="wizard__step-title">{STEP_TITLES[wizardStep]}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div className="px-5 py-4">
|
<div className="wizard__panel">
|
||||||
<h2 className="text-base font-semibold text-fg">
|
<h2 className="wizard__heading">
|
||||||
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
|
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{renderStepContent()}
|
{renderStepContent()}
|
||||||
|
|
||||||
{showProblems && stepValidation.problems.length > 0 && (
|
{showProblems && stepValidation.problems.length > 0 && (
|
||||||
<ul className="mt-3 space-y-1 rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
|
<ul className="wizard__problems" role="alert">
|
||||||
{stepValidation.problems.map((problemText) => (
|
{stepValidation.problems.map((problemText) => (
|
||||||
<li key={problemText}>{problemText}</li>
|
<li key={problemText}>{problemText}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-5 py-3">
|
<div className="wizard__actions">
|
||||||
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={onCancel}>
|
<button type="button" className="wizard__button" onClick={onCancel}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="wizard__navigation">
|
||||||
{currentStepIndex > 0 && (
|
{currentStepIndex > 0 && (
|
||||||
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={goToPreviousStep}>
|
<button type="button" className="wizard__button" onClick={goToPreviousStep}>
|
||||||
Zurück
|
Zurueck
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep !== 'create' ? (
|
{currentStep !== 'create' ? (
|
||||||
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 bg-accent text-accent-fg hover:bg-accent-hover" onClick={goToNextStep}>
|
<button type="button" className="wizard__button wizard__button--primary" onClick={goToNextStep}>
|
||||||
Weiter
|
Weiter
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 bg-accent text-accent-fg hover:bg-accent-hover"
|
className="wizard__button wizard__button--primary"
|
||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@ -195,7 +196,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Wählt den Inhalt des aktuellen Schrittes. */
|
/** Waehlt den Inhalt des aktuellen Schrittes. */
|
||||||
function renderStepContent(): React.JSX.Element {
|
function renderStepContent(): React.JSX.Element {
|
||||||
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
|
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
|
||||||
|
|
||||||
@ -227,15 +228,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
* Zeigt einen Schritt ohne Umsetzung im Backend.
|
* Zeigt einen Schritt ohne Umsetzung im Backend.
|
||||||
*
|
*
|
||||||
* Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die
|
* Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die
|
||||||
* niemand auswertet, ist ein vorgetäuschtes Funktionsversprechen
|
* niemand auswertet, ist ein vorgetaeuschtes Funktionsversprechen
|
||||||
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
|
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
|
||||||
* tatsächlich geschieht.
|
* tatsaechlich geschieht.
|
||||||
*/
|
*/
|
||||||
function renderUnavailableStep(explanationText: string): React.JSX.Element {
|
function renderUnavailableStep(explanationText: string): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-line bg-sunken p-4">
|
<div className="wizard__unavailable">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfügbar</p>
|
<p className="wizard__unavailable-badge">Noch nicht verfuegbar</p>
|
||||||
<p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
|
<p className="wizard__unavailable-text">{explanationText}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -243,23 +244,23 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
/** Schritt 1: Name und Beschreibung. */
|
/** Schritt 1: Name und Beschreibung. */
|
||||||
function renderNameStep(): React.JSX.Element {
|
function renderNameStep(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="wizard__fields">
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Name des Auftrags</span>
|
<span className="wizard__label">Name des Auftrags</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={jobDraft.name}
|
value={jobDraft.name}
|
||||||
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
|
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
|
||||||
placeholder="Nächtliche Sicherung Dateiserver"
|
placeholder="Naechtliche Sicherung Dateiserver"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Beschreibung (optional)</span>
|
<span className="wizard__label">Beschreibung (optional)</span>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle min-h-20 resize-y"
|
className="wizard__input wizard__input--area"
|
||||||
value={jobDraft.description}
|
value={jobDraft.description}
|
||||||
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
|
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
@ -271,7 +272,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
|
|
||||||
/** Schritt 2: Quellen. */
|
/** Schritt 2: Quellen. */
|
||||||
function renderSourceStep(): React.JSX.Element {
|
function renderSourceStep(): React.JSX.Element {
|
||||||
/** Ändert eine einzelne Quelle. */
|
/** Aendert eine einzelne Quelle. */
|
||||||
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
|
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
|
||||||
updateDraft({
|
updateDraft({
|
||||||
sources: jobDraft.sources.map((existingSource, currentIndex) =>
|
sources: jobDraft.sources.map((existingSource, currentIndex) =>
|
||||||
@ -281,15 +282,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="wizard__fields">
|
||||||
{jobDraft.sources.map((draftSource, sourceIndex) => (
|
{jobDraft.sources.map((draftSource, sourceIndex) => (
|
||||||
<fieldset key={sourceIndex} className="flex items-center justify-between gap-2 rounded-md border border-line px-3 py-2 text-sm">
|
<fieldset key={sourceIndex} className="wizard__source">
|
||||||
<legend className="block text-sm font-medium text-fg">Quelle {sourceIndex + 1}</legend>
|
<legend className="wizard__label">Quelle {sourceIndex + 1}</legend>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Art</span>
|
<span className="wizard__label">Art</span>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
value={draftSource.sourceType}
|
value={draftSource.sourceType}
|
||||||
onChange={(changeEvent) =>
|
onChange={(changeEvent) =>
|
||||||
updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType })
|
updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType })
|
||||||
@ -302,16 +303,16 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
|
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
|
||||||
>
|
>
|
||||||
{SOURCE_TYPE_LABELS[sourceType]}
|
{SOURCE_TYPE_LABELS[sourceType]}
|
||||||
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfügbar)' : ''}
|
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Pfad</span>
|
<span className="wizard__label">Pfad</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={draftSource.sourceIdentifier}
|
value={draftSource.sourceIdentifier}
|
||||||
onChange={(changeEvent) =>
|
onChange={(changeEvent) =>
|
||||||
@ -321,20 +322,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Bezeichnung (optional)</span>
|
<span className="wizard__label">Bezeichnung (optional)</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={draftSource.sourceName}
|
value={draftSource.sourceName}
|
||||||
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
|
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Auszuschließen (kommasepariert, optional)</span>
|
<span className="wizard__label">Auszuschliessen (kommasepariert, optional)</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={draftSource.excludePatterns}
|
value={draftSource.excludePatterns}
|
||||||
onChange={(changeEvent) =>
|
onChange={(changeEvent) =>
|
||||||
@ -347,7 +348,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
{jobDraft.sources.length > 1 && (
|
{jobDraft.sources.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs"
|
className="wizard__button wizard__button--small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateDraft({
|
updateDraft({
|
||||||
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
|
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
|
||||||
@ -362,7 +363,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs"
|
className="wizard__button wizard__button--small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateDraft({
|
updateDraft({
|
||||||
sources: [
|
sources: [
|
||||||
@ -381,31 +382,31 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
/** Schritt 3: Zeitplan. */
|
/** Schritt 3: Zeitplan. */
|
||||||
function renderScheduleStep(): React.JSX.Element {
|
function renderScheduleStep(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="wizard__fields">
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Art des Zeitplans</span>
|
<span className="wizard__label">Art des Zeitplans</span>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
value={jobDraft.scheduleType}
|
value={jobDraft.scheduleType}
|
||||||
onChange={(changeEvent) =>
|
onChange={(changeEvent) =>
|
||||||
updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] })
|
updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="manual">Nur auf Anforderung</option>
|
<option value="manual">Nur auf Anforderung</option>
|
||||||
<option value="interval">In festen Abständen</option>
|
<option value="interval">In festen Abstaenden</option>
|
||||||
<option value="hourly">Stündlich</option>
|
<option value="hourly">Stuendlich</option>
|
||||||
<option value="daily">Täglich</option>
|
<option value="daily">Taeglich</option>
|
||||||
<option value="weekly">Wöchentlich</option>
|
<option value="weekly">Woechentlich</option>
|
||||||
<option value="monthly">Monatlich</option>
|
<option value="monthly">Monatlich</option>
|
||||||
<option value="cron">Cron-Ausdruck</option>
|
<option value="cron">Cron-Ausdruck</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{jobDraft.scheduleType === 'interval' && (
|
{jobDraft.scheduleType === 'interval' && (
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Abstand in Stunden</span>
|
<span className="wizard__label">Abstand in Stunden</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={jobDraft.scheduleIntervalHours}
|
value={jobDraft.scheduleIntervalHours}
|
||||||
@ -418,12 +419,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
|
|
||||||
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' &&
|
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' &&
|
||||||
jobDraft.scheduleType !== 'cron' && (
|
jobDraft.scheduleType !== 'cron' && (
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">
|
<span className="wizard__label">
|
||||||
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
|
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="time"
|
type="time"
|
||||||
value={jobDraft.scheduleTime}
|
value={jobDraft.scheduleTime}
|
||||||
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
|
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
|
||||||
@ -432,11 +433,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{jobDraft.scheduleType === 'weekly' && (
|
{jobDraft.scheduleType === 'weekly' && (
|
||||||
<fieldset className="block space-y-1.5">
|
<fieldset className="wizard__field">
|
||||||
<legend className="block text-sm font-medium text-fg">Wochentage</legend>
|
<legend className="wizard__label">Wochentage</legend>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="wizard__checkbox-row">
|
||||||
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
|
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
|
||||||
<label key={weekdayName} className="flex items-center gap-2 text-sm text-fg">
|
<label key={weekdayName} className="wizard__checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
|
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
|
||||||
@ -456,11 +457,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{jobDraft.scheduleType === 'monthly' && (
|
{jobDraft.scheduleType === 'monthly' && (
|
||||||
<fieldset className="block space-y-1.5">
|
<fieldset className="wizard__field">
|
||||||
<legend className="block text-sm font-medium text-fg">Tage des Monats</legend>
|
<legend className="wizard__label">Tage des Monats</legend>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="wizard__checkbox-row">
|
||||||
{[1, 15, -1].map((monthDay) => (
|
{[1, 15, -1].map((monthDay) => (
|
||||||
<label key={monthDay} className="flex items-center gap-2 text-sm text-fg">
|
<label key={monthDay} className="wizard__checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={jobDraft.scheduleMonthDays.includes(monthDay)}
|
checked={jobDraft.scheduleMonthDays.includes(monthDay)}
|
||||||
@ -476,111 +477,46 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." wählt, bekommt in
|
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in
|
||||||
vier Monaten des Jahres keine Sicherung. */}
|
vier Monaten des Jahres keine Sicherung. */}
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="wizard__hint">
|
||||||
Der letzte Tag des Monats fällt nie aus - anders als etwa der 31.
|
Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{jobDraft.scheduleType === 'cron' && (
|
{jobDraft.scheduleType === 'cron' && (
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Cron-Ausdruck</span>
|
<span className="wizard__label">Cron-Ausdruck</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle font-mono"
|
className="wizard__input wizard__input--mono"
|
||||||
type="text"
|
type="text"
|
||||||
value={jobDraft.scheduleCronExpression}
|
value={jobDraft.scheduleCronExpression}
|
||||||
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
|
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
|
||||||
placeholder="0 2 * * *"
|
placeholder="0 2 * * *"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-fg-muted">Minute Stunde Tag Monat Wochentag</span>
|
<span className="wizard__hint">Minute Stunde Tag Monat Wochentag</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
|
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Zeitzone</span>
|
<span className="wizard__label">Zeitzone</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={jobDraft.scheduleTimeZone}
|
value={jobDraft.scheduleTimeZone}
|
||||||
onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })}
|
onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })}
|
||||||
/>
|
/>
|
||||||
{/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe
|
{/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe
|
||||||
dann je nach Standort zu einer anderen Uhrzeit. */}
|
dann je nach Standort zu einer anderen Uhrzeit. */}
|
||||||
<span className="text-xs text-fg-muted">
|
<span className="wizard__hint">
|
||||||
Ohne Angabe rechnet der Server in UTC.
|
Ohne Angabe rechnet der Server in UTC.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">Ergibt: {describeDraftSchedule(jobDraft)}</p>
|
<p className="wizard__preview">Ergibt: {describeDraftSchedule(jobDraft)}</p>
|
||||||
|
|
||||||
{/* --- Sicherungsart ---
|
|
||||||
Sie gehört zum Zeitplan, nicht zur Quelle: Beides zusammen
|
|
||||||
beantwortet die Frage „was passiert wann". */}
|
|
||||||
<div className="mt-4 space-y-3 border-t border-line pt-4">
|
|
||||||
<p className="text-sm font-medium text-fg">Sicherungsart</p>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-start gap-2.5 border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="backup-mode"
|
|
||||||
className="mt-0.5"
|
|
||||||
checked={jobDraft.backupMode === 'incremental'}
|
|
||||||
onChange={() => updateDraft({ backupMode: 'incremental' })}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block font-medium text-fg">Inkrementell</span>
|
|
||||||
<span className="block text-xs text-fg-muted">
|
|
||||||
Erster Lauf voll, danach nur Geändertes. Empfohlen.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-start gap-2.5 border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="backup-mode"
|
|
||||||
className="mt-0.5"
|
|
||||||
checked={jobDraft.backupMode === 'always_full'}
|
|
||||||
onChange={() => updateDraft({ backupMode: 'always_full', fullBackupWeekday: -1 })}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block font-medium text-fg">Immer voll</span>
|
|
||||||
<span className="block text-xs text-fg-muted">
|
|
||||||
Jeder Lauf liest die gesamte Quelle. Kostet Laufzeit, kaum Platz
|
|
||||||
— unveränderte Blöcke werden dedupliziert.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{jobDraft.backupMode === 'incremental' ? (
|
|
||||||
<label className="block space-y-1.5">
|
|
||||||
<span className="block text-sm font-medium text-fg">
|
|
||||||
Zusätzlich voll sichern an
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg"
|
|
||||||
value={String(jobDraft.fullBackupWeekday)}
|
|
||||||
onChange={(changeEvent) =>
|
|
||||||
updateDraft({ fullBackupWeekday: Number(changeEvent.target.value) })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="-1">Keinem festen Tag</option>
|
|
||||||
{WEEKDAY_LABELS.map((weekdayLabel, weekdayIndex) => (
|
|
||||||
<option key={weekdayLabel} value={String(weekdayIndex)}>
|
|
||||||
{weekdayLabel}s
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
Gerechnet in der Zeitzone des Zeitplans.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -588,22 +524,22 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
/** Schritt 4: Repository. */
|
/** Schritt 4: Repository. */
|
||||||
function renderRepositoryStep(): React.JSX.Element {
|
function renderRepositoryStep(): React.JSX.Element {
|
||||||
if (repositoryError !== null) {
|
if (repositoryError !== null) {
|
||||||
return <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical">{repositoryError}</p>;
|
return <p className="wizard__error">{repositoryError}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repositories.length === 0) {
|
if (repositories.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="wizard__hint">
|
||||||
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datenträger und
|
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und
|
||||||
wird mit <code>syncova-repo create --path <pfad> --name <name></code> angelegt.
|
wird mit <code>syncova-repo create --path <pfad> --name <name></code> angelegt.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="wizard__fields">
|
||||||
{repositories.map((repositoryEntry) => (
|
{repositories.map((repositoryEntry) => (
|
||||||
<label key={repositoryEntry.id} className="flex cursor-pointer items-start gap-2.5 rounded-md border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
|
<label key={repositoryEntry.id} className="wizard__radio">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="repository"
|
name="repository"
|
||||||
@ -613,15 +549,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
|
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="flex min-w-0 flex-col">
|
<span className="wizard__radio-body">
|
||||||
<span className="font-medium text-fg">{repositoryEntry.name}</span>
|
<span className="wizard__radio-title">{repositoryEntry.name}</span>
|
||||||
<span className="font-mono text-xs text-fg-muted">{repositoryEntry.location}</span>
|
<span className="wizard__radio-detail">{repositoryEntry.location}</span>
|
||||||
|
|
||||||
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
|
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
|
||||||
stillschweigend weggelassen: Sonst suchte der Anwender ein
|
stillschweigend weggelassen: Sonst suchte der Anwender ein
|
||||||
Repository, das er sieht und nicht wählen kann. */}
|
Repository, das er sieht und nicht waehlen kann. */}
|
||||||
{!repositoryEntry.accepts_backups && (
|
{!repositoryEntry.accepts_backups && (
|
||||||
<span className="text-xs text-warning">
|
<span className="wizard__radio-warning">
|
||||||
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
|
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -635,20 +571,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
/** Schritt 6: Sicherheit. */
|
/** Schritt 6: Sicherheit. */
|
||||||
function renderSecurityStep(): React.JSX.Element {
|
function renderSecurityStep(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="wizard__fields">
|
||||||
{/* Verschlüsselung ist keine Wahl: Der Executor verweigert den Dienst
|
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
|
||||||
ohne Schlüsselmaterial. Eine Schaltfläche zum Abschalten wäre
|
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
|
||||||
eine Einstellung, die es nicht gibt. */}
|
eine Einstellung, die es nicht gibt. */}
|
||||||
<div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm">
|
<div className="wizard__notice">
|
||||||
<strong>Verschlüsselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
|
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
|
||||||
verschlüsselt, bevor sie das System verlassen. Der Schlüssel gehört zum Repository und
|
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
|
||||||
wird vom Dienst verwaltet.
|
wird vom Dienst verwaltet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Dringlichkeit</span>
|
<span className="wizard__label">Dringlichkeit</span>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
value={jobDraft.priority}
|
value={jobDraft.priority}
|
||||||
onChange={(changeEvent) =>
|
onChange={(changeEvent) =>
|
||||||
updateDraft({ priority: changeEvent.target.value as JobDraft['priority'] })
|
updateDraft({ priority: changeEvent.target.value as JobDraft['priority'] })
|
||||||
@ -659,21 +595,21 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
<option value="normal">Normal</option>
|
<option value="normal">Normal</option>
|
||||||
<option value="low">Niedrig</option>
|
<option value="low">Niedrig</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-fg-muted">
|
<span className="wizard__hint">
|
||||||
Entscheidet die Reihenfolge, wenn mehrere Aufträge gleichzeitig fällig sind.
|
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-1.5">
|
<label className="wizard__field">
|
||||||
<span className="block text-sm font-medium text-fg">Bandbreitengrenze (optional)</span>
|
<span className="wizard__label">Bandbreitengrenze (optional)</span>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
|
className="wizard__input"
|
||||||
type="text"
|
type="text"
|
||||||
value={jobDraft.bandwidthLimit}
|
value={jobDraft.bandwidthLimit}
|
||||||
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
|
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
|
||||||
placeholder="50MB"
|
placeholder="50MB"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-fg-muted">
|
<span className="wizard__hint">
|
||||||
Begrenzt das Lesen von der Quelle. 100Mbit ist ein Achtel von 100MB.
|
Begrenzt das Lesen von der Quelle. 100Mbit ist ein Achtel von 100MB.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@ -681,14 +617,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schritt 9: Übersicht. */
|
/** Schritt 9: Uebersicht. */
|
||||||
function renderReviewStep(): React.JSX.Element {
|
function renderReviewStep(): React.JSX.Element {
|
||||||
const selectedRepository = repositories.find(
|
const selectedRepository = repositories.find(
|
||||||
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
|
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl className="mt-4 space-y-3">
|
<dl className="wizard__summary">
|
||||||
<dt>Name</dt>
|
<dt>Name</dt>
|
||||||
<dd>{jobDraft.name}</dd>
|
<dd>{jobDraft.name}</dd>
|
||||||
|
|
||||||
@ -701,14 +637,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
|
|
||||||
<dt>Quellen</dt>
|
<dt>Quellen</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<ul className="space-y-2">
|
<ul className="wizard__summary-list">
|
||||||
{jobDraft.sources
|
{jobDraft.sources
|
||||||
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
|
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
|
||||||
.map((draftSource, sourceIndex) => (
|
.map((draftSource, sourceIndex) => (
|
||||||
<li key={sourceIndex}>
|
<li key={sourceIndex}>
|
||||||
{draftSource.sourceIdentifier}
|
{draftSource.sourceIdentifier}
|
||||||
{splitPatternList(draftSource.excludePatterns).length > 0 && (
|
{splitPatternList(draftSource.excludePatterns).length > 0 && (
|
||||||
<span className="text-sm text-fg">
|
<span className="wizard__summary-detail">
|
||||||
{' '}
|
{' '}
|
||||||
ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
|
ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
@ -725,19 +661,19 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
<dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd>
|
<dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd>
|
||||||
|
|
||||||
<dt>Aufbewahrung</dt>
|
<dt>Aufbewahrung</dt>
|
||||||
<dd className="text-sm text-fg-muted">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
|
<dd className="wizard__summary-unavailable">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
|
||||||
|
|
||||||
<dt>Sicherheit</dt>
|
<dt>Sicherheit</dt>
|
||||||
<dd>
|
<dd>
|
||||||
verschlüsselt, Dringlichkeit {jobDraft.priority}
|
verschluesselt, Dringlichkeit {jobDraft.priority}
|
||||||
{jobDraft.bandwidthLimit.trim() !== '' && `, höchstens ${jobDraft.bandwidthLimit}`}
|
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt>Prüfung</dt>
|
<dt>Pruefung</dt>
|
||||||
<dd className="text-sm text-fg-muted">keine automatische Prüfung</dd>
|
<dd className="wizard__summary-unavailable">keine automatische Pruefung</dd>
|
||||||
|
|
||||||
<dt>Benachrichtigung</dt>
|
<dt>Benachrichtigung</dt>
|
||||||
<dd className="text-sm text-fg-muted">keine</dd>
|
<dd className="wizard__summary-unavailable">keine</dd>
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -745,14 +681,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
/** Schritt 10: Anlegen. */
|
/** Schritt 10: Anlegen. */
|
||||||
function renderCreateStep(): React.JSX.Element {
|
function renderCreateStep(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="wizard__fields">
|
||||||
<p>
|
<p>
|
||||||
Der Auftrag wird angelegt und läuft ab sofort nach Zeitplan. Der erste Lauf ist eine
|
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
|
||||||
vollständige Sicherung; jeder weitere liest nur, was sich geändert hat.
|
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{submitError !== null && (
|
{submitError !== null && (
|
||||||
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
|
<p className="wizard__error" role="alert">
|
||||||
{submitError}
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -761,27 +697,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Bildet die CSS-Klasse eines Schrittes in der Kopfzeile. */
|
||||||
* Bildet die Darstellung eines Schrittes in der Kopfzeile.
|
|
||||||
*
|
|
||||||
* Ein noch nicht erreichter Schritt bleibt gedämpft und ist nicht anklickbar —
|
|
||||||
* er übersprang eine Prüfung.
|
|
||||||
*/
|
|
||||||
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
|
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
|
||||||
const baseClasses = 'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs';
|
|
||||||
|
|
||||||
if (stepIndex === currentStepIndex) {
|
if (stepIndex === currentStepIndex) {
|
||||||
return `${baseClasses} bg-accent-subtle font-medium text-accent`;
|
return 'wizard__step wizard__step--current';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stepIndex < currentStepIndex) {
|
if (stepIndex < currentStepIndex) {
|
||||||
return `${baseClasses} text-healthy`;
|
return 'wizard__step wizard__step--done';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseClasses} text-fg-subtle`;
|
return 'wizard__step';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */
|
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
|
||||||
function describeError(caughtError: unknown, fallbackMessage: string): string {
|
function describeError(caughtError: unknown, fallbackMessage: string): string {
|
||||||
if (caughtError instanceof ApiError) {
|
if (caughtError instanceof ApiError) {
|
||||||
return caughtError.message;
|
return caughtError.message;
|
||||||
|
|||||||
@ -1,464 +0,0 @@
|
|||||||
/**
|
|
||||||
* Einzelheiten eines Auftrags samt Laufhistorie.
|
|
||||||
*
|
|
||||||
* Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum**
|
|
||||||
* etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht
|
|
||||||
* nur "fehlgeschlagen", sondern Fehlercode, Fehlerklasse und Meldung — und die
|
|
||||||
* Klasse sagt zugleich, ob eine Wiederholung überhaupt etwas bringt: Ein
|
|
||||||
* Anmeldefehler behebt sich nicht durch Warten.
|
|
||||||
*
|
|
||||||
* Ein Teilfehler bekommt eine eigene Erläuterung. "Erfolgreich mit
|
|
||||||
* übergangenen Objekten" gibt es nicht — die Datenbank lässt es per CHECK
|
|
||||||
* nicht zu, und die Oberfläche darf es erst recht nicht suggerieren.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ban, Pause, Play, RefreshCw } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
DataTable,
|
|
||||||
DetailItem,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
StatusBadge,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import {
|
|
||||||
formatBytes,
|
|
||||||
formatDateTime,
|
|
||||||
formatDuration,
|
|
||||||
formatRelativeTime,
|
|
||||||
formatThroughput,
|
|
||||||
} from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
cancelJobRun,
|
|
||||||
getJob,
|
|
||||||
listJobRuns,
|
|
||||||
pauseJob,
|
|
||||||
resumeJob,
|
|
||||||
runJob,
|
|
||||||
WEEKDAY_LABELS,
|
|
||||||
type BackupJobRun,
|
|
||||||
} from './jobsApi';
|
|
||||||
|
|
||||||
export function JobDetailPage({
|
|
||||||
jobIdentifier,
|
|
||||||
grantedPermissions,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
readonly jobIdentifier: string;
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [selectedRun, setSelectedRun] = useState<BackupJobRun | null>(null);
|
|
||||||
|
|
||||||
const jobResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => getJob(jobIdentifier, abortSignal), [jobIdentifier]),
|
|
||||||
jobIdentifier,
|
|
||||||
);
|
|
||||||
const runsResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listJobRuns(jobIdentifier, abortSignal), [jobIdentifier]),
|
|
||||||
jobIdentifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayRunJobs =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.run');
|
|
||||||
|
|
||||||
const reloadEverything = () => {
|
|
||||||
jobResource.reload();
|
|
||||||
runsResource.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const runMutation = useMutation(runJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Lauf eingereiht');
|
|
||||||
reloadEverything();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
apiError.statusCode === 409
|
|
||||||
? toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError))
|
|
||||||
: toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pauseMutation = useMutation(pauseJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Auftrag angehalten');
|
|
||||||
reloadEverything();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resumeMutation = useMutation(resumeJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Auftrag fortgesetzt');
|
|
||||||
reloadEverything();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cancelRunMutation = useMutation(cancelJobRun, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Abbruch angefordert', 'Der Lauf endet, sobald die laufende Stufe abschließt.');
|
|
||||||
reloadEverything();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Lauf ließ sich nicht abbrechen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (jobResource.loadState === 'loading') {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobResource.loadState === 'failed' || !jobResource.data) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Aufträgen" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={
|
|
||||||
jobResource.loadError ? describeApiError(jobResource.loadError) : undefined
|
|
||||||
}
|
|
||||||
requestId={jobResource.loadError?.requestId}
|
|
||||||
onRetry={jobResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = jobResource.data;
|
|
||||||
const runs = runsResource.data ?? [];
|
|
||||||
|
|
||||||
const runColumns: ReadonlyArray<TableColumn<BackupJobRun>> = [
|
|
||||||
{
|
|
||||||
key: 'started_at',
|
|
||||||
header: 'Beginn',
|
|
||||||
render: (run) => (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-fg">{formatDateTime(run.started_at)}</p>
|
|
||||||
<p className="text-xs text-fg-muted">{formatRelativeTime(run.started_at)}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Ausgang',
|
|
||||||
render: (run) => <StatusBadge status={run.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'duration',
|
|
||||||
header: 'Dauer',
|
|
||||||
numeric: true,
|
|
||||||
render: (run) =>
|
|
||||||
run.started_at && run.completed_at
|
|
||||||
? formatDuration(
|
|
||||||
(new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000,
|
|
||||||
)
|
|
||||||
: '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bytes_processed',
|
|
||||||
header: 'Gelesen',
|
|
||||||
numeric: true,
|
|
||||||
render: (run) => formatBytes(run.bytes_processed),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bytes_written',
|
|
||||||
header: 'Abgelegt',
|
|
||||||
numeric: true,
|
|
||||||
render: (run) => formatBytes(run.bytes_written),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'files',
|
|
||||||
header: 'Objekte',
|
|
||||||
numeric: true,
|
|
||||||
render: (run) => (
|
|
||||||
<span>
|
|
||||||
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
|
|
||||||
{run.files_skipped ? (
|
|
||||||
// Übergangene Objekte sind der Grund für den Teilfehler und
|
|
||||||
// gehören deshalb in dieselbe Zelle, nicht in eine Fußnote.
|
|
||||||
<span className="ml-1 text-warning">
|
|
||||||
({run.files_skipped.toLocaleString('de-DE')} übergangen)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (run) =>
|
|
||||||
(run.status === 'running' || run.status === 'queued') && mayRunJobs ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(clickEvent) => {
|
|
||||||
clickEvent.stopPropagation();
|
|
||||||
void cancelRunMutation.run(run.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ban />
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title={job.name}
|
|
||||||
description={job.description || job.schedule_description}
|
|
||||||
onBack={onBack}
|
|
||||||
backLabel="Zu den Aufträgen"
|
|
||||||
actions={
|
|
||||||
mayRunJobs ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={runMutation.isRunning}
|
|
||||||
onClick={() => void runMutation.run(job.id)}
|
|
||||||
>
|
|
||||||
<Play />
|
|
||||||
Jetzt sichern
|
|
||||||
</Button>
|
|
||||||
{job.status === 'paused' ? (
|
|
||||||
<Button onClick={() => void resumeMutation.run(job.id)}>
|
|
||||||
<Play />
|
|
||||||
Fortsetzen
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={() => void pauseMutation.run(job.id)}>
|
|
||||||
<Pause />
|
|
||||||
Anhalten
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{job.status === 'paused' ? (
|
|
||||||
<Callout tone="warning" title="Dieser Auftrag ist angehalten" className="mb-4">
|
|
||||||
Geplante Läufe entfallen, bis er fortgesetzt wird. Bereits laufende
|
|
||||||
Sicherungen sind davon nicht betroffen.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mb-4 grid gap-4 lg:grid-cols-3">
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader title="Konfiguration" />
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<DetailItem label="Zustand">
|
|
||||||
<StatusBadge status={job.status} />
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Dringlichkeit">{job.priority}</DetailItem>
|
|
||||||
<DetailItem label="Zeitplan">{job.schedule_description}</DetailItem>
|
|
||||||
<DetailItem label="Nächster Lauf">
|
|
||||||
{job.next_run_at ? formatDateTime(job.next_run_at) : 'Nur manuell'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Bandbreitengrenze">
|
|
||||||
{job.bandwidth_limit_bps
|
|
||||||
? formatThroughput(job.bandwidth_limit_bps)
|
|
||||||
: 'Ohne Grenze'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Quellen">{job.sources.length}</DetailItem>
|
|
||||||
<DetailItem label="Sicherungsart">
|
|
||||||
{job.backup_mode === 'always_full' ? (
|
|
||||||
'Immer voll'
|
|
||||||
) : job.full_backup_weekday !== undefined ? (
|
|
||||||
<>Inkrementell, {WEEKDAY_LABELS[job.full_backup_weekday]}s voll</>
|
|
||||||
) : (
|
|
||||||
'Inkrementell'
|
|
||||||
)}
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-5 border-t border-line pt-4">
|
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Zu sichernde Quellen
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{job.sources.map((source) => (
|
|
||||||
<li
|
|
||||||
key={`${source.type}:${source.id}`}
|
|
||||||
className="flex items-baseline gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs text-fg-muted">
|
|
||||||
{source.type}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 break-all text-fg">{source.id}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="Letzter Lauf" />
|
|
||||||
<CardContent>
|
|
||||||
{runs.length === 0 ? (
|
|
||||||
<p className="text-sm text-fg-muted">
|
|
||||||
Dieser Auftrag ist noch nie gelaufen. Es gibt daher keinen
|
|
||||||
Wiederherstellungspunkt.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<LastRunSummary run={runs[0]!} />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="Laufhistorie"
|
|
||||||
description="Die letzten 50 Läufe."
|
|
||||||
action={
|
|
||||||
<Button variant="ghost" size="sm" onClick={runsResource.reload}>
|
|
||||||
<RefreshCw />
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataTable
|
|
||||||
columns={runColumns}
|
|
||||||
rows={runs}
|
|
||||||
getRowKey={(run) => run.id}
|
|
||||||
onRowClick={(run) => setSelectedRun(run)}
|
|
||||||
isLoading={runsResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Noch kein Lauf"
|
|
||||||
emptyDescription="Stoßen Sie den ersten Lauf über die Schaltfläche oben an."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedRun ? (
|
|
||||||
<RunDetailCard run={selectedRun} onClose={() => setSelectedRun(null)} />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kurzfassung des letzten Laufs. */
|
|
||||||
function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<StatusBadge status={run.status} />
|
|
||||||
|
|
||||||
<dl className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<dt className="text-fg-muted">Beginn</dt>
|
|
||||||
<dd className="tabular text-fg">{formatRelativeTime(run.started_at)}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<dt className="text-fg-muted">Gelesen</dt>
|
|
||||||
<dd className="tabular text-fg">{formatBytes(run.bytes_processed)}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<dt className="text-fg-muted">Abgelegt</dt>
|
|
||||||
<dd className="tabular text-fg">{formatBytes(run.bytes_written)}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<dt className="text-fg-muted">Durchsatz</dt>
|
|
||||||
<dd className="tabular text-fg">{formatThroughput(run.throughput_bps)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{run.status === 'partial_failure' ? (
|
|
||||||
<Callout tone="warning" title="Teilfehler">
|
|
||||||
{run.files_skipped ?? 0} Objekte wurden übergangen. Ein Teilfehler
|
|
||||||
gilt <strong>nicht</strong> als Erfolg und wird bewusst nicht
|
|
||||||
wiederholt — die übergangenen Objekte wären beim nächsten Versuch
|
|
||||||
dieselben.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{run.error_code ? (
|
|
||||||
<Callout tone="critical" title={run.error_code}>
|
|
||||||
{run.error_message}
|
|
||||||
{run.error_class ? (
|
|
||||||
<p className="mt-1 text-xs">Klasse: {run.error_class}</p>
|
|
||||||
) : null}
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Einzelheiten eines ausgewählten Laufs. */
|
|
||||||
function RunDetailCard({
|
|
||||||
run,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
readonly run: BackupJobRun;
|
|
||||||
readonly onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader
|
|
||||||
title="Einzelheiten des Laufs"
|
|
||||||
description={formatDateTime(run.started_at)}
|
|
||||||
action={
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
Schließen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<DetailItem label="Ausgang">
|
|
||||||
<StatusBadge status={run.status} />
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Dauer">
|
|
||||||
{run.started_at && run.completed_at
|
|
||||||
? formatDuration(
|
|
||||||
(new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) /
|
|
||||||
1000,
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Objekte erfasst">
|
|
||||||
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Objekte übergangen">
|
|
||||||
{run.files_skipped?.toLocaleString('de-DE') ?? '0'}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem>
|
|
||||||
<DetailItem label="Abgelegt">{formatBytes(run.bytes_written)}</DetailItem>
|
|
||||||
<DetailItem label="Durchsatz">{formatThroughput(run.throughput_bps)}</DetailItem>
|
|
||||||
<DetailItem label="Lauf-Kennung">
|
|
||||||
<code className="break-all font-mono text-xs">{run.id}</code>
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{run.error_code ? (
|
|
||||||
<Callout tone="critical" title={run.error_code} className="mt-4">
|
|
||||||
<p>{run.error_message}</p>
|
|
||||||
{run.error_class ? (
|
|
||||||
<p className="mt-2 text-xs">
|
|
||||||
Fehlerklasse: <strong>{run.error_class}</strong>
|
|
||||||
{['transient', 'network', 'repository', 'source'].includes(run.error_class)
|
|
||||||
? ' — ein Wiederholungslauf kann helfen.'
|
|
||||||
: ' — eine Wiederholung behebt das nicht; die Ursache muss beseitigt werden.'}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,363 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sicherungsaufträge — Liste und Bedienung.
|
|
||||||
*
|
|
||||||
* Bis hierher zeigte die Oberfläche Aufträge an, die sich nicht bedienen
|
|
||||||
* ließen. Ein Betreiber, der einen Lauf außerhalb des Zeitplans anstoßen
|
|
||||||
* wollte, brauchte dafür die Kommandozeile.
|
|
||||||
*
|
|
||||||
* Zwei Entscheidungen tragen diese Seite:
|
|
||||||
*
|
|
||||||
* - **Ein zweiter Anstoß ist kein Fehler, sondern eine Auskunft.** Der Server
|
|
||||||
* antwortet mit 409, wenn der Auftrag bereits läuft. Das als Fehler zu
|
|
||||||
* melden wäre technisch richtig und praktisch irreführend — der Auftrag
|
|
||||||
* läuft ja, und genau das wollte der Betreiber.
|
|
||||||
* - **Löschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
|
|
||||||
* bestehen; sie gehören zum Repository, nicht zum Auftrag. Wer das nicht
|
|
||||||
* weiß, löscht einen Auftrag in der Annahme, damit Platz zu schaffen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
MoreHorizontal,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatRelativeTime } from '@/lib/utils';
|
|
||||||
import { BackupWizard } from './BackupWizard';
|
|
||||||
import {
|
|
||||||
deleteJob,
|
|
||||||
listJobs,
|
|
||||||
listRepositories,
|
|
||||||
pauseJob,
|
|
||||||
resumeJob,
|
|
||||||
runJob,
|
|
||||||
WEEKDAY_LABELS,
|
|
||||||
type BackupJob,
|
|
||||||
} from './jobsApi';
|
|
||||||
|
|
||||||
export function JobsPage({
|
|
||||||
grantedPermissions,
|
|
||||||
onOpenJob,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly onOpenJob: (jobIdentifier: string) => void;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
|
||||||
const [jobPendingDeletion, setJobPendingDeletion] = useState<BackupJob | null>(null);
|
|
||||||
|
|
||||||
const jobsResource = useApiResource<BackupJob[]>(
|
|
||||||
useCallback((abortSignal) => listJobs(abortSignal), []),
|
|
||||||
);
|
|
||||||
const repositoriesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRepositories(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayRunJobs =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.run');
|
|
||||||
const mayWriteJobs =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.write');
|
|
||||||
|
|
||||||
const runMutation = useMutation(runJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
// 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das —
|
|
||||||
// "Sicherung erfolgreich" wäre hier schlicht gelogen.
|
|
||||||
toast.showSuccess('Lauf eingereiht', 'Der Fortschritt erscheint in der Laufhistorie.');
|
|
||||||
jobsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) => {
|
|
||||||
if (apiError.statusCode === 409) {
|
|
||||||
// Der Auftrag läuft bereits — das ist die Antwort auf die Frage,
|
|
||||||
// nicht ein Fehler des Betreibers.
|
|
||||||
toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pauseMutation = useMutation(pauseJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Auftrag angehalten', 'Geplante Läufe entfallen, bis er fortgesetzt wird.');
|
|
||||||
jobsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Auftrag ließ sich nicht anhalten', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resumeMutation = useMutation(resumeJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Auftrag fortgesetzt');
|
|
||||||
jobsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Auftrag ließ sich nicht fortsetzen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation(deleteJob, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Auftrag gelöscht',
|
|
||||||
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
|
|
||||||
);
|
|
||||||
setJobPendingDeletion(null);
|
|
||||||
jobsResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Auftrag ließ sich nicht löschen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobs = jobsResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<BackupJob>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Auftrag',
|
|
||||||
render: (job) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{job.name}</p>
|
|
||||||
<p className="truncate text-xs text-fg-muted">
|
|
||||||
{job.schedule_description}
|
|
||||||
{job.backup_mode === 'always_full'
|
|
||||||
? ' · immer voll'
|
|
||||||
: job.full_backup_weekday !== undefined
|
|
||||||
? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll`
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (job) => <StatusBadge status={job.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'last_outcome',
|
|
||||||
header: 'Letzter Lauf',
|
|
||||||
render: (job) =>
|
|
||||||
job.last_run_at ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={job.last_outcome} showDot={false} />
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{formatRelativeTime(job.last_run_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied sähe ein
|
|
||||||
// nie gelaufener Auftrag aus wie ein funktionierender.
|
|
||||||
<span className="text-xs text-fg-muted">Noch nie gelaufen</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'next_run_at',
|
|
||||||
header: 'Nächster Lauf',
|
|
||||||
render: (job) =>
|
|
||||||
job.next_run_at ? (
|
|
||||||
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{job.status === 'paused' ? 'Angehalten' : 'Nur manuell'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sources',
|
|
||||||
header: 'Quellen',
|
|
||||||
numeric: true,
|
|
||||||
render: (job) => <span className="text-sm">{job.sources.length}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (job) => (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-1"
|
|
||||||
// Ohne das öffnete jeder Klick auf eine Schaltfläche zusätzlich die
|
|
||||||
// Detailseite der Zeile.
|
|
||||||
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
||||||
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
{mayRunJobs ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Jetzt sichern"
|
|
||||||
aria-label={`Auftrag ${job.name} jetzt sichern`}
|
|
||||||
isLoading={runMutation.isRunning}
|
|
||||||
onClick={() => void runMutation.run(job.id)}
|
|
||||||
>
|
|
||||||
<Play />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title={job.status === 'paused' ? 'Fortsetzen' : 'Anhalten'}
|
|
||||||
aria-label={
|
|
||||||
job.status === 'paused'
|
|
||||||
? `Auftrag ${job.name} fortsetzen`
|
|
||||||
: `Auftrag ${job.name} anhalten`
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
job.status === 'paused'
|
|
||||||
? void resumeMutation.run(job.id)
|
|
||||||
: void pauseMutation.run(job.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{job.status === 'paused' ? <Play /> : <Pause />}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{mayWriteJobs ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Löschen"
|
|
||||||
aria-label={`Auftrag ${job.name} löschen`}
|
|
||||||
onClick={() => setJobPendingDeletion(job)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
title="Einzelheiten"
|
|
||||||
aria-label={`Einzelheiten zu ${job.name}`}
|
|
||||||
onClick={() => onOpenJob(job.id)}
|
|
||||||
>
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Sicherungsaufträge" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(jobsResource.loadError)}
|
|
||||||
requestId={jobsResource.loadError.requestId}
|
|
||||||
onRetry={jobsResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const writableRepositories = (repositoriesResource.data ?? []).filter(
|
|
||||||
(repository) => repository.accepts_backups,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Sicherungsaufträge"
|
|
||||||
description="Was wann wohin gesichert wird."
|
|
||||||
actions={
|
|
||||||
mayWriteJobs ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Auftrag anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Ohne beschreibbares Ziel ist kein Auftrag möglich. Das erst beim
|
|
||||||
Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */}
|
|
||||||
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
|
|
||||||
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
|
|
||||||
Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={jobs}
|
|
||||||
getRowKey={(job) => job.id}
|
|
||||||
onRowClick={(job) => onOpenJob(job.id)}
|
|
||||||
isLoading={jobsResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Noch kein Sicherungsauftrag"
|
|
||||||
emptyDescription="Ohne Auftrag wird nichts gesichert."
|
|
||||||
emptyAction={
|
|
||||||
mayWriteJobs ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Ersten Auftrag anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{isWizardOpen ? (
|
|
||||||
<BackupWizard
|
|
||||||
onCancel={() => setIsWizardOpen(false)}
|
|
||||||
onJobCreated={(createdJob) => {
|
|
||||||
setIsWizardOpen(false);
|
|
||||||
jobsResource.reload();
|
|
||||||
toast.showSuccess('Auftrag angelegt', createdJob.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={jobPendingDeletion !== null}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setJobPendingDeletion(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Auftrag löschen"
|
|
||||||
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
|
|
||||||
confirmLabel="Löschen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteMutation.isRunning}
|
|
||||||
confirmationWord={jobPendingDeletion?.name}
|
|
||||||
confirmationHint={
|
|
||||||
<>
|
|
||||||
Tippen Sie zur Bestätigung den Namen des Auftrags:{' '}
|
|
||||||
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
{jobPendingDeletion?.name}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (jobPendingDeletion) {
|
|
||||||
void deleteMutation.run(jobPendingDeletion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Callout tone="info">
|
|
||||||
Die Wiederherstellungspunkte bleiben bestehen — es wird kein Speicher frei.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
108
apps/web/src/features/jobs/JobsPanel.css
Normal file
108
apps/web/src/features/jobs/JobsPanel.css
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Darstellung der Auftragsuebersicht.
|
||||||
|
*
|
||||||
|
* Semantische Farben ausschliesslich fuer Status (PROMPT.md §28). Ein
|
||||||
|
* Teilfehler bekommt Gelb, kein Gruen: Er ist kein Erfolg.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.jobs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__create {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-text-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
color: var(--color-surface-raised);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table th,
|
||||||
|
.jobs__table td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: var(--border-width) solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table th {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table td {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__badge {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__badge--paused {
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
border: var(--border-width) solid var(--color-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome--healthy {
|
||||||
|
color: var(--color-status-healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Teilfehler ist kein Erfolg und bekommt deshalb Gelb, nicht Gruen. */
|
||||||
|
.jobs__outcome--warning {
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome--critical {
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome--neutral {
|
||||||
|
color: var(--color-status-neutral);
|
||||||
|
}
|
||||||
173
apps/web/src/features/jobs/JobsPanel.tsx
Normal file
173
apps/web/src/features/jobs/JobsPanel.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Uebersicht der Sicherungsauftraege mit Zugang zum Assistenten.
|
||||||
|
*
|
||||||
|
* Die Liste zeigt, was tatsaechlich hinterlegt ist. Kennzahlen, die es noch
|
||||||
|
* nicht gibt - Erfolgsquoten, Speicherbedarf, Trends -, erscheinen nicht:
|
||||||
|
* Erfundene Zahlen in einer Uebersicht sind schlimmer als eine leere Spalte
|
||||||
|
* (PROMPT.md §138).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ApiError } from '../../api/client';
|
||||||
|
import { BackupWizard } from './BackupWizard';
|
||||||
|
import { listJobs } from './jobsApi';
|
||||||
|
import type { BackupJob } from './jobsApi';
|
||||||
|
import './JobsPanel.css';
|
||||||
|
|
||||||
|
/** Zeigt die Auftraege und den Assistenten zum Anlegen. */
|
||||||
|
export function JobsPanel(): React.JSX.Element {
|
||||||
|
const [jobs, setJobs] = useState<BackupJob[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
// reloadCounter erzwingt einen erneuten Lauf des Effekts, nachdem ein Auftrag
|
||||||
|
// angelegt wurde. Die Ladelogik bleibt damit an einer einzigen Stelle.
|
||||||
|
const [reloadCounter, setReloadCounter] = useState(0);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadCounter((previousCounter) => previousCounter + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Der Controller bricht die Anfrage ab, wenn die Komponente verschwindet.
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadJobs(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const loadedJobs = await listJobs(abortController.signal);
|
||||||
|
|
||||||
|
setJobs(loadedJobs);
|
||||||
|
setLoadError(null);
|
||||||
|
} catch (caughtError: unknown) {
|
||||||
|
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
|
||||||
|
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die bisherige Liste wird verworfen: Eine veraltete Anzeige als
|
||||||
|
// aktuellen Stand auszugeben waere irrefuehrend.
|
||||||
|
setJobs([]);
|
||||||
|
setLoadError(
|
||||||
|
caughtError instanceof ApiError
|
||||||
|
? caughtError.message
|
||||||
|
: 'Die Auftraege konnten nicht geladen werden.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadJobs();
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [reloadCounter]);
|
||||||
|
|
||||||
|
if (isWizardOpen) {
|
||||||
|
return (
|
||||||
|
<BackupWizard
|
||||||
|
onCancel={() => setIsWizardOpen(false)}
|
||||||
|
onJobCreated={() => {
|
||||||
|
setIsWizardOpen(false);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="jobs" aria-label="Sicherungsauftraege">
|
||||||
|
<header className="jobs__header">
|
||||||
|
<h2 className="jobs__heading">Sicherungsauftraege</h2>
|
||||||
|
|
||||||
|
<button type="button" className="jobs__create" onClick={() => setIsWizardOpen(true)}>
|
||||||
|
Auftrag anlegen
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadError !== null && (
|
||||||
|
<p className="jobs__error" role="alert">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <p className="jobs__hint">Wird geladen …</p>}
|
||||||
|
|
||||||
|
{!isLoading && loadError === null && jobs.length === 0 && (
|
||||||
|
<p className="jobs__hint">
|
||||||
|
Es ist kein Auftrag eingerichtet. Ohne Auftrag wird nichts gesichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<table className="jobs__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Zeitplan</th>
|
||||||
|
<th>Naechster Lauf</th>
|
||||||
|
<th>Letzter Ausgang</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((backupJob) => (
|
||||||
|
<tr key={backupJob.id}>
|
||||||
|
<td>
|
||||||
|
{backupJob.name}
|
||||||
|
{backupJob.status === 'paused' && (
|
||||||
|
<span className="jobs__badge jobs__badge--paused">ausgesetzt</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{backupJob.schedule_description}</td>
|
||||||
|
<td>{formatTimestamp(backupJob.next_run_at)}</td>
|
||||||
|
<td>{renderOutcome(backupJob.last_outcome)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt einen Zeitstempel in Ortszeit aus. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined || isoTimestamp === '') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(isoTimestamp);
|
||||||
|
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedDate.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt den Ausgang des letzten Laufs dar.
|
||||||
|
*
|
||||||
|
* Ein Teilfehler bekommt eine eigene Farbe und eine eigene Beschriftung. Ihn
|
||||||
|
* gruen als Erfolg zu zeigen waere genau die Beschoenigung, die PROMPT.md §138
|
||||||
|
* verbietet: Der Lauf hat Objekte uebergangen.
|
||||||
|
*/
|
||||||
|
function renderOutcome(lastOutcome: string | undefined): React.JSX.Element {
|
||||||
|
if (lastOutcome === undefined || lastOutcome === '') {
|
||||||
|
return <span className="jobs__outcome">noch nie gelaufen</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcomeLabels: Record<string, { label: string; modifier: string }> = {
|
||||||
|
succeeded: { label: 'erfolgreich', modifier: 'healthy' },
|
||||||
|
partial_failure: { label: 'TEILWEISE FEHLGESCHLAGEN', modifier: 'warning' },
|
||||||
|
failed: { label: 'gescheitert', modifier: 'critical' },
|
||||||
|
cancelled: { label: 'abgebrochen', modifier: 'neutral' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const outcomeInfo = outcomeLabels[lastOutcome] ?? { label: lastOutcome, modifier: 'neutral' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`jobs__outcome jobs__outcome--${outcomeInfo.modifier}`}>{outcomeInfo.label}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* API-Anbindung der Sicherungsaufträge (SYNCOVA_API.md §9).
|
* API-Anbindung der Sicherungsauftraege (SYNCOVA_API.md §9).
|
||||||
*
|
*
|
||||||
* Das Modul kennt nur den Vertrag nach außen. Die Gestalt der Anfrage folgt
|
* Das Modul kennt nur den Vertrag nach aussen. Die Gestalt der Anfrage folgt
|
||||||
* dem Backend und nicht der Oberfläche: Der Wizard führt seinen eigenen
|
* dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen
|
||||||
* Entwurf und übersetzt ihn erst beim Anlegen. Andernfalls müsste jede
|
* Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede
|
||||||
* Änderung an der API sofort die Maske umbauen.
|
* Aenderung an der API sofort die Maske umbauen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
import { requestApi } from '../../api/client';
|
||||||
@ -56,9 +56,9 @@ export interface SourceDescriptor {
|
|||||||
id: string;
|
id: string;
|
||||||
/** Sprechende Bezeichnung. */
|
/** Sprechende Bezeichnung. */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Einzuschließende Muster. */
|
/** Einzuschliessende Muster. */
|
||||||
include_patterns?: string[];
|
include_patterns?: string[];
|
||||||
/** Auszuschließende Muster. */
|
/** Auszuschliessende Muster. */
|
||||||
exclude_patterns?: string[];
|
exclude_patterns?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export interface SourceDescriptor {
|
|||||||
export interface CreateJobRequest {
|
export interface CreateJobRequest {
|
||||||
/** Eindeutige Bezeichnung. */
|
/** Eindeutige Bezeichnung. */
|
||||||
name: string;
|
name: string;
|
||||||
/** Erläuterung des Zwecks. */
|
/** Erlaeuterung des Zwecks. */
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Dringlichkeit. */
|
/** Dringlichkeit. */
|
||||||
priority?: JobPriority;
|
priority?: JobPriority;
|
||||||
@ -76,48 +76,21 @@ export interface CreateJobRequest {
|
|||||||
sources: SourceDescriptor[];
|
sources: SourceDescriptor[];
|
||||||
/** Ziel-Repository. */
|
/** Ziel-Repository. */
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
/** Zulässiger Datenverlust in Sekunden. */
|
/** Zulaessiger Datenverlust in Sekunden. */
|
||||||
rpo_seconds?: number;
|
rpo_seconds?: number;
|
||||||
/** Zulässige Wiederherstellungsdauer in Sekunden. */
|
/** Zulaessige Wiederherstellungsdauer in Sekunden. */
|
||||||
rto_seconds?: number;
|
rto_seconds?: number;
|
||||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||||
bandwidth_limit_bps?: number;
|
bandwidth_limit_bps?: number;
|
||||||
/**
|
|
||||||
* Sicherungsart: `incremental` (Standard) oder `always_full`.
|
|
||||||
*
|
|
||||||
* Der Platzbedarf steigt bei `always_full` **nicht** nennenswert —
|
|
||||||
* unveränderte Blöcke werden dedupliziert. Was steigt, ist die Laufzeit.
|
|
||||||
*/
|
|
||||||
backup_mode?: BackupMode;
|
|
||||||
/**
|
|
||||||
* Wochentag einer erzwungenen Vollsicherung.
|
|
||||||
*
|
|
||||||
* 0 = Sonntag … 6 = Samstag. Gerechnet in der Zeitzone des Zeitplans.
|
|
||||||
*/
|
|
||||||
full_backup_weekday?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sicherungsart eines Auftrags. */
|
|
||||||
export type BackupMode = 'incremental' | 'always_full';
|
|
||||||
|
|
||||||
/** Wochentage in der Zählung der API (0 = Sonntag). */
|
|
||||||
export const WEEKDAY_LABELS: readonly string[] = [
|
|
||||||
'Sonntag',
|
|
||||||
'Montag',
|
|
||||||
'Dienstag',
|
|
||||||
'Mittwoch',
|
|
||||||
'Donnerstag',
|
|
||||||
'Freitag',
|
|
||||||
'Samstag',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Auftrag in der Antwort der API. */
|
/** Auftrag in der Antwort der API. */
|
||||||
export interface BackupJob {
|
export interface BackupJob {
|
||||||
/** Öffentlicher Bezeichner. */
|
/** Oeffentlicher Bezeichner. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Bezeichnung. */
|
/** Bezeichnung. */
|
||||||
name: string;
|
name: string;
|
||||||
/** Erläuterung. */
|
/** Erlaeuterung. */
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Zustand. */
|
/** Zustand. */
|
||||||
status: string;
|
status: string;
|
||||||
@ -125,13 +98,13 @@ export interface BackupJob {
|
|||||||
priority: JobPriority;
|
priority: JobPriority;
|
||||||
/** Zeitplan. */
|
/** Zeitplan. */
|
||||||
schedule: ScheduleDescriptor;
|
schedule: ScheduleDescriptor;
|
||||||
/** Erklärung des Zeitplans in einem Satz. */
|
/** Erklaerung des Zeitplans in einem Satz. */
|
||||||
schedule_description: string;
|
schedule_description: string;
|
||||||
/** Quellen. */
|
/** Quellen. */
|
||||||
sources: SourceDescriptor[];
|
sources: SourceDescriptor[];
|
||||||
/** Ziel-Repository. */
|
/** Ziel-Repository. */
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
/** Nächster Zeitpunkt in UTC. */
|
/** Naechster Zeitpunkt in UTC. */
|
||||||
next_run_at?: string;
|
next_run_at?: string;
|
||||||
/** Beginn des letzten Laufs in UTC. */
|
/** Beginn des letzten Laufs in UTC. */
|
||||||
last_run_at?: string;
|
last_run_at?: string;
|
||||||
@ -139,15 +112,11 @@ export interface BackupJob {
|
|||||||
last_outcome?: string;
|
last_outcome?: string;
|
||||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||||
bandwidth_limit_bps?: number;
|
bandwidth_limit_bps?: number;
|
||||||
/** Sicherungsart. */
|
|
||||||
backup_mode?: BackupMode;
|
|
||||||
/** Wochentag einer erzwungenen Vollsicherung. */
|
|
||||||
full_backup_weekday?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sicherungsziel in der Antwort der API. */
|
/** Sicherungsziel in der Antwort der API. */
|
||||||
export interface BackupRepository {
|
export interface BackupRepository {
|
||||||
/** Öffentlicher Bezeichner. */
|
/** Oeffentlicher Bezeichner. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Sprechende Bezeichnung. */
|
/** Sprechende Bezeichnung. */
|
||||||
name: string;
|
name: string;
|
||||||
@ -160,26 +129,26 @@ export interface BackupRepository {
|
|||||||
/**
|
/**
|
||||||
* Meldet, ob dieses Ziel Sicherungen annimmt.
|
* Meldet, ob dieses Ziel Sicherungen annimmt.
|
||||||
*
|
*
|
||||||
* Die Auskunft kommt vom Server. Die Oberfläche müsste sonst wissen, welche
|
* Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche
|
||||||
* Zustände schreibend sind - eine Regel, die dort nicht hingehört.
|
* Zustaende schreibend sind - eine Regel, die dort nicht hingehoert.
|
||||||
*/
|
*/
|
||||||
accepts_backups: boolean;
|
accepts_backups: boolean;
|
||||||
/** Meldet den gehärteten Modus. */
|
/** Meldet den gehaerteten Modus. */
|
||||||
hardened: boolean;
|
hardened: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt die bekannten Sicherungsziele.
|
* Laedt die bekannten Sicherungsziele.
|
||||||
*
|
*
|
||||||
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
|
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
|
||||||
* exactOptionalPropertyTypes ist ein ausdrückliches undefined etwas anderes
|
* exactOptionalPropertyTypes ist ein ausdrueckliches undefined etwas anderes
|
||||||
* als ein fehlendes Feld.
|
* als ein fehlendes Feld.
|
||||||
*/
|
*/
|
||||||
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
|
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
|
||||||
return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt die vorhandenen Sicherungsaufträge. */
|
/** Laedt die vorhandenen Sicherungsauftraege. */
|
||||||
export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> {
|
export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> {
|
||||||
return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {});
|
return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {});
|
||||||
}
|
}
|
||||||
@ -188,115 +157,3 @@ export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]>
|
|||||||
export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob> {
|
export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob> {
|
||||||
return requestApi<BackupJob>('/jobs', { method: 'POST', body: jobRequest });
|
return requestApi<BackupJob>('/jobs', { method: 'POST', body: jobRequest });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bedienung der Aufträge
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Bis hierher konnte die Oberfläche einen Auftrag anlegen und ansehen — mehr
|
|
||||||
// nicht. Eine Liste, deren Einträge sich nicht bedienen lassen, ist ein
|
|
||||||
// Bericht, keine Konsole.
|
|
||||||
|
|
||||||
/** Ein einzelner Lauf eines Auftrags. */
|
|
||||||
export interface BackupJobRun {
|
|
||||||
/** Öffentlicher Bezeichner. */
|
|
||||||
id: string;
|
|
||||||
/** Zugehöriger Auftrag. */
|
|
||||||
job_id: string;
|
|
||||||
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
|
|
||||||
status: string;
|
|
||||||
/** Beginn in UTC. */
|
|
||||||
started_at?: string;
|
|
||||||
/** Ende in UTC. */
|
|
||||||
completed_at?: string;
|
|
||||||
/** Gelesene Bytes. */
|
|
||||||
bytes_processed?: number;
|
|
||||||
/** Tatsächlich abgelegte Bytes nach Deduplizierung. */
|
|
||||||
bytes_written?: number;
|
|
||||||
/** Anzahl erfasster Dateien. */
|
|
||||||
files_processed?: number;
|
|
||||||
/**
|
|
||||||
* Übergangene Objekte.
|
|
||||||
*
|
|
||||||
* Größer als null bedeutet Teilfehler — die Datenbank lässt
|
|
||||||
* "erfolgreich mit übergangenen Objekten" per CHECK gar nicht zu.
|
|
||||||
*/
|
|
||||||
files_skipped?: number;
|
|
||||||
/** Durchsatz in Byte je Sekunde. */
|
|
||||||
throughput_bps?: number;
|
|
||||||
/** Fehlercode bei nicht erfolgreichem Ausgang. */
|
|
||||||
error_code?: string;
|
|
||||||
/** Fehlermeldung. */
|
|
||||||
error_message?: string;
|
|
||||||
/** Klasse des Fehlers: transient, permanent, integrity, auth, … */
|
|
||||||
error_class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt einen einzelnen Auftrag. */
|
|
||||||
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
|
|
||||||
return requestApi<BackupJob>(
|
|
||||||
`/jobs/${encodeURIComponent(jobIdentifier)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Laufhistorie eines Auftrags. */
|
|
||||||
export async function listJobRuns(
|
|
||||||
jobIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<BackupJobRun[]> {
|
|
||||||
return requestApi<BackupJobRun[]>(
|
|
||||||
`/jobs/${encodeURIComponent(jobIdentifier)}/runs?page_size=50`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stößt einen Lauf an.
|
|
||||||
*
|
|
||||||
* Die Antwort ist 202, nicht 201: Der Lauf ist eingereiht, die Sicherung hat
|
|
||||||
* nicht begonnen. Ein zweiter Anstoß bei laufendem Auftrag ergibt 409 — das
|
|
||||||
* ist eine Auskunft, kein Fehler, und wird in der Oberfläche als solche
|
|
||||||
* gezeigt.
|
|
||||||
*/
|
|
||||||
export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> {
|
|
||||||
return requestApi<{ run_id?: string }>(`/jobs/${encodeURIComponent(jobIdentifier)}/run`, {
|
|
||||||
method: 'POST',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hält einen Auftrag an. Laufende Sicherungen bleiben unberührt. */
|
|
||||||
export async function pauseJob(jobIdentifier: string): Promise<BackupJob> {
|
|
||||||
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nimmt einen angehaltenen Auftrag wieder auf. */
|
|
||||||
export async function resumeJob(jobIdentifier: string): Promise<BackupJob> {
|
|
||||||
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/resume`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Löscht einen Auftrag.
|
|
||||||
*
|
|
||||||
* Die bereits erzeugten Wiederherstellungspunkte bleiben bestehen — sie
|
|
||||||
* gehören zum Repository, nicht zum Auftrag. Das muss die Oberfläche sagen,
|
|
||||||
* sonst löscht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
|
|
||||||
*/
|
|
||||||
export async function deleteJob(jobIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bricht einen laufenden Sicherungslauf ab. */
|
|
||||||
export async function cancelJobRun(runIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(`/backup-runs/${encodeURIComponent(runIdentifier)}/cancel`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Tests des Wizard-Modells.
|
* Tests des Wizard-Modells.
|
||||||
*
|
*
|
||||||
* Geprüft wird die Logik ohne gerenderte Maske: welcher Schritt vollständig
|
* Geprueft wird die Logik ohne gerenderte Maske: welcher Schritt vollstaendig
|
||||||
* ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden.
|
* ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -19,11 +19,11 @@ import {
|
|||||||
} from './wizardModel';
|
} from './wizardModel';
|
||||||
import type { JobDraft } from './wizardModel';
|
import type { JobDraft } from './wizardModel';
|
||||||
|
|
||||||
/** Liefert einen vollständig ausgefüllten Entwurf. */
|
/** Liefert einen vollstaendig ausgefuellten Entwurf. */
|
||||||
function buildCompleteDraft(): JobDraft {
|
function buildCompleteDraft(): JobDraft {
|
||||||
return {
|
return {
|
||||||
...createEmptyDraft(),
|
...createEmptyDraft(),
|
||||||
name: 'Nächtliche Sicherung',
|
name: 'Naechtliche Sicherung',
|
||||||
sources: [
|
sources: [
|
||||||
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
|
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
|
||||||
],
|
],
|
||||||
@ -43,8 +43,8 @@ describe('Schrittfolge', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Prüfung der Schritte', () => {
|
describe('Pruefung der Schritte', () => {
|
||||||
it('nimmt einen vollständigen Entwurf an', () => {
|
it('nimmt einen vollstaendigen Entwurf an', () => {
|
||||||
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
|
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ describe('Prüfung der Schritte', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern -
|
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern -
|
||||||
// die gefährlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
|
// die gefaehrlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
|
||||||
it('verlangt mindestens eine Quelle', () => {
|
it('verlangt mindestens eine Quelle', () => {
|
||||||
const sourcelessDraft = {
|
const sourcelessDraft = {
|
||||||
...buildCompleteDraft(),
|
...buildCompleteDraft(),
|
||||||
@ -108,13 +108,13 @@ describe('Prüfung der Schritte', () => {
|
|||||||
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true);
|
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('weist eine unmögliche Uhrzeit ab', () => {
|
it('weist eine unmoegliche Uhrzeit ab', () => {
|
||||||
const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' };
|
const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' };
|
||||||
|
|
||||||
expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false);
|
expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verlangt fünf Felder in einem Cron-Ausdruck', () => {
|
it('verlangt fuenf Felder in einem Cron-Ausdruck', () => {
|
||||||
const cronDraft: JobDraft = {
|
const cronDraft: JobDraft = {
|
||||||
...buildCompleteDraft(),
|
...buildCompleteDraft(),
|
||||||
scheduleType: 'cron',
|
scheduleType: 'cron',
|
||||||
@ -128,9 +128,9 @@ describe('Prüfung der Schritte', () => {
|
|||||||
expect(validateStep('schedule', cronDraft).isComplete).toBe(true);
|
expect(validateStep('schedule', cronDraft).isComplete).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aufbewahrung, Prüfung und Benachrichtigung fragen nichts ab und dürfen
|
// Aufbewahrung, Pruefung und Benachrichtigung fragen nichts ab und duerfen
|
||||||
// den Anwender deshalb auch nicht aufhalten.
|
// den Anwender deshalb auch nicht aufhalten.
|
||||||
it('hält bei den nicht umgesetzten Schritten nicht auf', () => {
|
it('haelt bei den nicht umgesetzten Schritten nicht auf', () => {
|
||||||
const emptyDraft = createEmptyDraft();
|
const emptyDraft = createEmptyDraft();
|
||||||
|
|
||||||
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
|
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
|
||||||
@ -142,7 +142,7 @@ describe('Prüfung der Schritte', () => {
|
|||||||
describe('Bandbreitenangabe', () => {
|
describe('Bandbreitenangabe', () => {
|
||||||
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
|
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
|
||||||
// Achtfache der beabsichtigten Rate.
|
// Achtfache der beabsichtigten Rate.
|
||||||
it('hält Bit und Byte auseinander', () => {
|
it('haelt Bit und Byte auseinander', () => {
|
||||||
const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
|
const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
|
||||||
const byteValue = parseBandwidthToBytesPerSecond('100MB');
|
const byteValue = parseBandwidthToBytesPerSecond('100MB');
|
||||||
|
|
||||||
@ -151,7 +151,7 @@ describe('Bandbreitenangabe', () => {
|
|||||||
expect(bitValue).toBeLessThan(byteValue);
|
expect(bitValue).toBeLessThan(byteValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nimmt die gebräuchlichen Schreibweisen an', () => {
|
it('nimmt die gebraeuchlichen Schreibweisen an', () => {
|
||||||
expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800);
|
expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800);
|
||||||
expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800);
|
expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800);
|
||||||
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
|
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
|
||||||
@ -170,10 +170,10 @@ describe('Bandbreitenangabe', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Anfrage an die API', () => {
|
describe('Anfrage an die API', () => {
|
||||||
it('überträgt Name, Quelle, Zeitplan und Ziel', () => {
|
it('uebertraegt Name, Quelle, Zeitplan und Ziel', () => {
|
||||||
const jobRequest = buildCreateRequest(buildCompleteDraft());
|
const jobRequest = buildCreateRequest(buildCompleteDraft());
|
||||||
|
|
||||||
expect(jobRequest.name).toBe('Nächtliche Sicherung');
|
expect(jobRequest.name).toBe('Naechtliche Sicherung');
|
||||||
expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111');
|
expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111');
|
||||||
expect(jobRequest.schedule.type).toBe('daily');
|
expect(jobRequest.schedule.type).toBe('daily');
|
||||||
expect(jobRequest.schedule.time).toBe('02:00');
|
expect(jobRequest.schedule.time).toBe('02:00');
|
||||||
@ -184,15 +184,15 @@ describe('Anfrage an die API', () => {
|
|||||||
|
|
||||||
// Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je
|
// Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je
|
||||||
// nach Standort zu einer anderen Uhrzeit.
|
// nach Standort zu einer anderen Uhrzeit.
|
||||||
it('sendet die Zeitzone bei Zeitplänen mit Uhrzeit', () => {
|
it('sendet die Zeitzone bei Zeitplaenen mit Uhrzeit', () => {
|
||||||
const jobRequest = buildCreateRequest(buildCompleteDraft());
|
const jobRequest = buildCreateRequest(buildCompleteDraft());
|
||||||
|
|
||||||
expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin');
|
expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ein Intervallplan zählt Abstände, keine Uhrzeiten. Eine Zeitzone wäre
|
// Ein Intervallplan zaehlt Abstaende, keine Uhrzeiten. Eine Zeitzone waere
|
||||||
// dort bedeutungslos.
|
// dort bedeutungslos.
|
||||||
it('lässt die Zeitzone bei einem Intervallplan weg', () => {
|
it('laesst die Zeitzone bei einem Intervallplan weg', () => {
|
||||||
const intervalDraft: JobDraft = {
|
const intervalDraft: JobDraft = {
|
||||||
...buildCompleteDraft(),
|
...buildCompleteDraft(),
|
||||||
scheduleType: 'interval',
|
scheduleType: 'interval',
|
||||||
@ -205,9 +205,9 @@ describe('Anfrage an die API', () => {
|
|||||||
expect(jobRequest.schedule.interval_seconds).toBe(21_600);
|
expect(jobRequest.schedule.interval_seconds).toBe(21_600);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ein leeres Feld mitzusenden überschriebe auf dem Server einen sinnvollen
|
// Ein leeres Feld mitzusenden ueberschriebe auf dem Server einen sinnvollen
|
||||||
// Standard mit einem Leerwert.
|
// Standard mit einem Leerwert.
|
||||||
it('lässt leere Felder weg', () => {
|
it('laesst leere Felder weg', () => {
|
||||||
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
|
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
|
||||||
|
|
||||||
const jobRequest = buildCreateRequest(sparseDraft);
|
const jobRequest = buildCreateRequest(sparseDraft);
|
||||||
@ -216,13 +216,13 @@ describe('Anfrage an die API', () => {
|
|||||||
expect(jobRequest.bandwidth_limit_bps).toBeUndefined();
|
expect(jobRequest.bandwidth_limit_bps).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('überträgt die Bandbreitengrenze in Byte je Sekunde', () => {
|
it('uebertraegt die Bandbreitengrenze in Byte je Sekunde', () => {
|
||||||
const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' };
|
const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' };
|
||||||
|
|
||||||
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
|
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('übergeht leere Quellzeilen', () => {
|
it('uebergeht leere Quellzeilen', () => {
|
||||||
const mixedDraft: JobDraft = {
|
const mixedDraft: JobDraft = {
|
||||||
...buildCompleteDraft(),
|
...buildCompleteDraft(),
|
||||||
sources: [
|
sources: [
|
||||||
@ -246,10 +246,10 @@ describe('Anfrage an die API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Beschreibung des Zeitplans', () => {
|
describe('Beschreibung des Zeitplans', () => {
|
||||||
it('beschreibt die Zeitpläne verständlich', () => {
|
it('beschreibt die Zeitplaene verstaendlich', () => {
|
||||||
const dailyDraft = buildCompleteDraft();
|
const dailyDraft = buildCompleteDraft();
|
||||||
|
|
||||||
expect(describeDraftSchedule(dailyDraft)).toBe('täglich um 02:00 Uhr (Europe/Berlin)');
|
expect(describeDraftSchedule(dailyDraft)).toBe('taeglich um 02:00 Uhr (Europe/Berlin)');
|
||||||
|
|
||||||
expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung');
|
expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung');
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Tests des Liniendiagramms.
|
* Tests des Liniendiagramms.
|
||||||
*
|
*
|
||||||
* Sie prüfen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
|
* Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
|
||||||
* wurde: **Eine Lücke wird nicht überbrückt und nicht auf null gezogen.**
|
* wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.**
|
||||||
* Beides ergäbe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
|
* Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
@ -30,12 +30,12 @@ function pointWithoutValue(minuteOffset: number): DataPoint {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Baut eine Reihe aus den übergebenen Punkten. */
|
/** Baut eine Reihe aus den uebergebenen Punkten. */
|
||||||
function seriesOf(points: readonly DataPoint[]): Series {
|
function seriesOf(points: readonly DataPoint[]): Series {
|
||||||
return { name: 'test', label: 'Testreihe', unit: 'count', points };
|
return { name: 'test', label: 'Testreihe', unit: 'count', points };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liest alle gezeichneten Linienzüge aus dem Dokument. */
|
/** Liest alle gezeichneten Linienzuege aus dem Dokument. */
|
||||||
function readPathDefinitions(container: HTMLElement): string[] {
|
function readPathDefinitions(container: HTMLElement): string[] {
|
||||||
return Array.from(container.querySelectorAll('path')).map(
|
return Array.from(container.querySelectorAll('path')).map(
|
||||||
(pathElement) => pathElement.getAttribute('d') ?? '',
|
(pathElement) => pathElement.getAttribute('d') ?? '',
|
||||||
@ -43,7 +43,7 @@ function readPathDefinitions(container: HTMLElement): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Liniendiagramm', () => {
|
describe('Liniendiagramm', () => {
|
||||||
it('unterbricht die Linie an einer Lücke, statt sie zu überbrücken', () => {
|
it('unterbricht die Linie an einer Luecke, statt sie zu ueberbruecken', () => {
|
||||||
// Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf.
|
// Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf.
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LineChart
|
<LineChart
|
||||||
@ -62,19 +62,19 @@ describe('Liniendiagramm', () => {
|
|||||||
|
|
||||||
const pathDefinitions = readPathDefinitions(container);
|
const pathDefinitions = readPathDefinitions(container);
|
||||||
|
|
||||||
// Zwei getrennte Linienzüge — nicht einer, der die Lücke überspringt.
|
// Zwei getrennte Linienzuege — nicht einer, der die Luecke ueberspringt.
|
||||||
expect(pathDefinitions).toHaveLength(2);
|
expect(pathDefinitions).toHaveLength(2);
|
||||||
|
|
||||||
for (const pathDefinition of pathDefinitions) {
|
for (const pathDefinition of pathDefinitions) {
|
||||||
// Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger
|
// Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger
|
||||||
// Linienzug mit zwei M-Befehlen wäre zwar optisch gleich, ließe sich
|
// Linienzug mit zwei M-Befehlen waere zwar optisch gleich, liesse sich
|
||||||
// aber nicht mehr als getrennt erkennen.
|
// aber nicht mehr als getrennt erkennen.
|
||||||
expect(pathDefinition.startsWith('M')).toBe(true);
|
expect(pathDefinition.startsWith('M')).toBe(true);
|
||||||
expect(pathDefinition.split('M').length - 1).toBe(1);
|
expect(pathDefinition.split('M').length - 1).toBe(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zieht eine Lücke nicht auf den Nullpunkt', () => {
|
it('zieht eine Luecke nicht auf den Nullpunkt', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LineChart
|
<LineChart
|
||||||
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
|
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
|
||||||
@ -86,7 +86,7 @@ describe('Liniendiagramm', () => {
|
|||||||
const allCoordinates = pathDefinitions.join(' ');
|
const allCoordinates = pathDefinitions.join(' ');
|
||||||
|
|
||||||
// Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der
|
// Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der
|
||||||
// Zeichenfläche. Taucht er auf, wurde die Lücke als Null gezeichnet — und
|
// Zeichenflaeche. Taucht er auf, wurde die Luecke als Null gezeichnet — und
|
||||||
// die Kurve behauptet einen Einbruch, den es nicht gab.
|
// die Kurve behauptet einen Einbruch, den es nicht gab.
|
||||||
const lowestDrawnPosition = Math.max(
|
const lowestDrawnPosition = Math.max(
|
||||||
...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])),
|
...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])),
|
||||||
@ -96,8 +96,8 @@ describe('Liniendiagramm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('zeichnet eine einzelne Messung als Punkt', () => {
|
it('zeichnet eine einzelne Messung als Punkt', () => {
|
||||||
// Eine einzelne Messung ergäbe eine Linie der Länge null und wäre
|
// Eine einzelne Messung ergaebe eine Linie der Laenge null und waere
|
||||||
// unsichtbar — die Kurve sähe aus wie „nichts gemessen".
|
// unsichtbar — die Kurve saehe aus wie „nichts gemessen".
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LineChart
|
<LineChart
|
||||||
series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]}
|
series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]}
|
||||||
@ -116,20 +116,20 @@ describe('Liniendiagramm', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Kein leeres Achsenkreuz: Das sähe aus wie eine Kurve auf null.
|
// Kein leeres Achsenkreuz: Das saehe aus wie eine Kurve auf null.
|
||||||
expect(screen.getByText(/keine Daten/)).toBeInTheDocument();
|
expect(screen.getByText(/keine Daten/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/nicht/)).toBeInTheDocument();
|
expect(screen.getByText(/nicht/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('beginnt die Werteachse bei null', () => {
|
it('beginnt die Werteachse bei null', () => {
|
||||||
// Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche
|
// Eine abgeschnittene Achse laesst kleine Schwankungen wie Einbrueche
|
||||||
// aussehen — der häufigste Weg, mit korrekten Zahlen etwas Falsches zu
|
// aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu
|
||||||
// zeigen.
|
// zeigen.
|
||||||
render(
|
render(
|
||||||
<LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />,
|
<LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Die Achse läuft von null bis zum größten Wert der Reihe.
|
// Die Achse laeuft von null bis zum groessten Wert der Reihe.
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
expect(screen.getByText('102')).toBeInTheDocument();
|
expect(screen.getByText('102')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,29 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* Liniendiagramm als SVG, ohne Diagrammbibliothek.
|
* Liniendiagramm als SVG, ohne Diagrammbibliothek.
|
||||||
*
|
*
|
||||||
* Warum keine? Die gängigen Bibliotheken bringen mehr Code mit, als die ganze
|
* Warum keine? Die gaengigen Bibliotheken bringen mehr Code mit, als die ganze
|
||||||
* Oberfläche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
|
* Oberflaeche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
|
||||||
* beherrschen sie standardmäßig falsch: **Sie zeichnen Lücken als Nullen.**
|
* beherrschen sie standardmaessig falsch: **Sie zeichnen Luecken als Nullen.**
|
||||||
*
|
*
|
||||||
* Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die
|
* Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die
|
||||||
* dort auf den Nullpunkt fällt, lässt eine Anlage aussehen, als wäre ihre
|
* dort auf den Nullpunkt faellt, laesst eine Anlage aussehen, als waere ihre
|
||||||
* Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei
|
* Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei
|
||||||
* unterbricht die Linie stattdessen.
|
* unterbricht die Linie stattdessen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DataPoint, Series, SeriesUnit } from './metricsApi';
|
import type { DataPoint, Series, SeriesUnit } from './metricsApi';
|
||||||
|
|
||||||
/** Abmessungen der Zeichenfläche. */
|
/** Abmessungen der Zeichenflaeche. */
|
||||||
const CHART_VIEWBOX_WIDTH = 720;
|
const CHART_VIEWBOX_WIDTH = 720;
|
||||||
/** Höhe der Zeichenfläche. */
|
/** Hoehe der Zeichenflaeche. */
|
||||||
const CHART_VIEWBOX_HEIGHT = 220;
|
const CHART_VIEWBOX_HEIGHT = 220;
|
||||||
/** Innenabstand links für die Beschriftung der Werteachse. */
|
/** Innenabstand links fuer die Beschriftung der Werteachse. */
|
||||||
const CHART_PADDING_LEFT = 64;
|
const CHART_PADDING_LEFT = 64;
|
||||||
/** Innenabstand rechts. */
|
/** Innenabstand rechts. */
|
||||||
const CHART_PADDING_RIGHT = 12;
|
const CHART_PADDING_RIGHT = 12;
|
||||||
/** Innenabstand oben. */
|
/** Innenabstand oben. */
|
||||||
const CHART_PADDING_TOP = 12;
|
const CHART_PADDING_TOP = 12;
|
||||||
/** Innenabstand unten für die Zeitachse. */
|
/** Innenabstand unten fuer die Zeitachse. */
|
||||||
const CHART_PADDING_BOTTOM = 28;
|
const CHART_PADDING_BOTTOM = 28;
|
||||||
|
|
||||||
/** Farben der Reihen in Zeichenreihenfolge. */
|
/** Farben der Reihen in Zeichenreihenfolge. */
|
||||||
@ -46,19 +46,19 @@ interface LineChartProperties {
|
|||||||
export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element {
|
export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element {
|
||||||
const maximumValue = findMaximumValue(series);
|
const maximumValue = findMaximumValue(series);
|
||||||
|
|
||||||
// Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Fläche mit
|
// Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Flaeche mit
|
||||||
// Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz.
|
// Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz.
|
||||||
if (maximumValue === null) {
|
if (maximumValue === null) {
|
||||||
return (
|
return (
|
||||||
<p className="chart__empty">
|
<p className="chart__empty">
|
||||||
In diesem Zeitraum wurde nichts gemessen. Die leere Fläche bedeutet „keine Daten", nicht
|
In diesem Zeitraum wurde nichts gemessen. Die leere Flaeche bedeutet „keine Daten", nicht
|
||||||
„Wert null".
|
„Wert null".
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiß das nach
|
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiss das nach
|
||||||
// der Rückgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
|
// der Rueckgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
|
||||||
const scaleMaximum: number = maximumValue;
|
const scaleMaximum: number = maximumValue;
|
||||||
|
|
||||||
const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT;
|
const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT;
|
||||||
@ -76,8 +76,8 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
|
|||||||
|
|
||||||
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
||||||
function verticalPositionOf(dataValue: number): number {
|
function verticalPositionOf(dataValue: number): number {
|
||||||
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse lässt
|
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse laesst
|
||||||
// kleine Schwankungen wie Einbrüche aussehen — der häufigste Weg, mit
|
// kleine Schwankungen wie Einbrueche aussehen — der haeufigste Weg, mit
|
||||||
// einem korrekten Diagramm etwas Falsches zu zeigen.
|
// einem korrekten Diagramm etwas Falsches zu zeigen.
|
||||||
return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight;
|
return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight;
|
||||||
}
|
}
|
||||||
@ -126,11 +126,11 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
|
|||||||
|
|
||||||
/** Eigenschaften der Hilfslinien. */
|
/** Eigenschaften der Hilfslinien. */
|
||||||
interface GridLinesProperties {
|
interface GridLinesProperties {
|
||||||
/** Größter Wert der Reihen. */
|
/** Groesster Wert der Reihen. */
|
||||||
readonly maximumValue: number;
|
readonly maximumValue: number;
|
||||||
/** Einheit der Werte. */
|
/** Einheit der Werte. */
|
||||||
readonly unit: SeriesUnit;
|
readonly unit: SeriesUnit;
|
||||||
/** Breite der Zeichenfläche. */
|
/** Breite der Zeichenflaeche. */
|
||||||
readonly plotWidth: number;
|
readonly plotWidth: number;
|
||||||
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
||||||
readonly verticalPositionOf: (dataValue: number) => number;
|
readonly verticalPositionOf: (dataValue: number) => number;
|
||||||
@ -197,8 +197,8 @@ function SeriesPath({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Ein einzelner Messpunkt ohne Nachbarn ergäbe eine Linie der Länge
|
{/* Ein einzelner Messpunkt ohne Nachbarn ergaebe eine Linie der Laenge
|
||||||
null und wäre unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
|
null und waere unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
|
||||||
{series.points.map((dataPoint, pointIndex) =>
|
{series.points.map((dataPoint, pointIndex) =>
|
||||||
dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? (
|
dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? (
|
||||||
<circle
|
<circle
|
||||||
@ -218,8 +218,8 @@ function SeriesPath({
|
|||||||
/**
|
/**
|
||||||
* Baut die Linienabschnitte einer Reihe.
|
* Baut die Linienabschnitte einer Reihe.
|
||||||
*
|
*
|
||||||
* Jede zusammenhängende Folge von Punkten mit Wert wird ein eigener Abschnitt.
|
* Jede zusammenhaengende Folge von Punkten mit Wert wird ein eigener Abschnitt.
|
||||||
* Eine Lücke unterbricht die Linie — sie wird nicht überbrückt und nicht auf
|
* Eine Luecke unterbricht die Linie — sie wird nicht ueberbrueckt und nicht auf
|
||||||
* null gezogen.
|
* null gezogen.
|
||||||
*/
|
*/
|
||||||
function buildPathSegments(
|
function buildPathSegments(
|
||||||
@ -263,7 +263,7 @@ function isIsolatedPoint(points: readonly DataPoint[], pointIndex: number): bool
|
|||||||
return !previousHasValue && !nextHasValue;
|
return !previousHasValue && !nextHasValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sucht den größten Wert aller Reihen. */
|
/** Sucht den groessten Wert aller Reihen. */
|
||||||
function findMaximumValue(series: readonly Series[]): number | null {
|
function findMaximumValue(series: readonly Series[]): number | null {
|
||||||
let maximumValue: number | null = null;
|
let maximumValue: number | null = null;
|
||||||
|
|
||||||
@ -281,8 +281,8 @@ function findMaximumValue(series: readonly Series[]): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Maximum von null macht die Division unmöglich. Eine Reihe aus lauter
|
// Ein Maximum von null macht die Division unmoeglich. Eine Reihe aus lauter
|
||||||
// Nullen ist ein gültiger Fall — etwa null Fehlschläge.
|
// Nullen ist ein gueltiger Fall — etwa null Fehlschlaege.
|
||||||
if (maximumValue !== null && maximumValue === 0) {
|
if (maximumValue !== null && maximumValue === 0) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@ -290,7 +290,7 @@ function findMaximumValue(series: readonly Series[]): number | null {
|
|||||||
return maximumValue;
|
return maximumValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Faktor zwischen zwei Größeneinheiten. */
|
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||||
const BYTE_UNIT_STEP = 1024;
|
const BYTE_UNIT_STEP = 1024;
|
||||||
|
|
||||||
/** Schreibt einen Achsenwert lesbar. */
|
/** Schreibt einen Achsenwert lesbar. */
|
||||||
|
|||||||
@ -1,22 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Kennzahlen und Diagramme.
|
* Kennzahlen und Diagramme.
|
||||||
*
|
*
|
||||||
* Alle zwölf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage
|
* Alle zwoelf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage
|
||||||
* — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt.
|
* — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt.
|
||||||
* Dieselbe Regel wie in der Übersicht: Ein weggelassenes Diagramm sieht aus wie
|
* Dieselbe Regel wie in der Uebersicht: Ein weggelassenes Diagramm sieht aus wie
|
||||||
* ein vergessenes, ein leeres wie eine Anlage ohne Betrieb.
|
* ein vergessenes, ein leeres wie eine Anlage ohne Betrieb.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
import { describeApiError } from '@/api/useMutation';
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { Card, CardContent, CardHeader, ErrorState, LoadingState, SelectInput } from '@/components/ui';
|
|
||||||
import { LineChart } from './LineChart';
|
import { LineChart } from './LineChart';
|
||||||
import { fetchChart, fetchChartCatalog } from './metricsApi';
|
import { fetchChart, fetchChartCatalog } from './metricsApi';
|
||||||
import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi';
|
import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi';
|
||||||
|
|
||||||
/** Auswählbare Zeiträume mit ihrer Beschriftung. */
|
/** Auswaehlbare Zeitraeume mit ihrer Beschriftung. */
|
||||||
const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
||||||
{ value: '1h', label: 'Letzte Stunde' },
|
{ value: '1h', label: 'Letzte Stunde' },
|
||||||
{ value: '24h', label: 'Letzte 24 Stunden' },
|
{ value: '24h', label: 'Letzte 24 Stunden' },
|
||||||
@ -34,18 +32,22 @@ export function MetricsPage(): React.JSX.Element {
|
|||||||
const { loadState, data, loadError, reload } = useApiResource<ChartCatalog>(loadCatalog);
|
const { loadState, data, loadError, reload } = useApiResource<ChartCatalog>(loadCatalog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="page">
|
||||||
<PageHeader
|
<header className="page__header">
|
||||||
title="Kennzahlen"
|
<h1 className="page__title">Kennzahlen</h1>
|
||||||
description={
|
|
||||||
data !== null
|
{data !== null && (
|
||||||
? `${data.available_count} von ${data.charts.length} Diagrammen haben eine Datengrundlage.`
|
<span className="page__meta">
|
||||||
: undefined
|
{data.available_count} von {data.charts.length} Diagrammen haben eine Datengrundlage
|
||||||
}
|
</span>
|
||||||
actions={
|
)}
|
||||||
<SelectInput
|
</header>
|
||||||
aria-label="Zeitraum"
|
|
||||||
className="w-52"
|
<div className="filter-bar">
|
||||||
|
<label className="filter-bar__field">
|
||||||
|
<span className="filter-bar__label">Zeitraum</span>
|
||||||
|
<select
|
||||||
|
className="filter-bar__select"
|
||||||
value={selectedRange}
|
value={selectedRange}
|
||||||
onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)}
|
onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)}
|
||||||
>
|
>
|
||||||
@ -54,28 +56,17 @@ export function MetricsPage(): React.JSX.Element {
|
|||||||
{rangeOption.label}
|
{rangeOption.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</select>
|
||||||
}
|
</label>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
{loadState === 'loading' ? (
|
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />}
|
||||||
<Card>
|
{loadState === 'failed' && loadError !== null && (
|
||||||
<LoadingState label="Die Kennzahlen werden geladen …" />
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
</Card>
|
)}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'failed' && loadError !== null ? (
|
{loadState === 'loaded' && data !== null && (
|
||||||
<Card>
|
<div className="chart-list">
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(loadError)}
|
|
||||||
requestId={loadError.requestId}
|
|
||||||
onRetry={reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null ? (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
|
||||||
{data.charts.map((chartDefinition) => (
|
{data.charts.map((chartDefinition) => (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
key={chartDefinition.metric}
|
key={chartDefinition.metric}
|
||||||
@ -84,8 +75,8 @@ export function MetricsPage(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +84,7 @@ export function MetricsPage(): React.JSX.Element {
|
|||||||
interface ChartCardProperties {
|
interface ChartCardProperties {
|
||||||
/** Das darzustellende Diagramm. */
|
/** Das darzustellende Diagramm. */
|
||||||
readonly definition: ChartDefinition;
|
readonly definition: ChartDefinition;
|
||||||
/** Der gewählte Zeitraum. */
|
/** Der gewaehlte Zeitraum. */
|
||||||
readonly timeRange: string;
|
readonly timeRange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,8 +96,8 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Ein Diagramm ohne Datengrundlage wird gar nicht erst abgerufen: Der Server
|
// Ein Diagramm ohne Datengrundlage wird gar nicht erst abgerufen: Der Server
|
||||||
// antwortete mit 501, und ein Fehler in der Oberfläche sähe aus wie eine
|
// antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine
|
||||||
// Störung. Es ist keine — die Funktion gibt es nur noch nicht.
|
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht.
|
||||||
const { loadState, data, loadError, reload } = useApiResource<Chart>(
|
const { loadState, data, loadError, reload } = useApiResource<Chart>(
|
||||||
loadChart,
|
loadChart,
|
||||||
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
|
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
|
||||||
@ -114,41 +105,35 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
|
|||||||
|
|
||||||
if (!definition.available) {
|
if (!definition.available) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<article className="chart-card chart-card--unavailable">
|
||||||
<CardHeader title={definition.title} description="noch nicht verfügbar" />
|
<h2 className="chart-card__title">{definition.title}</h2>
|
||||||
<CardContent>
|
<p className="chart-card__badge">noch nicht verfuegbar</p>
|
||||||
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
|
<p className="chart-card__description">{definition.unavailable_reason}</p>
|
||||||
</CardContent>
|
</article>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<article className="chart-card">
|
||||||
<CardHeader title={definition.title} description={definition.description} />
|
<h2 className="chart-card__title">{definition.title}</h2>
|
||||||
<CardContent>
|
<p className="chart-card__description">{definition.description}</p>
|
||||||
{loadState === 'loading' ? <LoadingState label="Die Reihe wird geladen …" /> : null}
|
|
||||||
|
|
||||||
{loadState === 'failed' && loadError !== null ? (
|
{loadState === 'loading' && <LoadingState what="Die Reihe" />}
|
||||||
<ErrorState
|
{loadState === 'failed' && loadError !== null && (
|
||||||
message={describeApiError(loadError)}
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
requestId={loadError.requestId}
|
)}
|
||||||
onRetry={reload}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null ? (
|
{loadState === 'loaded' && data !== null && (
|
||||||
<>
|
<>
|
||||||
<LineChart series={data.series} unit={definition.unit} />
|
<LineChart series={data.series} unit={definition.unit} />
|
||||||
|
|
||||||
{/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen
|
{/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen
|
||||||
aus wie ein Trend und sind keiner. */}
|
aus wie ein Trend und sind keiner. */}
|
||||||
{data.note !== undefined && data.note !== '' ? (
|
{data.note !== undefined && data.note !== '' && (
|
||||||
<p className="mt-2 text-xs text-fg-muted">{data.note}</p>
|
<p className="chart-card__note">{data.note}</p>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</CardContent>
|
</article>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,9 +17,9 @@ export type SeriesUnit =
|
|||||||
export interface ChartDefinition {
|
export interface ChartDefinition {
|
||||||
/** Bezeichner im Pfad. */
|
/** Bezeichner im Pfad. */
|
||||||
readonly metric: string;
|
readonly metric: string;
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärung. */
|
/** Erklaerung. */
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
/** Einheit der Werte. */
|
/** Einheit der Werte. */
|
||||||
readonly unit: SeriesUnit;
|
readonly unit: SeriesUnit;
|
||||||
@ -27,7 +27,7 @@ export interface ChartDefinition {
|
|||||||
readonly source: string;
|
readonly source: string;
|
||||||
/** Meldet, ob es eine Datengrundlage gibt. */
|
/** Meldet, ob es eine Datengrundlage gibt. */
|
||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
/** Erklärt eine fehlende Datengrundlage. */
|
/** Erklaert eine fehlende Datengrundlage. */
|
||||||
readonly unavailable_reason?: string;
|
readonly unavailable_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ export interface ChartCatalog {
|
|||||||
readonly charts: readonly ChartDefinition[];
|
readonly charts: readonly ChartDefinition[];
|
||||||
/** Zahl der Diagramme mit Datengrundlage. */
|
/** Zahl der Diagramme mit Datengrundlage. */
|
||||||
readonly available_count: number;
|
readonly available_count: number;
|
||||||
/** Wählbare Zeiträume. */
|
/** Waehlbare Zeitraeume. */
|
||||||
readonly ranges: readonly string[];
|
readonly ranges: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ export interface ChartCatalog {
|
|||||||
export interface DataPoint {
|
export interface DataPoint {
|
||||||
/** Beginn des Zeitfensters in UTC. */
|
/** Beginn des Zeitfensters in UTC. */
|
||||||
readonly timestamp: string;
|
readonly timestamp: string;
|
||||||
/** Wert; nur gültig, wenn has_value gesetzt ist. */
|
/** Wert; nur gueltig, wenn has_value gesetzt ist. */
|
||||||
readonly value?: number;
|
readonly value?: number;
|
||||||
/**
|
/**
|
||||||
* Meldet, ob in diesem Zeitfenster etwas gemessen wurde.
|
* Meldet, ob in diesem Zeitfenster etwas gemessen wurde.
|
||||||
@ -70,13 +70,13 @@ export interface Series {
|
|||||||
readonly points: readonly DataPoint[];
|
readonly points: readonly DataPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein vollständiges Diagramm. */
|
/** Ein vollstaendiges Diagramm. */
|
||||||
export interface Chart {
|
export interface Chart {
|
||||||
/** Bezeichner. */
|
/** Bezeichner. */
|
||||||
readonly metric: string;
|
readonly metric: string;
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärung. */
|
/** Erklaerung. */
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
/** Ausgewerteter Zeitraum. */
|
/** Ausgewerteter Zeitraum. */
|
||||||
readonly window: {
|
readonly window: {
|
||||||
@ -90,12 +90,12 @@ export interface Chart {
|
|||||||
readonly note?: string;
|
readonly note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt den Katalog aller Diagramme. */
|
/** Laedt den Katalog aller Diagramme. */
|
||||||
export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> {
|
export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> {
|
||||||
return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {});
|
return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt ein Diagramm für den angegebenen Zeitraum. */
|
/** Laedt ein Diagramm fuer den angegebenen Zeitraum. */
|
||||||
export async function fetchChart(
|
export async function fetchChart(
|
||||||
metricName: string,
|
metricName: string,
|
||||||
timeRange: string,
|
timeRange: string,
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Der wichtigste Test ist der dritte: Eine Kennzahl ohne Messung darf nirgends
|
* Der wichtigste Test ist der dritte: Eine Kennzahl ohne Messung darf nirgends
|
||||||
* als Zahl erscheinen — weder in der Vorschau noch in der Datei. In einem
|
* als Zahl erscheinen — weder in der Vorschau noch in der Datei. In einem
|
||||||
* Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlässt die
|
* Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlaesst die
|
||||||
* Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort
|
* Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort
|
||||||
* überlebt eine erfundene Null jede mündliche Erläuterung.
|
* ueberlebt eine erfundene Null jede muendliche Erlaeuterung.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ReportsPage } from './ReportsPage';
|
import { ReportsPage } from './ReportsPage';
|
||||||
|
|
||||||
/** Baut eine Antwort in der Standardhülle. */
|
/** Baut eine Antwort in der Standardhuelle. */
|
||||||
function buildJsonResponse(payload: unknown): Response {
|
function buildJsonResponse(payload: unknown): Response {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -28,14 +28,14 @@ const reportCatalog = [
|
|||||||
{
|
{
|
||||||
type: 'daily_backup',
|
type: 'daily_backup',
|
||||||
title: 'Tagesbericht Sicherungen',
|
title: 'Tagesbericht Sicherungen',
|
||||||
description: 'Alle Sicherungsläufe eines Tages.',
|
description: 'Alle Sicherungslaeufe eines Tages.',
|
||||||
period_kind: 'range',
|
period_kind: 'range',
|
||||||
default_period: 'letzte 24 Stunden',
|
default_period: 'letzte 24 Stunden',
|
||||||
formats: ['json', 'csv', 'pdf'],
|
formats: ['json', 'csv', 'pdf'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'repository_capacity',
|
type: 'repository_capacity',
|
||||||
title: 'Bericht Repository-Kapazität',
|
title: 'Bericht Repository-Kapazitaet',
|
||||||
description: 'Belegung und Zustand aller Repositories.',
|
description: 'Belegung und Zustand aller Repositories.',
|
||||||
period_kind: 'point_in_time',
|
period_kind: 'point_in_time',
|
||||||
formats: ['json', 'csv', 'pdf'],
|
formats: ['json', 'csv', 'pdf'],
|
||||||
@ -46,34 +46,34 @@ const reportCatalog = [
|
|||||||
const reportWithUnknownMetric = {
|
const reportWithUnknownMetric = {
|
||||||
type: 'daily_backup',
|
type: 'daily_backup',
|
||||||
title: 'Tagesbericht Sicherungen',
|
title: 'Tagesbericht Sicherungen',
|
||||||
description: 'Alle Sicherungsläufe eines Tages.',
|
description: 'Alle Sicherungslaeufe eines Tages.',
|
||||||
period_from: '2026-08-12T00:00:00Z',
|
period_from: '2026-08-12T00:00:00Z',
|
||||||
period_to: '2026-08-13T00:00:00Z',
|
period_to: '2026-08-13T00:00:00Z',
|
||||||
generated_at: '2026-08-13T06:00:00Z',
|
generated_at: '2026-08-13T06:00:00Z',
|
||||||
generated_by: 'prüfer',
|
generated_by: 'pruefer',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'Überblick',
|
title: 'Ueberblick',
|
||||||
metrics: [
|
metrics: [
|
||||||
{ label: 'Läufe insgesamt', value: 0, unit: 'count', is_known: true },
|
{ label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true },
|
||||||
{
|
{
|
||||||
label: 'Erfolgsquote',
|
label: 'Erfolgsquote',
|
||||||
unit: 'percent',
|
unit: 'percent',
|
||||||
is_known: false,
|
is_known: false,
|
||||||
unknown_reason: 'Im gewählten Zeitraum wurde kein Lauf abgeschlossen.',
|
unknown_reason: 'Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tables: [
|
tables: [
|
||||||
{
|
{
|
||||||
title: 'Einzelne Läufe',
|
title: 'Einzelne Laeufe',
|
||||||
columns: ['Auftrag', 'Ergebnis'],
|
columns: ['Auftrag', 'Ergebnis'],
|
||||||
rows: [],
|
rows: [],
|
||||||
empty_notice: 'Im gewählten Zeitraum wurde kein Lauf begonnen.',
|
empty_notice: 'Im gewaehlten Zeitraum wurde kein Lauf begonnen.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: ['Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'],
|
notes: ['Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Berichte', () => {
|
describe('Berichte', () => {
|
||||||
@ -94,10 +94,10 @@ describe('Berichte', () => {
|
|||||||
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
|
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('Bericht Repository-Kapazität')).toBeInTheDocument();
|
expect(screen.getByText('Bericht Repository-Kapazitaet')).toBeInTheDocument();
|
||||||
|
|
||||||
// Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er
|
// Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er
|
||||||
// nicht auswertet, wäre ein Versprechen, das er nicht einlöst.
|
// nicht auswertet, waere ein Versprechen, das er nicht einloest.
|
||||||
expect(screen.getByText('Zustandsbericht')).toBeInTheDocument();
|
expect(screen.getByText('Zustandsbericht')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
|
expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -119,19 +119,19 @@ describe('Berichte', () => {
|
|||||||
expect(screen.getByText('Erfolgsquote')).toBeInTheDocument();
|
expect(screen.getByText('Erfolgsquote')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Die ungemessene Quote steht als „nicht gemessen" da — mit Begründung.
|
// Die ungemessene Quote steht als „nicht gemessen" da — mit Begruendung.
|
||||||
expect(screen.getByText('nicht gemessen')).toBeInTheDocument();
|
expect(screen.getByText('nicht gemessen')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'),
|
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.', {
|
||||||
|
selector: '.reports__metric-reason',
|
||||||
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Die gemessene Null bleibt dagegen sichtbar: „null Läufe" ist eine
|
// Die gemessene Null bleibt dagegen sichtbar: „null Laeufe" ist eine
|
||||||
// Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden.
|
// Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden.
|
||||||
// Die gemessene Null steht als Zahl da und trägt **keine** Begründung —
|
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling;
|
||||||
// sie braucht keine. Genau daran unterscheidet sie sich von der
|
|
||||||
// ungemessenen Quote daneben.
|
|
||||||
const measuredValue = screen.getByText('Läufe insgesamt').nextElementSibling;
|
|
||||||
expect(measuredValue?.textContent).toBe('0');
|
expect(measuredValue?.textContent).toBe('0');
|
||||||
|
expect(measuredValue?.className).not.toContain('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('benennt eine leere Tabelle, statt sie wegzulassen', async () => {
|
it('benennt eine leere Tabelle, statt sie wegzulassen', async () => {
|
||||||
@ -148,11 +148,11 @@ describe('Berichte', () => {
|
|||||||
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
|
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Einzelne Läufe')).toBeInTheDocument();
|
expect(screen.getByText('Einzelne Laeufe')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Im gewählten Zeitraum wurde kein Lauf begonnen.'),
|
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Berichte.
|
* Berichte.
|
||||||
*
|
*
|
||||||
* Die Seite, die in Phase 12 als „noch nicht verfügbar" im Menü stand.
|
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand.
|
||||||
*
|
*
|
||||||
* Zwei Dinge unterscheidet sie von einer gewöhnlichen Berichtsmaske:
|
* Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske:
|
||||||
*
|
*
|
||||||
* Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV
|
* Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV
|
||||||
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
|
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
|
||||||
@ -16,18 +16,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { ApiError, downloadApiFile, requestApi } from '../../api/client';
|
import { ApiError, downloadApiFile, requestApi } from '../../api/client';
|
||||||
import { useApiResource } from '../../api/useApiResource';
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
/** Art des Zeitbezugs eines Berichts. */
|
/** Art des Zeitbezugs eines Berichts. */
|
||||||
type PeriodKind = 'range' | 'point_in_time';
|
type PeriodKind = 'range' | 'point_in_time';
|
||||||
@ -41,13 +30,13 @@ interface ReportCatalogEntry {
|
|||||||
readonly type: string;
|
readonly type: string;
|
||||||
/** Bezeichnung. */
|
/** Bezeichnung. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärt, welche Frage der Bericht beantwortet. */
|
/** Erklaert, welche Frage der Bericht beantwortet. */
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
/** Art des Zeitbezugs. */
|
/** Art des Zeitbezugs. */
|
||||||
readonly period_kind: PeriodKind;
|
readonly period_kind: PeriodKind;
|
||||||
/** Beschreibung des Standardzeitraums. */
|
/** Beschreibung des Standardzeitraums. */
|
||||||
readonly default_period?: string;
|
readonly default_period?: string;
|
||||||
/** Verfügbare Ausgabeformate. */
|
/** Verfuegbare Ausgabeformate. */
|
||||||
readonly formats: readonly ReportFormat[];
|
readonly formats: readonly ReportFormat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +44,7 @@ interface ReportCatalogEntry {
|
|||||||
interface ReportMetric {
|
interface ReportMetric {
|
||||||
/** Beschriftung. */
|
/** Beschriftung. */
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
/** Gemessener Wert; nur gültig, wenn is_known gilt. */
|
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */
|
||||||
readonly value?: number;
|
readonly value?: number;
|
||||||
/** Einheit. */
|
/** Einheit. */
|
||||||
readonly unit?: string;
|
readonly unit?: string;
|
||||||
@ -63,13 +52,13 @@ interface ReportMetric {
|
|||||||
readonly text?: string;
|
readonly text?: string;
|
||||||
/** Meldet, ob der Wert gemessen wurde. */
|
/** Meldet, ob der Wert gemessen wurde. */
|
||||||
readonly is_known: boolean;
|
readonly is_known: boolean;
|
||||||
/** Erklärt einen fehlenden Wert. */
|
/** Erklaert einen fehlenden Wert. */
|
||||||
readonly unknown_reason?: string;
|
readonly unknown_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine Tabelle eines Berichts. */
|
/** Eine Tabelle eines Berichts. */
|
||||||
interface ReportTable {
|
interface ReportTable {
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Spaltenbeschriftungen. */
|
/** Spaltenbeschriftungen. */
|
||||||
readonly columns: readonly string[];
|
readonly columns: readonly string[];
|
||||||
@ -81,9 +70,9 @@ interface ReportTable {
|
|||||||
|
|
||||||
/** Ein Abschnitt eines Berichts. */
|
/** Ein Abschnitt eines Berichts. */
|
||||||
interface ReportSection {
|
interface ReportSection {
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärung. */
|
/** Erklaerung. */
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
/** Kennzahlen. */
|
/** Kennzahlen. */
|
||||||
readonly metrics?: readonly ReportMetric[];
|
readonly metrics?: readonly ReportMetric[];
|
||||||
@ -97,7 +86,7 @@ interface GeneratedReport {
|
|||||||
readonly type: string;
|
readonly type: string;
|
||||||
/** Bezeichnung. */
|
/** Bezeichnung. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärung. */
|
/** Erklaerung. */
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
/** Beginn des Zeitraums. */
|
/** Beginn des Zeitraums. */
|
||||||
readonly period_from: string;
|
readonly period_from: string;
|
||||||
@ -109,19 +98,19 @@ interface GeneratedReport {
|
|||||||
readonly generated_by?: string;
|
readonly generated_by?: string;
|
||||||
/** Abschnitte. */
|
/** Abschnitte. */
|
||||||
readonly sections: readonly ReportSection[];
|
readonly sections: readonly ReportSection[];
|
||||||
/** Benannte Lücken und Einschränkungen. */
|
/** Benannte Luecken und Einschraenkungen. */
|
||||||
readonly notes?: readonly string[];
|
readonly notes?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zustand einer laufenden Ausgabe. */
|
/** Zustand einer laufenden Ausgabe. */
|
||||||
interface ExportState {
|
interface ExportState {
|
||||||
/** Format, das gerade erzeugt wird; null, wenn nichts läuft. */
|
/** Format, das gerade erzeugt wird; null, wenn nichts laeuft. */
|
||||||
readonly runningFormat: ReportFormat | null;
|
readonly runningFormat: ReportFormat | null;
|
||||||
/** Fehler der letzten Ausgabe. */
|
/** Fehler der letzten Ausgabe. */
|
||||||
readonly exportError: ApiError | null;
|
readonly exportError: ApiError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt den Berichtskatalog. */
|
/** Laedt den Berichtskatalog. */
|
||||||
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
|
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
|
||||||
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
|
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
|
||||||
}
|
}
|
||||||
@ -193,57 +182,44 @@ export function ReportsPage(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (catalogResource.loadState === 'loading') {
|
if (catalogResource.loadState === 'loading') {
|
||||||
return (
|
return <LoadingState what="die Berichte" />;
|
||||||
<Card>
|
|
||||||
<LoadingState label="Die Berichte werden geladen …" />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
|
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
|
||||||
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
|
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
|
||||||
// blieb — dann steht der Katalog trotzdem nicht zur Verfügung.
|
// blieb — dann steht der Katalog trotzdem nicht zur Verfuegung.
|
||||||
return (
|
return catalogResource.loadError === null ? (
|
||||||
<Card>
|
<p className="page__state">Der Berichtskatalog steht nicht zur Verfügung.</p>
|
||||||
<ErrorState
|
) : (
|
||||||
title="Der Berichtskatalog steht nicht zur Verfügung."
|
<ErrorState error={catalogResource.loadError} onRetry={catalogResource.reload} />
|
||||||
message={
|
|
||||||
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
|
|
||||||
}
|
|
||||||
requestId={catalogResource.loadError?.requestId}
|
|
||||||
onRetry={catalogResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="page">
|
||||||
<PageHeader
|
<header className="page__header">
|
||||||
title="Berichte"
|
<h1 className="page__title">Berichte</h1>
|
||||||
description="Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei — einschließlich der Angaben, die sich nicht messen ließen."
|
<p className="page__subtitle">
|
||||||
/>
|
Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei —
|
||||||
|
einschließlich der Angaben, die sich nicht messen ließen.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="reports__catalog">
|
||||||
{catalogResource.data.map((catalogEntry) => (
|
{catalogResource.data.map((catalogEntry) => (
|
||||||
<button
|
<button
|
||||||
key={catalogEntry.type}
|
key={catalogEntry.type}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectReport(catalogEntry.type)}
|
className={
|
||||||
className={cn(
|
|
||||||
'rounded-lg border p-4 text-left transition-colors',
|
|
||||||
catalogEntry.type === selectedType
|
catalogEntry.type === selectedType
|
||||||
? 'border-accent bg-accent-subtle'
|
? 'reports__entry reports__entry--selected'
|
||||||
: 'border-line bg-card hover:bg-hover',
|
: 'reports__entry'
|
||||||
)}
|
}
|
||||||
|
onClick={() => selectReport(catalogEntry.type)}
|
||||||
>
|
>
|
||||||
<span className="block font-medium text-fg">{catalogEntry.title}</span>
|
<span className="reports__entry-title">{catalogEntry.title}</span>
|
||||||
<span className="mt-1 block text-sm text-fg-muted">
|
<span className="reports__entry-description">{catalogEntry.description}</span>
|
||||||
{catalogEntry.description}
|
<span className="reports__entry-period">
|
||||||
</span>
|
|
||||||
<span className="mt-2 block text-xs text-fg-subtle">
|
|
||||||
{/* Ein Zustandsbericht bekommt keinen Zeitraum. Die Belegung wird
|
|
||||||
nicht historisiert; "vom letzten Dienstag" kann es nicht geben. */}
|
|
||||||
{catalogEntry.period_kind === 'point_in_time'
|
{catalogEntry.period_kind === 'point_in_time'
|
||||||
? 'Zustandsbericht'
|
? 'Zustandsbericht'
|
||||||
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
|
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
|
||||||
@ -252,19 +228,19 @@ export function ReportsPage(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedEntry !== null ? (
|
{selectedEntry !== null && (
|
||||||
<ReportPanel
|
<ReportPanel
|
||||||
catalogEntry={selectedEntry}
|
catalogEntry={selectedEntry}
|
||||||
previewReport={previewReport}
|
previewReport={previewReport}
|
||||||
exportState={exportState}
|
exportState={exportState}
|
||||||
onExport={(outputFormat) => void generateReport(selectedEntry.type, outputFormat)}
|
onExport={(outputFormat) => void generateReport(selectedEntry.type, outputFormat)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt einen erzeugten Bericht mit seinen Ausgabemöglichkeiten. */
|
/** Zeigt einen erzeugten Bericht mit seinen Ausgabemoeglichkeiten. */
|
||||||
function ReportPanel(properties: {
|
function ReportPanel(properties: {
|
||||||
readonly catalogEntry: ReportCatalogEntry;
|
readonly catalogEntry: ReportCatalogEntry;
|
||||||
readonly previewReport: GeneratedReport | null;
|
readonly previewReport: GeneratedReport | null;
|
||||||
@ -274,47 +250,44 @@ function ReportPanel(properties: {
|
|||||||
const { catalogEntry, previewReport, exportState, onExport } = properties;
|
const { catalogEntry, previewReport, exportState, onExport } = properties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<article className="reports__panel">
|
||||||
<CardHeader
|
<header className="reports__panel-header">
|
||||||
title={catalogEntry.title}
|
<h2 className="reports__panel-title">{catalogEntry.title}</h2>
|
||||||
action={
|
<div className="reports__actions">
|
||||||
<div className="flex gap-2">
|
<button
|
||||||
<Button
|
type="button"
|
||||||
size="sm"
|
className="button button--secondary"
|
||||||
isLoading={exportState.runningFormat === 'csv'}
|
|
||||||
disabled={exportState.runningFormat !== null}
|
disabled={exportState.runningFormat !== null}
|
||||||
onClick={() => onExport('csv')}
|
onClick={() => onExport('csv')}
|
||||||
>
|
>
|
||||||
CSV herunterladen
|
{exportState.runningFormat === 'csv' ? 'CSV wird erzeugt …' : 'CSV herunterladen'}
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
size="sm"
|
type="button"
|
||||||
isLoading={exportState.runningFormat === 'pdf'}
|
className="button button--secondary"
|
||||||
disabled={exportState.runningFormat !== null}
|
disabled={exportState.runningFormat !== null}
|
||||||
onClick={() => onExport('pdf')}
|
onClick={() => onExport('pdf')}
|
||||||
>
|
>
|
||||||
PDF herunterladen
|
{exportState.runningFormat === 'pdf' ? 'PDF wird erzeugt …' : 'PDF herunterladen'}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
</header>
|
||||||
/>
|
|
||||||
<CardContent>
|
{exportState.exportError !== null && (
|
||||||
{exportState.exportError !== null ? (
|
<p className="reports__error" role="alert">
|
||||||
<Callout tone="critical" role="alert" className="mb-4">
|
{exportState.exportError.message}
|
||||||
<p>{describeApiError(exportState.exportError)}</p>
|
<span className="reports__request-id">
|
||||||
<p className="mt-1 font-mono text-xs">
|
Vorgang {exportState.exportError.requestId}
|
||||||
Vorgangsnummer: {exportState.exportError.requestId}
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</Callout>
|
)}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{previewReport === null ? (
|
{previewReport === null ? (
|
||||||
<LoadingState label="Der Bericht wird erzeugt …" />
|
<LoadingState what="den Bericht" />
|
||||||
) : (
|
) : (
|
||||||
<ReportPreview report={previewReport} />
|
<ReportPreview report={previewReport} />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</article>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,56 +300,44 @@ function ReportPreview(properties: { readonly report: GeneratedReport }): React.
|
|||||||
const isPointInTime = report.period_from === report.period_to;
|
const isPointInTime = report.period_from === report.period_to;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="reports__preview">
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="reports__meta">
|
||||||
{isPointInTime
|
{isPointInTime
|
||||||
? `Zustand zum ${formatTimestamp(report.period_to)}`
|
? `Zustand zum ${formatTimestamp(report.period_to)}`
|
||||||
: `Zeitraum ${formatTimestamp(report.period_from)} bis ${formatTimestamp(report.period_to)}`}
|
: `Zeitraum ${formatTimestamp(report.period_from)} bis ${formatTimestamp(report.period_to)}`}
|
||||||
{' · '}
|
{' · '}
|
||||||
Erzeugt am {formatTimestamp(report.generated_at)}
|
Erzeugt am {formatTimestamp(report.generated_at)}
|
||||||
{report.generated_by !== undefined && report.generated_by !== ''
|
{report.generated_by !== undefined && report.generated_by !== '' && ` von ${report.generated_by}`}
|
||||||
? ` von ${report.generated_by}`
|
|
||||||
: ''}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{report.sections.map((reportSection) => (
|
{report.sections.map((reportSection) => (
|
||||||
<section key={reportSection.title}>
|
<section key={reportSection.title} className="reports__section">
|
||||||
<h3 className="text-sm font-semibold text-fg">{reportSection.title}</h3>
|
<h3 className="reports__section-title">{reportSection.title}</h3>
|
||||||
{reportSection.description !== undefined ? (
|
{reportSection.description !== undefined && (
|
||||||
<p className="mt-0.5 text-sm text-fg-muted">{reportSection.description}</p>
|
<p className="reports__section-description">{reportSection.description}</p>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 ? (
|
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 && (
|
||||||
<dl className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<dl className="reports__metrics">
|
||||||
{reportSection.metrics.map((reportMetric) => (
|
{reportSection.metrics.map((reportMetric) => (
|
||||||
<div
|
<div key={reportMetric.label} className="reports__metric">
|
||||||
key={reportMetric.label}
|
<dt className="reports__metric-label">{reportMetric.label}</dt>
|
||||||
className="rounded-md border border-line bg-sunken p-3"
|
<dd
|
||||||
>
|
|
||||||
<dt className="text-xs text-fg-subtle">{reportMetric.label}</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
|
|
||||||
Eine erfundene Null überlebt in einem Prüfordner jede
|
|
||||||
mündliche Erläuterung. */}
|
|
||||||
<span
|
|
||||||
className={
|
className={
|
||||||
reportMetric.is_known
|
reportMetric.is_known
|
||||||
? 'text-lg font-semibold tabular text-fg'
|
? 'reports__metric-value'
|
||||||
: 'text-sm text-fg-muted'
|
: 'reports__metric-value reports__metric-value--unknown'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatMetricValue(reportMetric)}
|
{formatMetricValue(reportMetric)}
|
||||||
</span>
|
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined && (
|
||||||
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined ? (
|
<span className="reports__metric-reason">{reportMetric.unknown_reason}</span>
|
||||||
<span className="mt-0.5 block text-xs text-fg-muted">
|
)}
|
||||||
{reportMetric.unknown_reason}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{reportSection.tables?.map((reportTable) => (
|
{reportSection.tables?.map((reportTable) => (
|
||||||
<ReportTableView key={reportTable.title} table={reportTable} />
|
<ReportTableView key={reportTable.title} table={reportTable} />
|
||||||
@ -384,18 +345,16 @@ function ReportPreview(properties: { readonly report: GeneratedReport }): React.
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{report.notes !== undefined && report.notes.length > 0 ? (
|
{report.notes !== undefined && report.notes.length > 0 && (
|
||||||
<section className="border-t border-line pt-4">
|
<section className="reports__notes">
|
||||||
<h3 className="text-sm font-semibold text-fg">Hinweise</h3>
|
<h3 className="reports__section-title">Hinweise</h3>
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="reports__note-list">
|
||||||
{report.notes.map((note) => (
|
{report.notes.map((note) => (
|
||||||
<li key={note} className="text-sm text-fg-muted">
|
<li key={note}>{note}</li>
|
||||||
• {note}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -409,11 +368,9 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
|
|||||||
|
|
||||||
if (table.rows.length === 0) {
|
if (table.rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="reports__table">
|
||||||
<h4 className="text-sm font-medium text-fg">{table.title}</h4>
|
<h4 className="reports__table-title">{table.title}</h4>
|
||||||
<p className="mt-1 text-sm text-fg-muted">
|
<p className="reports__empty">{table.empty_notice ?? 'Keine Einträge.'}</p>
|
||||||
{table.empty_notice ?? 'Keine Einträge.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -421,38 +378,28 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
|
|||||||
const visibleRows = table.rows.slice(0, previewRowLimit);
|
const visibleRows = table.rows.slice(0, previewRowLimit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="reports__table">
|
||||||
<h4 className="text-sm font-medium text-fg">{table.title}</h4>
|
<h4 className="reports__table-title">{table.title}</h4>
|
||||||
|
<div className="reports__table-scroll">
|
||||||
{/* Breite Tabellen laufen in ihrem eigenen Rahmen, nicht auf der Seite. */}
|
<table className="data-table">
|
||||||
<div className="mt-2 overflow-x-auto rounded-md border border-line">
|
|
||||||
<table className="w-full border-collapse text-sm">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line">
|
<tr>
|
||||||
{table.columns.map((columnLabel) => (
|
{table.columns.map((columnLabel) => (
|
||||||
<th
|
<th key={columnLabel}>{columnLabel}</th>
|
||||||
key={columnLabel}
|
|
||||||
scope="col"
|
|
||||||
className="whitespace-nowrap px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-fg-subtle"
|
|
||||||
>
|
|
||||||
{columnLabel}
|
|
||||||
</th>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{/*
|
{/*
|
||||||
Die Position ist der Schlüssel: Eine Berichtszeile trägt keine
|
Die Position ist der Schluessel: Eine Berichtszeile traegt keine
|
||||||
Kennung, und zwei Zeilen können Zeichen für Zeichen gleich sein.
|
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein.
|
||||||
Unbedenklich, weil die Tabelle statisch ist — sie wird weder
|
Unbedenklich, weil die Tabelle statisch ist — sie wird weder
|
||||||
umsortiert noch ergänzt, sondern mit dem Bericht neu aufgebaut.
|
umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut.
|
||||||
*/}
|
*/}
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleRows.map((tableRow, rowIndex) => (
|
{visibleRows.map((tableRow, rowIndex) => (
|
||||||
<tr key={rowIndex} className="border-b border-line/60 last:border-0">
|
<tr key={rowIndex}>
|
||||||
{tableRow.map((cellValue, cellIndex) => (
|
{tableRow.map((cellValue, cellIndex) => (
|
||||||
<td key={cellIndex} className="px-3 py-2 align-top text-fg">
|
<td key={cellIndex}>{cellValue}</td>
|
||||||
{cellValue}
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -460,7 +407,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{table.rows.length > visibleRows.length && (
|
{table.rows.length > visibleRows.length && (
|
||||||
<p className="mt-1.5 text-xs text-fg-muted">
|
<p className="reports__truncation">
|
||||||
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
|
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
|
||||||
heruntergeladene Datei enthält alle.
|
heruntergeladene Datei enthält alle.
|
||||||
</p>
|
</p>
|
||||||
@ -539,7 +486,7 @@ function formatDuration(seconds: number): string {
|
|||||||
* Stellt einen Anteil dar.
|
* Stellt einen Anteil dar.
|
||||||
*
|
*
|
||||||
* Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent
|
* Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent
|
||||||
* liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Übersicht).
|
* liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Uebersicht).
|
||||||
*/
|
*/
|
||||||
function formatPercent(percentValue: number): string {
|
function formatPercent(percentValue: number): string {
|
||||||
if (percentValue > 0 && percentValue < 0.1) {
|
if (percentValue > 0 && percentValue < 0.1) {
|
||||||
@ -555,11 +502,11 @@ function formatTimestamp(isoTimestamp: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Löst den Browser-Download einer Datei aus.
|
* Loest den Browser-Download einer Datei aus.
|
||||||
*
|
*
|
||||||
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das hält der Browser
|
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser
|
||||||
* jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei
|
* jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei
|
||||||
* einem Monatsbericht über eine große Anlage sind das schnell einige Megabyte
|
* einem Monatsbericht ueber eine grosse Anlage sind das schnell einige Megabyte
|
||||||
* je Klick.
|
* je Klick.
|
||||||
*/
|
*/
|
||||||
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {
|
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {
|
||||||
|
|||||||
@ -1,509 +0,0 @@
|
|||||||
/**
|
|
||||||
* Repositories — Liste, Übernahme und Wartung.
|
|
||||||
*
|
|
||||||
* Die Maske zur Übernahme sagt ausdrücklich, dass hier **nichts angelegt**
|
|
||||||
* wird. Ein Repository entsteht auf einem Datenträger; der Server öffnet das
|
|
||||||
* vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis
|
|
||||||
* trägt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
|
|
||||||
*
|
|
||||||
* Der Integritätslauf trennt zwei Lagen, die gern vermengt werden: „Die
|
|
||||||
* Prüfung schlug fehl" und „das Repository ist beschädigt". Das eine ist ein
|
|
||||||
* Problem des Werkzeugs, das andere ein Datenverlust.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Database, HardDrive, Plus, RefreshCw, ShieldCheck, Wrench } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DetailItem,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
adoptRepository,
|
|
||||||
checkRepositoryHealth,
|
|
||||||
listRepositories,
|
|
||||||
measureEnforcement,
|
|
||||||
rebuildCatalog,
|
|
||||||
startIntegrityScan,
|
|
||||||
type IntegrityScanDetails,
|
|
||||||
type Repository,
|
|
||||||
} from './repositoriesApi';
|
|
||||||
|
|
||||||
export function RepositoriesPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
|
||||||
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
|
|
||||||
const [scanResult, setScanResult] = useState<IntegrityScanDetails | null>(null);
|
|
||||||
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
|
|
||||||
|
|
||||||
const [adoptName, setAdoptName] = useState('');
|
|
||||||
const [adoptLocation, setAdoptLocation] = useState('');
|
|
||||||
|
|
||||||
const repositoriesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRepositories(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('repositories.write');
|
|
||||||
|
|
||||||
const adoptMutation = useMutation(adoptRepository, {
|
|
||||||
onSuccess: (repository) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Repository übernommen',
|
|
||||||
`${repository.name} ist jetzt in der Control Plane bekannt.`,
|
|
||||||
);
|
|
||||||
setIsAdoptDialogOpen(false);
|
|
||||||
setAdoptName('');
|
|
||||||
setAdoptLocation('');
|
|
||||||
repositoriesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Die Übernahme schlug fehl',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const scanMutation = useMutation(startIntegrityScan, {
|
|
||||||
onSuccess: (result) => {
|
|
||||||
setScanResult(result);
|
|
||||||
setRepositoryPendingScan(null);
|
|
||||||
|
|
||||||
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
|
|
||||||
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
|
|
||||||
// wird bald nicht mehr ernst genommen.
|
|
||||||
if (result.missing_chunks > 0 || result.corrupted_chunks > 0) {
|
|
||||||
toast.showError(
|
|
||||||
'Der Integritätslauf hat Befunde',
|
|
||||||
`${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Integritätslauf ohne Befund',
|
|
||||||
`${result.chunks_checked.toLocaleString('de-DE')} Blöcke geprüft.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
repositoriesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Der Integritätslauf ließ sich nicht durchführen',
|
|
||||||
`${describeApiError(apiError)} — das ist ein Problem der Prüfung, kein Befund am Repository.`,
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const healthMutation = useMutation(checkRepositoryHealth, {
|
|
||||||
onSuccess: (checkResponse) => {
|
|
||||||
// Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der
|
|
||||||
// Hülle, die zweite in `details`.
|
|
||||||
if (!checkResponse.reachable) {
|
|
||||||
toast.showError(
|
|
||||||
'Das Repository ist nicht erreichbar',
|
|
||||||
checkResponse.error ?? 'Ohne nähere Angabe.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Gesundheitsprüfung abgeschlossen',
|
|
||||||
checkResponse.details?.message ?? 'Das Repository ist erreichbar.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
repositoriesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const enforcementMutation = useMutation(measureEnforcement, {
|
|
||||||
onSuccess: (measurement) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
`Durchsetzungsstufe gemessen: ${measurement.level}`,
|
|
||||||
measurement.explanation,
|
|
||||||
);
|
|
||||||
repositoriesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Messung schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rebuildMutation = useMutation(rebuildCatalog, {
|
|
||||||
onSuccess: (checkResponse) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Katalog neu aufgebaut',
|
|
||||||
`${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
|
||||||
);
|
|
||||||
repositoriesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Neuaufbau schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const repositories = repositoriesResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<Repository>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Repository',
|
|
||||||
render: (repository) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-fg">{repository.name}</p>
|
|
||||||
<p className="truncate font-mono text-xs text-fg-muted">{repository.location}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (repository) => (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<StatusBadge status={repository.status} />
|
|
||||||
{!repository.accepts_backups ? (
|
|
||||||
<span className="text-xs text-warning">nimmt keine Sicherungen an</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'enforcement',
|
|
||||||
header: 'Löschschutz',
|
|
||||||
render: (repository) =>
|
|
||||||
repository.enforcement_level ? (
|
|
||||||
<StatusBadge status={repository.enforcement_level} />
|
|
||||||
) : (
|
|
||||||
// Ungemessen ist nicht "kein Schutz" — es ist keine Aussage.
|
|
||||||
<span className="text-xs text-fg-muted">ungemessen</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'used',
|
|
||||||
header: 'Belegt',
|
|
||||||
numeric: true,
|
|
||||||
render: (repository) => formatBytes(repository.used_bytes),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'hardened',
|
|
||||||
header: 'Gehärtet',
|
|
||||||
render: (repository) =>
|
|
||||||
repository.hardened ? (
|
|
||||||
<ShieldCheck className="size-4 text-healthy" aria-label="gehärtet" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-fg-muted">nein</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (repositoriesResource.loadState === 'failed' && repositoriesResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Repositories" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(repositoriesResource.loadError)}
|
|
||||||
requestId={repositoriesResource.loadError.requestId}
|
|
||||||
onRetry={repositoriesResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Repositories"
|
|
||||||
description="Die Ablageorte der Sicherungen."
|
|
||||||
actions={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Repository übernehmen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={repositories}
|
|
||||||
getRowKey={(repository) => repository.id}
|
|
||||||
onRowClick={(repository) => setSelectedRepository(repository)}
|
|
||||||
isLoading={repositoriesResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Kein Repository eingetragen"
|
|
||||||
emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen."
|
|
||||||
emptyAction={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Repository übernehmen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedRepository ? (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader
|
|
||||||
title={selectedRepository.name}
|
|
||||||
description={selectedRepository.location}
|
|
||||||
action={
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
|
|
||||||
Schließen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<DetailItem label="Zustand">
|
|
||||||
<StatusBadge status={selectedRepository.status} />
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Art">{selectedRepository.repository_type}</DetailItem>
|
|
||||||
<DetailItem label="Belegt">
|
|
||||||
{formatBytes(selectedRepository.used_bytes)}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Zuletzt geprüft">
|
|
||||||
{selectedRepository.last_health_check_at
|
|
||||||
? formatDateTime(selectedRepository.last_health_check_at)
|
|
||||||
: 'Nie'}
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{mayWrite ? (
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
isLoading={healthMutation.isRunning}
|
|
||||||
onClick={() => void healthMutation.run(selectedRepository.id)}
|
|
||||||
>
|
|
||||||
<RefreshCw />
|
|
||||||
Gesundheit prüfen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
isLoading={enforcementMutation.isRunning}
|
|
||||||
onClick={() => void enforcementMutation.run(selectedRepository.id)}
|
|
||||||
>
|
|
||||||
<ShieldCheck />
|
|
||||||
Durchsetzungsstufe messen
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
|
|
||||||
<Database />
|
|
||||||
Integritätslauf
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
isLoading={rebuildMutation.isRunning}
|
|
||||||
onClick={() => void rebuildMutation.run(selectedRepository.id)}
|
|
||||||
>
|
|
||||||
<Wrench />
|
|
||||||
Katalog neu aufbauen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Callout tone="info" className="mt-4">
|
|
||||||
Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei.
|
|
||||||
</Callout>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{scanResult ? (
|
|
||||||
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* --- Übernahme --- */}
|
|
||||||
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader
|
|
||||||
title="Repository übernehmen"
|
|
||||||
description="Ein vorhandenes Repository in die Control Plane aufnehmen."
|
|
||||||
/>
|
|
||||||
<DialogBody>
|
|
||||||
<Callout tone="warning" title="Hier wird nichts angelegt">
|
|
||||||
Ein Repository entsteht auf einem Datenträger:
|
|
||||||
<code className="mt-1 block rounded bg-sunken px-2 py-1 font-mono text-xs">
|
|
||||||
sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
|
|
||||||
</code>
|
|
||||||
Dieser Schritt öffnet das vorhandene und liest seine Kennung aus
|
|
||||||
dem Descriptor.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Bezeichnung"
|
|
||||||
required
|
|
||||||
value={adoptName}
|
|
||||||
onChange={(changeEvent) => setAdoptName(changeEvent.target.value)}
|
|
||||||
placeholder="Hauptziel"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Ablageort"
|
|
||||||
required
|
|
||||||
className="font-mono"
|
|
||||||
value={adoptLocation}
|
|
||||||
onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)}
|
|
||||||
placeholder="/srv/syncova-repository"
|
|
||||||
hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Einträge auf dasselbe Verzeichnis ergäben Wettlauf um die Schreibsperre."
|
|
||||||
/>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsAdoptDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={adoptMutation.isRunning}
|
|
||||||
disabled={!adoptName.trim() || !adoptLocation.trim().startsWith('/')}
|
|
||||||
onClick={() =>
|
|
||||||
void adoptMutation.run({
|
|
||||||
name: adoptName.trim(),
|
|
||||||
location: adoptLocation.trim(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={repositoryPendingScan !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
|
|
||||||
title="Integritätslauf starten"
|
|
||||||
confirmLabel="Lauf starten"
|
|
||||||
isLoading={scanMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (repositoryPendingScan) {
|
|
||||||
void scanMutation.run(repositoryPendingScan.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-fg">
|
|
||||||
Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das.
|
|
||||||
</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ergebnis eines Integritätslaufs. */
|
|
||||||
function IntegrityScanCard({
|
|
||||||
result,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
readonly result: IntegrityScanDetails;
|
|
||||||
readonly onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader
|
|
||||||
title="Ergebnis des Integritätslaufs"
|
|
||||||
action={
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
Schließen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
|
||||||
{hasFindings
|
|
||||||
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
|
|
||||||
: result.summary}
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<DetailItem label="Blöcke geprüft">
|
|
||||||
{result.chunks_checked.toLocaleString('de-DE')}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Fehlend">
|
|
||||||
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
|
|
||||||
{result.missing_chunks.toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Beschädigt">
|
|
||||||
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
|
|
||||||
{result.corrupted_chunks.toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Backups">
|
|
||||||
{result.backups_healthy} von {result.backups_checked} vollständig
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{!result.verified_chunk_contents ? (
|
|
||||||
<Callout tone="warning" className="mt-4">
|
|
||||||
Nur die Kennungen wurden geprüft, nicht die Blockinhalte. Das ist
|
|
||||||
ein halber Nachweis.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{result.affected_backup_ids && result.affected_backup_ids.length > 0 ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Betroffene Backups
|
|
||||||
</p>
|
|
||||||
<ul className="mt-1.5 space-y-1">
|
|
||||||
{result.affected_backup_ids.map((backupIdentifier) => (
|
|
||||||
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
|
|
||||||
{backupIdentifier}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kachel mit der Speicherbelegung, für die Übersicht. */
|
|
||||||
export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) {
|
|
||||||
// Ohne hinterlegte Kapazität gibt es keinen Prozentsatz. Ihn zu erfinden
|
|
||||||
// wäre die häufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
|
|
||||||
const hasCapacity =
|
|
||||||
repository.total_bytes !== undefined && repository.total_bytes > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<HardDrive className="size-4 text-fg-subtle" aria-hidden />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm text-fg">{repository.name}</p>
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{hasCapacity
|
|
||||||
? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}`
|
|
||||||
: `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazität nicht hinterlegt`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regressionstest zum schwarzen Bildschirm auf /retention.
|
|
||||||
*
|
|
||||||
* `GET /retention-policies` liefert ein **Objekt** `{ policies, predefined }`,
|
|
||||||
* als einziger von zehn geprüften Listenendpunkten. Die Oberfläche behandelte
|
|
||||||
* es als Array; `map` gibt es auf einem Objekt nicht, React hängte den ganzen
|
|
||||||
* Baum aus, und übrig blieb eine leere Seite — im dunklen Thema schwarz.
|
|
||||||
*
|
|
||||||
* Der Test füttert die **echte** Antwortform. Ein Test mit einem Array hätte
|
|
||||||
* den Fehler nie gefunden — und genau das war passiert.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ToastProvider } from '@/components/ui';
|
|
||||||
import { RetentionPage } from './RetentionPage';
|
|
||||||
|
|
||||||
/** Baut eine Antwort in der Hülle der API. */
|
|
||||||
function apiResponse(payload: unknown): Response {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
||||||
json: async () => ({ data: payload, meta: { request_id: 'req-1' } }),
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Die Antwortform, die der Server tatsächlich liefert. */
|
|
||||||
const echteAntwort = {
|
|
||||||
policies: [],
|
|
||||||
predefined: [
|
|
||||||
{
|
|
||||||
name: '7 Tage',
|
|
||||||
description: 'Behalten werden alles jünger als 7 Tage, die letzten 1 Backups.',
|
|
||||||
keep_within_seconds: 604800,
|
|
||||||
keep_last: 1,
|
|
||||||
keep_daily: 0,
|
|
||||||
keep_weekly: 0,
|
|
||||||
keep_monthly: 0,
|
|
||||||
keep_yearly: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Aufbewahrung', () => {
|
|
||||||
it('stürzt nicht ab, wenn die Regelliste ein Objekt ist', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(async (requestUrl: string) =>
|
|
||||||
String(requestUrl).includes('retention-policies')
|
|
||||||
? apiResponse(echteAntwort)
|
|
||||||
: apiResponse([]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ToastProvider>
|
|
||||||
<RetentionPage grantedPermissions={['*']} />
|
|
||||||
</ToastProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Die Seite steht, statt den Baum abzureißen.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Und die mitgelieferten Vorlagen erscheinen als anklickbare Kacheln.
|
|
||||||
expect(await screen.findByText('7 Tage')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('kommt ohne die beiden Felder zurecht', async () => {
|
|
||||||
// Ein Server, der beide Felder weglässt, darf die Seite nicht schwärzen.
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(async (requestUrl: string) =>
|
|
||||||
String(requestUrl).includes('retention-policies')
|
|
||||||
? apiResponse({})
|
|
||||||
: apiResponse([]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ToastProvider>
|
|
||||||
<RetentionPage grantedPermissions={['*']} />
|
|
||||||
</ToastProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('Keine Aufbewahrungsregel')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,491 +0,0 @@
|
|||||||
/**
|
|
||||||
* Aufbewahrung — Regeln und Anwendung.
|
|
||||||
*
|
|
||||||
* Zwei Dinge tragen diese Seite:
|
|
||||||
*
|
|
||||||
* - **`keep_last` schützt das letzte vorhandene Backup.** Ohne es löschte
|
|
||||||
* „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup —
|
|
||||||
* genau dann, wenn man es am dringendsten braucht. Eine Regel ganz ohne
|
|
||||||
* Haltevorgabe wird abgelehnt; wird eine 1 ergänzt, sagt die Oberfläche das,
|
|
||||||
* statt es still zu tun.
|
|
||||||
* - **Die Vorschau ist keine Formalie.** Sie löscht nichts und liefert die
|
|
||||||
* Zahl, die danach wörtlich bestätigt werden muss.
|
|
||||||
*
|
|
||||||
* Dass beim Löschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
|
|
||||||
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rückfrage.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
ErrorState,
|
|
||||||
SelectInput,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
applyRetention,
|
|
||||||
createRetentionPolicy,
|
|
||||||
deleteRetentionPolicy,
|
|
||||||
listRepositories,
|
|
||||||
listRetentionPolicies,
|
|
||||||
previewRetention,
|
|
||||||
type RetentionPolicy,
|
|
||||||
type RetentionPreview,
|
|
||||||
} from './repositoriesApi';
|
|
||||||
|
|
||||||
export function RetentionPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
||||||
const [policyPendingDeletion, setPolicyPendingDeletion] = useState<RetentionPolicy | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [previewResult, setPreviewResult] = useState<RetentionPreview | null>(null);
|
|
||||||
const [isApplyDialogOpen, setIsApplyDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const [selectedRepositoryId, setSelectedRepositoryId] = useState('');
|
|
||||||
const [selectedPolicyId, setSelectedPolicyId] = useState('');
|
|
||||||
|
|
||||||
const [draftName, setDraftName] = useState('');
|
|
||||||
const [draftKeepDays, setDraftKeepDays] = useState('30');
|
|
||||||
const [draftKeepLast, setDraftKeepLast] = useState('3');
|
|
||||||
const [draftKeepDaily, setDraftKeepDaily] = useState('7');
|
|
||||||
const [draftKeepMonthly, setDraftKeepMonthly] = useState('6');
|
|
||||||
|
|
||||||
const policiesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRetentionPolicies(abortSignal), []),
|
|
||||||
);
|
|
||||||
const repositoriesResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRepositories(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayWrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('retention.write');
|
|
||||||
|
|
||||||
const createMutation = useMutation(createRetentionPolicy, {
|
|
||||||
onSuccess: (policy) => {
|
|
||||||
// Eine stillschweigend ergänzte Haltevorgabe wäre eine Änderung, die
|
|
||||||
// niemand sieht — und die später niemand erklären kann.
|
|
||||||
if (!draftKeepLast || Number(draftKeepLast) < 1) {
|
|
||||||
toast.showInfo(
|
|
||||||
'Regel angelegt',
|
|
||||||
`„${policy.name}" — es wurde eine Mindesthaltung von 1 ergänzt, damit nie das letzte Backup entfällt.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.showSuccess('Regel angelegt', policy.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreateDialogOpen(false);
|
|
||||||
setDraftName('');
|
|
||||||
policiesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Regel ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation(deleteRetentionPolicy, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Regel gelöscht');
|
|
||||||
setPolicyPendingDeletion(null);
|
|
||||||
policiesResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const previewMutation = useMutation(previewRetention, {
|
|
||||||
onSuccess: (preview) => {
|
|
||||||
setPreviewResult(preview);
|
|
||||||
toast.showInfo(
|
|
||||||
'Vorschau erstellt',
|
|
||||||
`${preview.backups_to_delete} Wiederherstellungspunkte würden entfallen. Es wurde nichts gelöscht.`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Vorschau schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const applyMutation = useMutation(applyRetention, {
|
|
||||||
onSuccess: (result) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Aufbewahrung angewendet',
|
|
||||||
result.bytes_to_free
|
|
||||||
? `${formatBytes(result.bytes_to_free)} frei geworden.`
|
|
||||||
: 'Es wurde kein Speicher frei — die Blöcke werden von anderen Backups gebraucht (Deduplizierung).',
|
|
||||||
);
|
|
||||||
setIsApplyDialogOpen(false);
|
|
||||||
setPreviewResult(null);
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const policies = policiesResource.data?.policies ?? [];
|
|
||||||
const predefinedTemplates = policiesResource.data?.predefined ?? [];
|
|
||||||
const repositories = repositoriesResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Regel',
|
|
||||||
render: (policy) => <span className="font-medium text-fg">{policy.name}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'keep_within',
|
|
||||||
header: 'Aufbewahrung',
|
|
||||||
render: (policy) =>
|
|
||||||
policy.keep_within_seconds
|
|
||||||
? `${Math.round(policy.keep_within_seconds / 86400)} Tage`
|
|
||||||
: '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'keep_last',
|
|
||||||
header: 'Mindestens',
|
|
||||||
numeric: true,
|
|
||||||
render: (policy) => (
|
|
||||||
<span title="Schützt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist.">
|
|
||||||
{policy.keep_last ?? '—'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'grid',
|
|
||||||
header: 'Täglich / Wöchentlich / Monatlich / Jährlich',
|
|
||||||
render: (policy) =>
|
|
||||||
[policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly]
|
|
||||||
.map((value) => value ?? '—')
|
|
||||||
.join(' / '),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (policy) =>
|
|
||||||
mayWrite ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={`Regel ${policy.name} löschen`}
|
|
||||||
onClick={(clickEvent) => {
|
|
||||||
clickEvent.stopPropagation();
|
|
||||||
setPolicyPendingDeletion(policy);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (policiesResource.loadState === 'failed' && policiesResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Aufbewahrung" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(policiesResource.loadError)}
|
|
||||||
requestId={policiesResource.loadError.requestId}
|
|
||||||
onRetry={policiesResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Aufbewahrung"
|
|
||||||
description="Wie lange Wiederherstellungspunkte erhalten bleiben."
|
|
||||||
actions={
|
|
||||||
mayWrite ? (
|
|
||||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Regel anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{mayWrite && predefinedTemplates.length > 0 ? (
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader
|
|
||||||
title="Vorlagen"
|
|
||||||
description="Mitgeliefert. Ein Klick legt eine eigene Regel daraus an."
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{predefinedTemplates.map((template) => (
|
|
||||||
<button
|
|
||||||
key={template.name}
|
|
||||||
type="button"
|
|
||||||
disabled={createMutation.isRunning}
|
|
||||||
onClick={() =>
|
|
||||||
void createMutation.run({
|
|
||||||
name: template.name,
|
|
||||||
keep_within_seconds: template.keep_within_seconds ?? 0,
|
|
||||||
keep_last: template.keep_last ?? 1,
|
|
||||||
keep_daily: template.keep_daily ?? 0,
|
|
||||||
keep_weekly: template.keep_weekly ?? 0,
|
|
||||||
keep_monthly: template.keep_monthly ?? 0,
|
|
||||||
keep_yearly: template.keep_yearly ?? 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-line bg-card p-3 text-left transition-colors hover:border-accent hover:bg-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Plus className="size-3.5 text-accent" aria-hidden />
|
|
||||||
<span className="text-sm font-medium text-fg">{template.name}</span>
|
|
||||||
</span>
|
|
||||||
{template.description ? (
|
|
||||||
<span className="mt-1 block text-xs text-fg-muted">
|
|
||||||
{template.description}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader title="Eigene Regeln" />
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={policies}
|
|
||||||
getRowKey={(policy) => policy.id}
|
|
||||||
isLoading={policiesResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Keine Aufbewahrungsregel"
|
|
||||||
emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand löscht."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{mayWrite ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="Regel anwenden"
|
|
||||||
description="Die Vorschau löscht nichts und zeigt, was entfiele."
|
|
||||||
/>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<SelectInput
|
|
||||||
label="Repository"
|
|
||||||
value={selectedRepositoryId}
|
|
||||||
onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Bitte wählen</option>
|
|
||||||
{repositories.map((repository) => (
|
|
||||||
<option key={repository.id} value={repository.id}>
|
|
||||||
{repository.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</SelectInput>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label="Regel"
|
|
||||||
value={selectedPolicyId}
|
|
||||||
onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Bitte wählen</option>
|
|
||||||
{policies.map((policy) => (
|
|
||||||
<option key={policy.id} value={policy.id}>
|
|
||||||
{policy.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</SelectInput>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
isLoading={previewMutation.isRunning}
|
|
||||||
disabled={!selectedRepositoryId || !selectedPolicyId}
|
|
||||||
onClick={() =>
|
|
||||||
void previewMutation.run(selectedRepositoryId, selectedPolicyId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Vorschau erstellen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={previewResult === null || previewResult.backups_to_delete === 0}
|
|
||||||
onClick={() => setIsApplyDialogOpen(true)}
|
|
||||||
title={
|
|
||||||
previewResult === null
|
|
||||||
? 'Erstellen Sie zuerst eine Vorschau.'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Anwenden und löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewResult ? (
|
|
||||||
<div className="rounded-md border border-line bg-sunken p-3">
|
|
||||||
<p className="text-sm text-fg">
|
|
||||||
<strong>{previewResult.backups_to_delete}</strong>{' '}
|
|
||||||
Wiederherstellungspunkte würden entfallen
|
|
||||||
{previewResult.backups_protected
|
|
||||||
? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten`
|
|
||||||
: ''}
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-fg-muted">
|
|
||||||
Frei würden voraussichtlich {formatBytes(previewResult.bytes_to_free)}.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-xs text-fg-muted">
|
|
||||||
Deutlich weniger als die Summe der Backupgrößen ist der
|
|
||||||
Normalfall: Deduplizierte Blöcke werden von anderen
|
|
||||||
Wiederherstellungspunkten weiter gebraucht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* --- Regel anlegen --- */}
|
|
||||||
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader title="Aufbewahrungsregel anlegen" />
|
|
||||||
<DialogBody>
|
|
||||||
<TextInput
|
|
||||||
label="Bezeichnung"
|
|
||||||
required
|
|
||||||
value={draftName}
|
|
||||||
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
|
|
||||||
placeholder="Standard 30 Tage"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<TextInput
|
|
||||||
label="Aufbewahrung in Tagen"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={draftKeepDays}
|
|
||||||
onChange={(changeEvent) => setDraftKeepDays(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Mindestens behalten"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={draftKeepLast}
|
|
||||||
onChange={(changeEvent) => setDraftKeepLast(changeEvent.target.value)}
|
|
||||||
hint="Schützt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist."
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Tägliche behalten"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={draftKeepDaily}
|
|
||||||
onChange={(changeEvent) => setDraftKeepDaily(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Monatliche behalten"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={draftKeepMonthly}
|
|
||||||
onChange={(changeEvent) => setDraftKeepMonthly(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Callout tone="warning">
|
|
||||||
Ohne Haltevorgabe wird die Regel abgelehnt.
|
|
||||||
</Callout>
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={createMutation.isRunning}
|
|
||||||
disabled={!draftName.trim()}
|
|
||||||
onClick={() =>
|
|
||||||
void createMutation.run({
|
|
||||||
name: draftName.trim(),
|
|
||||||
keep_within_seconds: Number(draftKeepDays) * 86400,
|
|
||||||
keep_last: Number(draftKeepLast) || 1,
|
|
||||||
keep_daily: Number(draftKeepDaily) || 0,
|
|
||||||
keep_monthly: Number(draftKeepMonthly) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Anlegen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={policyPendingDeletion !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)}
|
|
||||||
title="Regel löschen"
|
|
||||||
description={policyPendingDeletion?.name}
|
|
||||||
confirmLabel="Löschen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={deleteMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (policyPendingDeletion) {
|
|
||||||
void deleteMutation.run(policyPendingDeletion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-fg-muted">
|
|
||||||
Künftig wird nach dieser Regel nichts mehr entfernt.
|
|
||||||
</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={isApplyDialogOpen}
|
|
||||||
onOpenChange={setIsApplyDialogOpen}
|
|
||||||
title="Aufbewahrung anwenden"
|
|
||||||
description="Wiederherstellungspunkte werden endgültig entfernt."
|
|
||||||
confirmLabel="Endgültig löschen"
|
|
||||||
isDestructive
|
|
||||||
isLoading={applyMutation.isRunning}
|
|
||||||
confirmationWord={String(previewResult?.backups_to_delete ?? '')}
|
|
||||||
confirmationHint={
|
|
||||||
<>
|
|
||||||
Tippen Sie zur Bestätigung die Zahl der zu löschenden Punkte:{' '}
|
|
||||||
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
{previewResult?.backups_to_delete}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={() =>
|
|
||||||
void applyMutation.run(
|
|
||||||
selectedRepositoryId,
|
|
||||||
selectedPolicyId,
|
|
||||||
String(previewResult?.backups_to_delete ?? ''),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Callout tone="critical">
|
|
||||||
Geschützte Punkte bleiben unberührt.
|
|
||||||
</Callout>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,342 +0,0 @@
|
|||||||
/**
|
|
||||||
* API-Anbindung der Repositories (SYNCOVA_API.md §8).
|
|
||||||
*
|
|
||||||
* Ein Punkt prägt die ganze Maske: **`POST /repositories` legt nichts an, es
|
|
||||||
* übernimmt.** Ein Repository entsteht auf einem Datenträger — mit
|
|
||||||
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt öffnet
|
|
||||||
* ein vorhandenes und liest dessen Kennung aus dem Descriptor.
|
|
||||||
*
|
|
||||||
* Wer das nicht weiß, trägt einen Pfad ein, bekommt einen Fehler und sucht
|
|
||||||
* ihn an der falschen Stelle.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
/** Ein Sicherungsziel. */
|
|
||||||
export interface Repository {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
repository_type: string;
|
|
||||||
location: string;
|
|
||||||
status: string;
|
|
||||||
/** Meldet, ob dieses Ziel Sicherungen annimmt. Die Auskunft kommt vom Server. */
|
|
||||||
accepts_backups: boolean;
|
|
||||||
hardened: boolean;
|
|
||||||
/** Belegter Speicher; nicht immer bekannt. */
|
|
||||||
used_bytes?: number;
|
|
||||||
/** Gesamtkapazität, sofern hinterlegt. */
|
|
||||||
total_bytes?: number;
|
|
||||||
/** Gemessene Durchsetzungsstufe des Löschschutzes. */
|
|
||||||
enforcement_level?: string;
|
|
||||||
last_health_check_at?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Antworthülle der Repository-Prüfungen.
|
|
||||||
*
|
|
||||||
* **Alle vier Prüfendpunkte antworten in dieser Form** — Integritätslauf,
|
|
||||||
* Gesundheitsprüfung, Verbindungstest und Katalog-Neuaufbau. Das eigentliche
|
|
||||||
* Ergebnis steckt unter `details`, nicht an der Oberfläche der Antwort.
|
|
||||||
*
|
|
||||||
* Das falsch anzunehmen kostete eine Fehlermeldung, die wie ein Defekt der
|
|
||||||
* Anlage aussah: „can't access property toLocaleString, chunks_checked is
|
|
||||||
* undefined — das ist ein Problem der Prüfung, kein Befund am Repository."
|
|
||||||
* Genau das Gegenteil war der Fall; die Prüfung war einwandfrei gelaufen.
|
|
||||||
*/
|
|
||||||
export interface RepositoryCheckResponse<TDetails> {
|
|
||||||
repository_id: string;
|
|
||||||
reachable: boolean;
|
|
||||||
repository_uuid?: string;
|
|
||||||
error?: string;
|
|
||||||
details?: TDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ergebnis eines Integritätslaufs.
|
|
||||||
*
|
|
||||||
* Die Feldnamen stammen aus `repository.ScanReport` und heißen anders herum als
|
|
||||||
* erwartet: `missing_chunks`, nicht `chunks_missing`.
|
|
||||||
*/
|
|
||||||
export interface IntegrityScanDetails {
|
|
||||||
/** Geprüfte Blöcke. */
|
|
||||||
chunks_checked: number;
|
|
||||||
/** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */
|
|
||||||
missing_chunks: number;
|
|
||||||
/** Prüfsumme stimmt nicht. */
|
|
||||||
corrupted_chunks: number;
|
|
||||||
/** Blöcke ohne Verweis aus einem Manifest. */
|
|
||||||
orphaned_chunks: number;
|
|
||||||
backups_checked: number;
|
|
||||||
backups_healthy: number;
|
|
||||||
/** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */
|
|
||||||
verified_chunk_contents: boolean;
|
|
||||||
healthy: boolean;
|
|
||||||
summary: string;
|
|
||||||
/** Betroffene Backups, sofern benennbar. */
|
|
||||||
affected_backup_ids?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ergebnis einer Gesundheitsprüfung. */
|
|
||||||
export interface RepositoryHealth {
|
|
||||||
status: string;
|
|
||||||
/** Freier Platz am Ablageort. */
|
|
||||||
available_bytes?: number;
|
|
||||||
writable?: boolean;
|
|
||||||
/** Gemessene Durchsetzungsstufe. */
|
|
||||||
enforcement_level?: string;
|
|
||||||
enforcement_explanation?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vorschau einer Aufbewahrungsanwendung. */
|
|
||||||
export interface RetentionPreview {
|
|
||||||
/** Wiederherstellungspunkte, die entfielen. */
|
|
||||||
backups_to_delete: number;
|
|
||||||
/** Punkte, die durch Schutz erhalten bleiben. */
|
|
||||||
backups_protected?: number;
|
|
||||||
/**
|
|
||||||
* Speicher, der frei würde.
|
|
||||||
*
|
|
||||||
* Oft deutlich weniger als die Summe der Backupgrößen — das ist
|
|
||||||
* Deduplizierung und kein Fehler.
|
|
||||||
*/
|
|
||||||
bytes_to_free?: number;
|
|
||||||
summary?: string;
|
|
||||||
affected?: Array<{ backup_id: string; completed_at?: string; reason?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Repositories. */
|
|
||||||
export async function listRepositories(abortSignal?: AbortSignal): Promise<Repository[]> {
|
|
||||||
return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt ein einzelnes Repository. */
|
|
||||||
export async function getRepository(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<Repository> {
|
|
||||||
return requestApi<Repository>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Übernimmt ein vorhandenes Repository in die Control Plane.
|
|
||||||
*
|
|
||||||
* Es wird **nicht angelegt**: Der Server öffnet das Verzeichnis und liest die
|
|
||||||
* Kennung aus dem Descriptor. Liegt dort keines, schlägt der Aufruf fehl.
|
|
||||||
*/
|
|
||||||
export async function adoptRepository(repositoryRequest: {
|
|
||||||
name: string;
|
|
||||||
location: string;
|
|
||||||
repository_type?: string;
|
|
||||||
}): Promise<Repository> {
|
|
||||||
return requestApi<Repository>('/repositories', {
|
|
||||||
method: 'POST',
|
|
||||||
body: repositoryRequest,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ändert Name oder Zustand eines Repositorys. */
|
|
||||||
export async function updateRepository(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
changes: { name?: string; status?: string },
|
|
||||||
): Promise<Repository> {
|
|
||||||
return requestApi<Repository>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}`,
|
|
||||||
{ method: 'PATCH', body: changes },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prüft die Erreichbarkeit. */
|
|
||||||
export async function testRepository(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
|
|
||||||
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Führt eine Gesundheitsprüfung aus. */
|
|
||||||
export async function checkRepositoryHealth(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
): Promise<RepositoryCheckResponse<RepositoryHealth>> {
|
|
||||||
return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Startet einen Integritätslauf.
|
|
||||||
*
|
|
||||||
* Er prüft jeden Block gegen die gespeicherte Prüfsumme der **abgelegten**
|
|
||||||
* Form — deshalb braucht er den Datenschlüssel nicht. Ein Befund ist ein
|
|
||||||
* Ergebnis, kein Fehler des Laufs; die Oberfläche trennt beides.
|
|
||||||
*/
|
|
||||||
export async function startIntegrityScan(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
): Promise<IntegrityScanDetails> {
|
|
||||||
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
|
||||||
{ method: 'POST', idempotencyKey: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ein nicht erreichbares Repository ist kein Befund am Bestand, sondern ein
|
|
||||||
// Fehler des Laufs — die Unterscheidung, die diese Seite durchgehend macht.
|
|
||||||
if (!response.reachable || !response.details) {
|
|
||||||
throw new Error(
|
|
||||||
response.error || 'Das Repository war während der Prüfung nicht erreichbar.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.details;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Baut den Katalog neu auf.
|
|
||||||
*
|
|
||||||
* Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er
|
|
||||||
* lässt sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis,
|
|
||||||
* dass das Repository ohne die Datenbank auskommt.
|
|
||||||
*/
|
|
||||||
export async function rebuildCatalog(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
|
|
||||||
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
|
||||||
{ method: 'POST', idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Misst die Durchsetzungsstufe des Löschschutzes.
|
|
||||||
*
|
|
||||||
* Gemessen, nicht behauptet: Der Server legt Probedateien an und versucht sie
|
|
||||||
* zu löschen. Gemeldet wird nur, was das Betriebssystem nachweislich
|
|
||||||
* verhindert.
|
|
||||||
*/
|
|
||||||
export async function measureEnforcement(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
): Promise<{
|
|
||||||
level: string;
|
|
||||||
explanation: string;
|
|
||||||
observations?: Array<{ attempt: string; prevented: boolean; detail?: string }>;
|
|
||||||
immutable_flag_supported?: boolean;
|
|
||||||
measured_at?: string;
|
|
||||||
}> {
|
|
||||||
return requestApi(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/enforcement/measure`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Zeigt, was eine Aufbewahrungsregel entfernen würde. Löscht nichts. */
|
|
||||||
export async function previewRetention(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
policyIdentifier: string,
|
|
||||||
): Promise<RetentionPreview> {
|
|
||||||
return requestApi<RetentionPreview>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/retention/preview`,
|
|
||||||
{ method: 'POST', body: { policy_id: policyIdentifier, dry_run: true } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wendet eine Aufbewahrungsregel an und löscht.
|
|
||||||
*
|
|
||||||
* `confirm_deletion` wiederholt die Zahl der zu löschenden Punkte wörtlich —
|
|
||||||
* die Vorschau davor ist keine Formalie, sondern die Grundlage dieser Zahl.
|
|
||||||
*/
|
|
||||||
export async function applyRetention(
|
|
||||||
repositoryIdentifier: string,
|
|
||||||
policyIdentifier: string,
|
|
||||||
confirmDeletion: string,
|
|
||||||
): Promise<RetentionPreview> {
|
|
||||||
return requestApi<RetentionPreview>(
|
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/retention/apply`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: { policy_id: policyIdentifier, dry_run: false, confirm_deletion: confirmDeletion },
|
|
||||||
idempotencyKey: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Aufbewahrungsregeln
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Eine Aufbewahrungsregel. */
|
|
||||||
export interface RetentionPolicy {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
keep_within_seconds?: number;
|
|
||||||
/** Schützt das letzte vorhandene Backup. */
|
|
||||||
keep_last?: number;
|
|
||||||
keep_daily?: number;
|
|
||||||
keep_weekly?: number;
|
|
||||||
keep_monthly?: number;
|
|
||||||
keep_yearly?: number;
|
|
||||||
time_zone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Antwort auf die Regelliste.
|
|
||||||
*
|
|
||||||
* **Dieser Endpunkt liefert ein Objekt, keine Liste** — als einziger von zehn
|
|
||||||
* geprüften. Ihn als Array zu behandeln ließ die Seite abstürzen: `map` gibt es
|
|
||||||
* auf einem Objekt nicht, React hängte den ganzen Baum aus, und übrig blieb ein
|
|
||||||
* schwarzer Bildschirm.
|
|
||||||
*/
|
|
||||||
export interface RetentionPolicyList {
|
|
||||||
/** Selbst angelegte Regeln. */
|
|
||||||
policies: RetentionPolicy[];
|
|
||||||
/**
|
|
||||||
* Mitgelieferte Vorlagen.
|
|
||||||
*
|
|
||||||
* Sie tragen keine Kennung — es sind Vorschläge, keine Regeln. Wer eine
|
|
||||||
* übernimmt, legt damit eine eigene an.
|
|
||||||
*/
|
|
||||||
predefined: Array<Omit<RetentionPolicy, 'id'> & { description?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Aufbewahrungsregeln samt Vorlagen. */
|
|
||||||
export async function listRetentionPolicies(
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<RetentionPolicyList> {
|
|
||||||
const response = await requestApi<RetentionPolicyList>(
|
|
||||||
'/retention-policies',
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fehlt ein Feld, ist die Liste leer — nicht undefined. Ein `?? []` an jeder
|
|
||||||
// Verwendungsstelle wäre ein `??` zu viel, das irgendwann jemand vergisst.
|
|
||||||
return {
|
|
||||||
policies: response.policies ?? [],
|
|
||||||
predefined: response.predefined ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt eine Aufbewahrungsregel an. */
|
|
||||||
export async function createRetentionPolicy(
|
|
||||||
policy: Omit<RetentionPolicy, 'id'>,
|
|
||||||
): Promise<RetentionPolicy> {
|
|
||||||
return requestApi<RetentionPolicy>('/retention-policies', {
|
|
||||||
method: 'POST',
|
|
||||||
body: policy,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Löscht eine Aufbewahrungsregel. */
|
|
||||||
export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> {
|
|
||||||
return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auswahl dessen, was zurückgeholt werden soll.
|
|
||||||
*
|
|
||||||
* Bisher gab es nur „alles" — ein Textfeld für einen Teilbaum, das voraussetzte,
|
|
||||||
* dass man die Pfade im Backup auswendig kennt. Jetzt lässt sich blättern.
|
|
||||||
*
|
|
||||||
* Der gewählte Pfad wandert als `path_prefix` in die Anfrage. Das genügt für
|
|
||||||
* beides: Der Server vergleicht auf Gleichheit **oder** Präfix mit
|
|
||||||
* Verzeichnisgrenze, trifft also sowohl einen ganzen Ordner als auch eine
|
|
||||||
* einzelne Datei. „dokumente" trifft dabei nicht „dokumentation".
|
|
||||||
*
|
|
||||||
* Bewusste Grenze: **eine** Auswahl je Lauf, kein Mehrfachhaken. Eine Liste
|
|
||||||
* ausgewählter Pfade kennt die API nicht, und sie vorzutäuschen — etwa durch
|
|
||||||
* mehrere Läufe hintereinander — ergäbe mehrere Wiederherstellungen mit
|
|
||||||
* getrenntem Ausgang. Ein „teilweise fehlgeschlagen" ließe sich dann niemandem
|
|
||||||
* mehr erklären.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import { Button, Callout, ErrorState, LoadingState } from '@/components/ui';
|
|
||||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
|
||||||
import { browseBackupContents } from './browseApi';
|
|
||||||
|
|
||||||
export function BackupContentPicker({
|
|
||||||
backupIdentifier,
|
|
||||||
selectedPath,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
readonly backupIdentifier: string;
|
|
||||||
/** Leer bedeutet: das gesamte Backup. */
|
|
||||||
readonly selectedPath: string;
|
|
||||||
readonly onSelect: (contentPath: string) => void;
|
|
||||||
}) {
|
|
||||||
const [currentPath, setCurrentPath] = useState('');
|
|
||||||
|
|
||||||
const contentsResource = useApiResource(
|
|
||||||
useCallback(
|
|
||||||
(abortSignal) => browseBackupContents(backupIdentifier, currentPath, abortSignal),
|
|
||||||
[backupIdentifier, currentPath],
|
|
||||||
),
|
|
||||||
`${backupIdentifier}|${currentPath}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const listing = contentsResource.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Der Regelfall steht oben und ist vorausgewählt: Die meisten
|
|
||||||
Wiederherstellungen holen alles zurück. */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === '' ? 'primary' : 'secondary'}
|
|
||||||
onClick={() => onSelect('')}
|
|
||||||
>
|
|
||||||
Gesamtes Backup
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pfadleiste */}
|
|
||||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() => setCurrentPath('')}
|
|
||||||
>
|
|
||||||
Wurzel
|
|
||||||
</button>
|
|
||||||
{currentPath
|
|
||||||
.split('/')
|
|
||||||
.filter((segment) => segment !== '')
|
|
||||||
.map((segment, segmentIndex, allSegments) => (
|
|
||||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
|
||||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() => setCurrentPath(allSegments.slice(0, segmentIndex + 1).join('/'))}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-72 overflow-y-auto border border-line">
|
|
||||||
{contentsResource.loadState === 'loading' ? (
|
|
||||||
<LoadingState label="Inhalt wird gelesen …" />
|
|
||||||
) : contentsResource.loadState === 'failed' && contentsResource.loadError ? (
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(contentsResource.loadError)}
|
|
||||||
requestId={contentsResource.loadError.requestId}
|
|
||||||
onRetry={contentsResource.reload}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-line">
|
|
||||||
{currentPath !== '' ? (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
|
||||||
onClick={() => setCurrentPath(listing?.parent_path ?? '')}
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
|
||||||
… eine Ebene höher
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).map((entry) => (
|
|
||||||
<li key={entry.path} className="flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
|
||||||
onClick={() =>
|
|
||||||
entry.is_directory ? setCurrentPath(entry.path) : onSelect(entry.path)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{entry.is_directory ? (
|
|
||||||
<Folder className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<File className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-fg">{entry.name}</span>
|
|
||||||
|
|
||||||
<span className="shrink-0 text-[11px] tabular text-fg-muted">
|
|
||||||
{entry.is_directory
|
|
||||||
? entry.child_count
|
|
||||||
? `${entry.child_count} Objekte · ${formatBytes(entry.total_bytes)}`
|
|
||||||
: ''
|
|
||||||
: formatBytes(entry.size_bytes)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
|
||||||
className="mr-1 shrink-0"
|
|
||||||
onClick={() => onSelect(entry.path)}
|
|
||||||
title={
|
|
||||||
entry.is_directory
|
|
||||||
? 'Diesen Ordner mit allem darin zurückholen'
|
|
||||||
: 'Nur diese Datei zurückholen'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).length === 0 ? (
|
|
||||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
|
||||||
Dieser Ordner ist im Backup leer.
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPath ? (
|
|
||||||
<Callout tone="info">
|
|
||||||
Zurückgeholt wird nur: <code className="font-mono">{selectedPath}</code>
|
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des
|
|
||||||
Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das
|
|
||||||
Backup.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
) : (
|
|
||||||
<Callout tone="info">Zurückgeholt wird das gesamte Backup.</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{listing?.entries.some((entry) => entry.modified_at) ? (
|
|
||||||
<p className="text-xs text-fg-subtle">
|
|
||||||
Stand der Dateien:{' '}
|
|
||||||
{formatDateTime(
|
|
||||||
listing.entries.find((entry) => entry.modified_at)?.modified_at,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auswahl des Zielverzeichnisses.
|
|
||||||
*
|
|
||||||
* Ersetzt das Textfeld, in das man einen Pfad tippte und erst nach der
|
|
||||||
* Vorabprüfung erfuhr, dass der Dienst dort gar nicht schreiben darf.
|
|
||||||
*
|
|
||||||
* Die tragende Angabe ist **`is_writable`**, und sie wird gemessen: Der Server
|
|
||||||
* legt eine Probedatei an und entfernt sie wieder. Aus den Rechtebits ließe sie
|
|
||||||
* sich nicht ableiten — der Dienst läuft mit `ProtectSystem=strict`, und davon
|
|
||||||
* steht nichts im Modus. Genau deshalb endete eine Wiederherstellung nach
|
|
||||||
* `/opt/test` mit „permission denied", obwohl das Verzeichnis beschreibbar
|
|
||||||
* aussah.
|
|
||||||
*
|
|
||||||
* Nicht beschreibbare Verzeichnisse werden **gezeigt**, nicht versteckt: Sie
|
|
||||||
* lassen sich betreten, um tiefer zu blättern, aber nicht auswählen. Sie
|
|
||||||
* wegzulassen ließe den Betreiber im Dunkeln, warum sein Pfad fehlt.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ChevronRight, FolderOpen, Lock, Plus } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
TextInput,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { browseFilesystem } from './browseApi';
|
|
||||||
|
|
||||||
export function DirectoryPicker({
|
|
||||||
selectedPath,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
readonly selectedPath: string;
|
|
||||||
readonly onSelect: (directoryPath: string) => void;
|
|
||||||
}) {
|
|
||||||
// Der Startpunkt ist das übergeordnete Verzeichnis der Auswahl, sonst die
|
|
||||||
// Wurzel — so landet man beim zweiten Öffnen dort, wo man aufgehört hat.
|
|
||||||
const [currentPath, setCurrentPath] = useState(() => {
|
|
||||||
const trimmedSelection = selectedPath.trim();
|
|
||||||
|
|
||||||
if (!trimmedSelection.startsWith('/')) {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = trimmedSelection.replace(/\/[^/]*$/, '');
|
|
||||||
|
|
||||||
return parentPath === '' ? '/' : parentPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
|
||||||
|
|
||||||
const browseResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => browseFilesystem(currentPath, abortSignal), [currentPath]),
|
|
||||||
currentPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
const listing = browseResource.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen
|
|
||||||
brauchbaren Ort zu kommen. */}
|
|
||||||
{listing?.suggested_paths && listing.suggested_paths.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
<p className="mb-1.5 text-xs text-fg-subtle">Beschreibbare Orte</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{listing.suggested_paths.map((suggestedPath) => (
|
|
||||||
<Button
|
|
||||||
key={suggestedPath}
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === suggestedPath ? 'primary' : 'secondary'}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(suggestedPath);
|
|
||||||
setCurrentPath(suggestedPath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{suggestedPath}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Pfadleiste */}
|
|
||||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() => setCurrentPath('/')}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</button>
|
|
||||||
{currentPath
|
|
||||||
.split('/')
|
|
||||||
.filter((segment) => segment !== '')
|
|
||||||
.map((segment, segmentIndex, allSegments) => (
|
|
||||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
|
||||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPath('/' + allSegments.slice(0, segmentIndex + 1).join('/'))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liste */}
|
|
||||||
<div className="max-h-64 overflow-y-auto border border-line">
|
|
||||||
{browseResource.loadState === 'loading' ? (
|
|
||||||
<LoadingState label="Verzeichnis wird gelesen …" />
|
|
||||||
) : browseResource.loadState === 'failed' && browseResource.loadError ? (
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(browseResource.loadError)}
|
|
||||||
requestId={browseResource.loadError.requestId}
|
|
||||||
onRetry={browseResource.reload}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-line">
|
|
||||||
{listing?.parent_path ? (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
|
||||||
onClick={() => setCurrentPath(listing.parent_path!)}
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
|
||||||
… eine Ebene höher
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).map((entry) => (
|
|
||||||
<li key={entry.path} className="flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
|
||||||
onClick={() => setCurrentPath(entry.path)}
|
|
||||||
title={entry.forbidden_reason}
|
|
||||||
>
|
|
||||||
{entry.is_writable ? (
|
|
||||||
<FolderOpen className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Lock className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className={cn('truncate', entry.is_writable ? 'text-fg' : 'text-fg-subtle')}>
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
{!entry.is_writable ? (
|
|
||||||
<span className="ml-auto shrink-0 text-[11px] text-fg-subtle">
|
|
||||||
{entry.forbidden_reason ? 'gesperrt' : 'nicht beschreibbar'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{entry.is_writable ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
|
||||||
className="mr-1 shrink-0"
|
|
||||||
onClick={() => onSelect(entry.path)}
|
|
||||||
>
|
|
||||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).length === 0 && !listing?.parent_path ? (
|
|
||||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
|
||||||
Keine Unterverzeichnisse.
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er
|
|
||||||
im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach
|
|
||||||
an den aktuellen Pfad. */}
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<TextInput
|
|
||||||
label="Neues Unterverzeichnis"
|
|
||||||
className="flex-1"
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(changeEvent) => setNewFolderName(changeEvent.target.value)}
|
|
||||||
placeholder="wiederherstellung-2026-08-18"
|
|
||||||
hint="Es wird beim Zurückschreiben angelegt."
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="mb-0.5"
|
|
||||||
disabled={!newFolderName.trim()}
|
|
||||||
onClick={() => {
|
|
||||||
const combinedPath =
|
|
||||||
(currentPath === '/' ? '' : currentPath) + '/' + newFolderName.trim();
|
|
||||||
onSelect(combinedPath);
|
|
||||||
setNewFolderName('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPath ? (
|
|
||||||
<Callout tone="info">
|
|
||||||
Ziel: <code className="font-mono">{selectedPath}</code>
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,531 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
|
|
||||||
*
|
|
||||||
* Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht
|
|
||||||
* auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen —
|
|
||||||
* ausgerechnet in der Lage, in der niemand experimentieren will.
|
|
||||||
*
|
|
||||||
* Der Assistent hat vier Schritte, und der dritte ist der Grund für seine
|
|
||||||
* Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die
|
|
||||||
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block
|
|
||||||
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
|
|
||||||
* gesichert hat.
|
|
||||||
*
|
|
||||||
* Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt:
|
|
||||||
*
|
|
||||||
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
|
|
||||||
* 2. Die Berechtigung `restores.overwrite` prüft der Server; sie steckt nicht
|
|
||||||
* in `restores.execute`.
|
|
||||||
* 3. `confirm_overwrite` verlangt den **wörtlich wiederholten Zielpfad**.
|
|
||||||
*
|
|
||||||
* Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts
|
|
||||||
* überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das
|
|
||||||
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
CheckboxField,
|
|
||||||
DialogBody,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogRoot,
|
|
||||||
Field,
|
|
||||||
StatusBadge,
|
|
||||||
TextInput,
|
|
||||||
useToast,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
|
||||||
import { BackupContentPicker } from './BackupContentPicker';
|
|
||||||
import { DirectoryPicker } from './DirectoryPicker';
|
|
||||||
import {
|
|
||||||
createRestore,
|
|
||||||
validateRestore,
|
|
||||||
type CreateRestoreRequest,
|
|
||||||
type ValidationResponse,
|
|
||||||
} from './restoresApi';
|
|
||||||
|
|
||||||
/** Die vier Schritte in Reihenfolge. */
|
|
||||||
const WIZARD_STEPS = [
|
|
||||||
{ key: 'ziel', label: 'Ziel' },
|
|
||||||
{ key: 'umfang', label: 'Umfang' },
|
|
||||||
{ key: 'prüfung', label: 'Vorabprüfung' },
|
|
||||||
{ key: 'ausführen', label: 'Ausführen' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function RestoreWizard({
|
|
||||||
backupIdentifier,
|
|
||||||
backupLabel,
|
|
||||||
grantedPermissions,
|
|
||||||
onClose,
|
|
||||||
onStarted,
|
|
||||||
}: {
|
|
||||||
readonly backupIdentifier: string;
|
|
||||||
/** Sprechende Bezeichnung des Wiederherstellungspunkts. */
|
|
||||||
readonly backupLabel?: string;
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
readonly onClose: () => void;
|
|
||||||
readonly onStarted: (restoreIdentifier: string) => void;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
||||||
|
|
||||||
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst
|
|
||||||
// wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten).
|
|
||||||
const [targetPath, setTargetPath] = useState('');
|
|
||||||
const [pathPrefix, setPathPrefix] = useState('');
|
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
|
||||||
const [skipPermissions, setSkipPermissions] = useState(false);
|
|
||||||
const [skipDeepCheck, setSkipDeepCheck] = useState(false);
|
|
||||||
const [typedConfirmation, setTypedConfirmation] = useState('');
|
|
||||||
|
|
||||||
const [validationResult, setValidationResult] = useState<ValidationResponse | null>(null);
|
|
||||||
|
|
||||||
const mayOverwrite =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('restores.overwrite');
|
|
||||||
|
|
||||||
/** Baut die Anfrage aus dem Entwurf. */
|
|
||||||
function buildRequest(includeConfirmation: boolean): CreateRestoreRequest {
|
|
||||||
const request: CreateRestoreRequest = {
|
|
||||||
backup_id: backupIdentifier,
|
|
||||||
target_type: 'filesystem',
|
|
||||||
target_path: targetPath.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pathPrefix.trim()) {
|
|
||||||
request.path_prefix = pathPrefix.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overwriteExisting) {
|
|
||||||
request.overwrite_existing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipPermissions) {
|
|
||||||
request.skip_permissions = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipDeepCheck) {
|
|
||||||
request.skip_deep_check = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeConfirmation && typedConfirmation) {
|
|
||||||
request.confirm_overwrite = typedConfirmation;
|
|
||||||
}
|
|
||||||
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationMutation = useMutation(
|
|
||||||
() => validateRestore(buildRequest(false)),
|
|
||||||
{
|
|
||||||
onSuccess: (result) => {
|
|
||||||
setValidationResult(result);
|
|
||||||
setCurrentStepIndex(2);
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Die Vorabprüfung ist fehlgeschlagen',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMutation = useMutation(() => createRestore(buildRequest(true)), {
|
|
||||||
onSuccess: (session) => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Wiederherstellung gestartet',
|
|
||||||
'Der Fortschritt erscheint in der Übersicht.',
|
|
||||||
);
|
|
||||||
onStarted(session.id);
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Die Wiederherstellung ließ sich nicht starten',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
|
|
||||||
|
|
||||||
// Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich
|
|
||||||
// überschrieben wird — der Server sagt das nach der Prüfung.
|
|
||||||
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
|
|
||||||
const confirmationSatisfied =
|
|
||||||
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogRoot open onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
||||||
<DialogContent size="lg">
|
|
||||||
<DialogHeader
|
|
||||||
title="Wiederherstellen"
|
|
||||||
description={backupLabel ?? backupIdentifier}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIndicator currentStepIndex={currentStepIndex} />
|
|
||||||
|
|
||||||
<DialogBody className="min-h-64">
|
|
||||||
{currentStepIndex === 0 ? (
|
|
||||||
<DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStepIndex === 1 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<BackupContentPicker
|
|
||||||
backupIdentifier={backupIdentifier}
|
|
||||||
selectedPath={pathPrefix}
|
|
||||||
onSelect={setPathPrefix}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-line pt-4">
|
|
||||||
<CheckboxField
|
|
||||||
checked={overwriteExisting}
|
|
||||||
onCheckedChange={setOverwriteExisting}
|
|
||||||
disabled={!mayOverwrite}
|
|
||||||
label="Vorhandene Dateien überschreiben"
|
|
||||||
hint={
|
|
||||||
mayOverwrite
|
|
||||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.'
|
|
||||||
: 'Ihrer Rolle fehlt das Recht restores.overwrite.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CheckboxField
|
|
||||||
checked={skipPermissions}
|
|
||||||
onCheckedChange={setSkipPermissions}
|
|
||||||
label="Rechte nicht zurückschreiben"
|
|
||||||
hint="Dateien entstehen mit den Standardrechten des Dienstkontos."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CheckboxField
|
|
||||||
checked={skipDeepCheck}
|
|
||||||
onCheckedChange={setSkipDeepCheck}
|
|
||||||
label="Blockprüfung überspringen"
|
|
||||||
hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStepIndex === 2 ? (
|
|
||||||
<ValidationSummary
|
|
||||||
validationResult={validationResult}
|
|
||||||
isRunning={validationMutation.isRunning}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStepIndex === 3 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Callout tone={confirmationRequired ? 'warning' : 'info'}>
|
|
||||||
{confirmationRequired ? (
|
|
||||||
<>
|
|
||||||
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
|
|
||||||
<strong>überschrieben</strong>.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Das Ziel ist leer. Es wird nichts überschrieben.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<dl className="grid gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-fg-subtle">Ziel</dt>
|
|
||||||
<dd className="break-all font-mono text-sm text-fg">{targetPath}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-fg-subtle">Umfang</dt>
|
|
||||||
<dd className="text-sm text-fg">
|
|
||||||
{validationResult?.report
|
|
||||||
? `${validationResult.report.file_count.toLocaleString('de-DE')} Dateien, ${formatBytes(validationResult.report.total_bytes)}`
|
|
||||||
: '—'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{confirmationRequired ? (
|
|
||||||
<Field
|
|
||||||
label="Zielpfad zur Bestätigung wiederholen"
|
|
||||||
hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
|
|
||||||
required
|
|
||||||
htmlFor="restore-confirm"
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
id="restore-confirm"
|
|
||||||
autoComplete="off"
|
|
||||||
className="font-mono"
|
|
||||||
placeholder={targetPath}
|
|
||||||
value={typedConfirmation}
|
|
||||||
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DialogBody>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{currentStepIndex > 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setCurrentStepIndex((stepIndex) => stepIndex - 1)}
|
|
||||||
disabled={validationMutation.isRunning || createMutation.isRunning}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
Zurück
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="ghost" onClick={onClose}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStepIndex === 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={!targetIsValid}
|
|
||||||
onClick={() => setCurrentStepIndex(1)}
|
|
||||||
>
|
|
||||||
Weiter
|
|
||||||
<ArrowRight />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStepIndex === 1 ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
isLoading={validationMutation.isRunning}
|
|
||||||
onClick={() => void validationMutation.run()}
|
|
||||||
>
|
|
||||||
Vorabprüfung starten
|
|
||||||
<ArrowRight />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStepIndex === 2 ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={!validationResult?.can_proceed}
|
|
||||||
onClick={() => setCurrentStepIndex(3)}
|
|
||||||
>
|
|
||||||
Weiter
|
|
||||||
<ArrowRight />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentStepIndex === 3 ? (
|
|
||||||
<Button
|
|
||||||
variant={confirmationRequired ? 'destructive' : 'primary'}
|
|
||||||
disabled={!confirmationSatisfied}
|
|
||||||
isLoading={createMutation.isRunning}
|
|
||||||
onClick={() => void createMutation.run()}
|
|
||||||
>
|
|
||||||
<Check />
|
|
||||||
Wiederherstellung starten
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schrittanzeige am Kopf des Assistenten. */
|
|
||||||
function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number }) {
|
|
||||||
return (
|
|
||||||
<ol className="flex items-center gap-1 border-b border-line px-5 py-3">
|
|
||||||
{WIZARD_STEPS.map((step, stepIndex) => (
|
|
||||||
<li key={step.key} className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
|
|
||||||
stepIndex === currentStepIndex
|
|
||||||
? 'bg-accent-subtle font-medium text-accent'
|
|
||||||
: stepIndex < currentStepIndex
|
|
||||||
? 'text-healthy'
|
|
||||||
: 'text-fg-subtle',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{stepIndex < currentStepIndex ? (
|
|
||||||
<Check className="size-3" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<span className="tabular">{stepIndex + 1}.</span>
|
|
||||||
)}
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
{stepIndex < WIZARD_STEPS.length - 1 ? (
|
|
||||||
<span className="text-fg-subtle" aria-hidden>
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ergebnis der Vorabprüfung.
|
|
||||||
*
|
|
||||||
* Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund,
|
|
||||||
* bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen
|
|
||||||
* kann — alles andere ist ein Hinweis.
|
|
||||||
*/
|
|
||||||
function ValidationSummary({
|
|
||||||
validationResult,
|
|
||||||
isRunning,
|
|
||||||
}: {
|
|
||||||
readonly validationResult: ValidationResponse | null;
|
|
||||||
readonly isRunning: boolean;
|
|
||||||
}) {
|
|
||||||
if (isRunning || !validationResult) {
|
|
||||||
return (
|
|
||||||
<div className="py-10 text-center text-sm text-fg-muted">
|
|
||||||
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder
|
|
||||||
benötigte Block gesucht — das dauert bei großen Beständen.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = validationResult.report;
|
|
||||||
const blockingFindings =
|
|
||||||
report?.findings.filter((finding) => finding.severity === 'blocking') ?? [];
|
|
||||||
const otherFindings =
|
|
||||||
report?.findings.filter((finding) => finding.severity !== 'blocking') ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-start gap-3 rounded-md border p-3',
|
|
||||||
validationResult.can_proceed
|
|
||||||
? 'border-healthy/30 bg-healthy-bg'
|
|
||||||
: 'border-critical/30 bg-critical-bg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{validationResult.can_proceed ? (
|
|
||||||
<Check className="mt-0.5 size-5 shrink-0 text-healthy" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-medium',
|
|
||||||
validationResult.can_proceed ? 'text-healthy' : 'text-critical',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{validationResult.can_proceed
|
|
||||||
? 'Die Wiederherstellung kann beginnen'
|
|
||||||
: 'Die Wiederherstellung ist nicht möglich'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{report ? (
|
|
||||||
<dl className="grid grid-cols-2 gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-fg-subtle">Dateien</dt>
|
|
||||||
<dd className="tabular text-sm text-fg">
|
|
||||||
{report.file_count.toLocaleString('de-DE')}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-fg-subtle">Datenmenge</dt>
|
|
||||||
<dd className="tabular text-sm text-fg">{formatBytes(report.total_bytes)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
|
|
||||||
<dd className="tabular text-sm text-fg">
|
|
||||||
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil
|
|
||||||
der Wahrheit — es sähe nach "voll" aus. */}
|
|
||||||
{report.available_target_bytes < 0
|
|
||||||
? 'unbekannt'
|
|
||||||
: formatBytes(report.available_target_bytes)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-fg-subtle">Dauer der Prüfung</dt>
|
|
||||||
<dd className="tabular text-sm text-fg">
|
|
||||||
{formatDuration(report.duration_seconds)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{report && report.missing_chunk_count > 0 ? (
|
|
||||||
<Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}>
|
|
||||||
Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten
|
|
||||||
Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
|
|
||||||
auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das
|
|
||||||
Repository mit einem Integritätslauf.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{blockingFindings.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-critical">
|
|
||||||
Verhindernde Befunde
|
|
||||||
</p>
|
|
||||||
{blockingFindings.map((finding, findingIndex) => (
|
|
||||||
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{otherFindings.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Hinweise
|
|
||||||
</p>
|
|
||||||
{otherFindings.map((finding, findingIndex) => (
|
|
||||||
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein einzelner Befund mit Code, Meldung und betroffenem Objekt. */
|
|
||||||
function FindingRow({ finding }: { readonly finding: import('./restoresApi').ValidationFinding }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-2.5 rounded-md border border-line p-2.5">
|
|
||||||
<AlertTriangle
|
|
||||||
className={cn(
|
|
||||||
'mt-0.5 size-4 shrink-0',
|
|
||||||
finding.severity === 'blocking'
|
|
||||||
? 'text-critical'
|
|
||||||
: finding.severity === 'warning'
|
|
||||||
? 'text-warning'
|
|
||||||
: 'text-info',
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<StatusBadge
|
|
||||||
status={finding.severity === 'blocking' ? 'critical' : finding.severity}
|
|
||||||
showDot={false}
|
|
||||||
/>
|
|
||||||
<code className="font-mono text-xs text-fg-muted">{finding.code}</code>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-fg">{finding.message}</p>
|
|
||||||
{/* Der Befund nennt die betroffene Datei, nicht nur eine Zahl. */}
|
|
||||||
{finding.detail ? (
|
|
||||||
<p className="mt-0.5 break-all font-mono text-xs text-fg-muted">
|
|
||||||
{finding.detail}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,325 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wiederherstellungen — Übersicht und Bedienung.
|
|
||||||
*
|
|
||||||
* Der Zustand `failed` mit vorhandenem Prüfpunkt ist der interessanteste Fall
|
|
||||||
* dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die
|
|
||||||
* Fortsetzung schreibt ab dem letzten vollständig abgelegten Pfad weiter.
|
|
||||||
*
|
|
||||||
* Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein
|
|
||||||
* zweiter Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der
|
|
||||||
* erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die
|
|
||||||
* jemand auslöst — und die Seite sagt, was dabei geschieht.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ban, PlayCircle, RefreshCw } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
ConfirmDialog,
|
|
||||||
DataTable,
|
|
||||||
DetailItem,
|
|
||||||
ErrorState,
|
|
||||||
StatusBadge,
|
|
||||||
useToast,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { formatBytes, formatDateTime, formatDuration, formatRelativeTime } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
cancelRestore,
|
|
||||||
listRestores,
|
|
||||||
resumeRestore,
|
|
||||||
type RestoreSession,
|
|
||||||
} from './restoresApi';
|
|
||||||
|
|
||||||
export function RestoresPage({
|
|
||||||
grantedPermissions,
|
|
||||||
}: {
|
|
||||||
readonly grantedPermissions: readonly string[];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [selectedSession, setSelectedSession] = useState<RestoreSession | null>(null);
|
|
||||||
const [sessionPendingResume, setSessionPendingResume] = useState<RestoreSession | null>(null);
|
|
||||||
|
|
||||||
const restoresResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => listRestores(abortSignal), []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mayExecute =
|
|
||||||
grantedPermissions.includes('*') || grantedPermissions.includes('restores.execute');
|
|
||||||
|
|
||||||
const cancelMutation = useMutation(cancelRestore, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Abbruch angefordert',
|
|
||||||
'Bereits geschriebene Dateien bleiben am Ziel liegen.',
|
|
||||||
);
|
|
||||||
restoresResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError('Der Abbruch schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resumeMutation = useMutation(resumeRestore, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.showSuccess('Wiederherstellung wird fortgesetzt');
|
|
||||||
setSessionPendingResume(null);
|
|
||||||
restoresResource.reload();
|
|
||||||
},
|
|
||||||
onError: (apiError) =>
|
|
||||||
toast.showError(
|
|
||||||
'Die Fortsetzung schlug fehl',
|
|
||||||
describeApiError(apiError),
|
|
||||||
apiError.requestId,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessions = restoresResource.data ?? [];
|
|
||||||
|
|
||||||
const tableColumns: ReadonlyArray<TableColumn<RestoreSession>> = [
|
|
||||||
{
|
|
||||||
key: 'target',
|
|
||||||
header: 'Ziel',
|
|
||||||
render: (session) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-mono text-sm text-fg">{session.target_path}</p>
|
|
||||||
{session.path_prefix ? (
|
|
||||||
<p className="truncate text-xs text-fg-muted">
|
|
||||||
nur Teilbaum: {session.path_prefix}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Zustand',
|
|
||||||
render: (session) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={session.status} />
|
|
||||||
{/* Ein Prüfpunkt bei gescheitertem Lauf heißt "unterbrochen",
|
|
||||||
nicht "verloren". Das ist der Unterschied zwischen einer
|
|
||||||
Fortsetzung und einem Neubeginn. */}
|
|
||||||
{session.status === 'failed' && session.checkpoint ? (
|
|
||||||
<span className="text-xs text-info">fortsetzbar</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'started_at',
|
|
||||||
header: 'Begonnen',
|
|
||||||
render: (session) => (
|
|
||||||
<span className="text-sm text-fg-muted">
|
|
||||||
{formatRelativeTime(session.started_at ?? session.created_at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'files_restored',
|
|
||||||
header: 'Dateien',
|
|
||||||
numeric: true,
|
|
||||||
render: (session) => (
|
|
||||||
<span>
|
|
||||||
{session.files_restored.toLocaleString('de-DE')}
|
|
||||||
{session.files_skipped > 0 ? (
|
|
||||||
<span className="ml-1 text-warning">
|
|
||||||
({session.files_skipped.toLocaleString('de-DE')} übergangen)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bytes_restored',
|
|
||||||
header: 'Zurückgeschrieben',
|
|
||||||
numeric: true,
|
|
||||||
render: (session) => formatBytes(session.bytes_restored),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
className: 'w-px',
|
|
||||||
render: (session) => (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-1"
|
|
||||||
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
||||||
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
{mayExecute && (session.status === 'running' || session.status === 'queued') ? (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => void cancelMutation.run(session.id)}>
|
|
||||||
<Ban />
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{mayExecute && session.status === 'failed' && session.checkpoint ? (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSessionPendingResume(session)}>
|
|
||||||
<PlayCircle />
|
|
||||||
Fortsetzen
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (restoresResource.loadState === 'failed' && restoresResource.loadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="Wiederherstellung" />
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(restoresResource.loadError)}
|
|
||||||
requestId={restoresResource.loadError.requestId}
|
|
||||||
onRetry={restoresResource.reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Wiederherstellung"
|
|
||||||
description="Laufende und abgeschlossene Wiederherstellungen."
|
|
||||||
actions={
|
|
||||||
<Button variant="secondary" onClick={restoresResource.reload}>
|
|
||||||
<RefreshCw />
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout tone="info" className="mb-4">
|
|
||||||
Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '}
|
|
||||||
gestartet — dort steht, was gesichert wurde und ob es geprüft ist.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
rows={sessions}
|
|
||||||
getRowKey={(session) => session.id}
|
|
||||||
onRowClick={(session) => setSelectedSession(session)}
|
|
||||||
isLoading={restoresResource.loadState === 'loading'}
|
|
||||||
emptyTitle="Noch keine Wiederherstellung"
|
|
||||||
emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedSession ? (
|
|
||||||
<RestoreDetailCard
|
|
||||||
session={selectedSession}
|
|
||||||
onClose={() => setSelectedSession(null)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={sessionPendingResume !== null}
|
|
||||||
onOpenChange={(isOpen) => !isOpen && setSessionPendingResume(null)}
|
|
||||||
title="Wiederherstellung fortsetzen"
|
|
||||||
confirmLabel="Fortsetzen"
|
|
||||||
isLoading={resumeMutation.isRunning}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (sessionPendingResume) {
|
|
||||||
void resumeMutation.run(sessionPendingResume.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<p className="text-fg">
|
|
||||||
Es wird ab dem letzten vollständig geschriebenen Pfad weitergearbeitet.
|
|
||||||
</p>
|
|
||||||
{sessionPendingResume?.checkpoint?.last_completed_path ? (
|
|
||||||
<div className="rounded-md border border-line bg-sunken p-2.5">
|
|
||||||
<p className="text-xs text-fg-subtle">Zuletzt abgeschlossen</p>
|
|
||||||
<p className="break-all font-mono text-xs text-fg">
|
|
||||||
{sessionPendingResume.checkpoint.last_completed_path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Callout tone="warning">
|
|
||||||
Die beim Abbruch angefangene Datei wird ersetzt.
|
|
||||||
</Callout>
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Einzelheiten einer Wiederherstellung. */
|
|
||||||
function RestoreDetailCard({
|
|
||||||
session,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
readonly session: RestoreSession;
|
|
||||||
readonly onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader
|
|
||||||
title="Einzelheiten"
|
|
||||||
description={session.target_path}
|
|
||||||
action={
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
Schließen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<DetailItem label="Zustand">
|
|
||||||
<StatusBadge status={session.status} />
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Begonnen">{formatDateTime(session.started_at)}</DetailItem>
|
|
||||||
<DetailItem label="Beendet">{formatDateTime(session.completed_at)}</DetailItem>
|
|
||||||
<DetailItem label="Dauer">{formatDuration(session.duration_seconds)}</DetailItem>
|
|
||||||
<DetailItem label="Dateien">
|
|
||||||
{session.files_restored.toLocaleString('de-DE')}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Übergangen">
|
|
||||||
{session.files_skipped.toLocaleString('de-DE')}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
|
|
||||||
<DetailItem label="Überschreiben erlaubt">
|
|
||||||
{session.overwrite_existing ? 'ja' : 'nein'}
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{session.error_code ? (
|
|
||||||
<Callout tone="critical" title={session.error_code} className="mt-4">
|
|
||||||
{session.error_message}
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{session.checkpoint ? (
|
|
||||||
<div className="mt-4 rounded-md border border-line bg-sunken p-3">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
||||||
Prüfpunkt
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 break-all font-mono text-xs text-fg">
|
|
||||||
{session.checkpoint.last_completed_path ?? '—'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-fg-muted">
|
|
||||||
Er entsteht erst, <strong>nachdem</strong> eine Datei vollständig
|
|
||||||
und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei
|
|
||||||
wäre schlimmer als keiner.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<p className="mt-4 font-mono text-xs text-fg-subtle">
|
|
||||||
Vorgangsnummer: {session.correlation_id}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Blättern im Dateisystem des Servers und im Inhalt eines Backups.
|
|
||||||
*
|
|
||||||
* Beides gehört zur Wiederherstellung: Wohin darf zurückgeschrieben werden, und
|
|
||||||
* was steckt überhaupt in dem Backup? Ein Textfeld beantwortet weder das eine
|
|
||||||
* noch das andere.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
/** Ein Verzeichnis auf dem Server. */
|
|
||||||
export interface FilesystemEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
is_directory: boolean;
|
|
||||||
/**
|
|
||||||
* Meldet, ob der **Dienst** hier anlegen darf.
|
|
||||||
*
|
|
||||||
* Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: Der
|
|
||||||
* Dienst läuft mit `ProtectSystem=strict`, und davon steht nichts im Modus.
|
|
||||||
*/
|
|
||||||
is_writable: boolean;
|
|
||||||
/** Grund, wenn der Zielschutz den Ort ausschließt. */
|
|
||||||
forbidden_reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Antwort auf das Blättern im Dateisystem. */
|
|
||||||
export interface BrowseFilesystemResponse {
|
|
||||||
path: string;
|
|
||||||
parent_path?: string;
|
|
||||||
entries: FilesystemEntry[];
|
|
||||||
/** Orte, an denen der Dienst nachweislich schreiben darf. */
|
|
||||||
suggested_paths?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Eintrag im Inhaltsverzeichnis eines Backups. */
|
|
||||||
export interface BackupContentEntry {
|
|
||||||
name: string;
|
|
||||||
/** Genau der Wert, den eine Wiederherstellung als `path_prefix` erwartet. */
|
|
||||||
path: string;
|
|
||||||
is_directory: boolean;
|
|
||||||
entry_type: string;
|
|
||||||
size_bytes?: number;
|
|
||||||
modified_at?: string;
|
|
||||||
mode?: string;
|
|
||||||
/** Zahl der Einträge unterhalb eines Verzeichnisses. */
|
|
||||||
child_count?: number;
|
|
||||||
/** Datenmenge unterhalb eines Verzeichnisses. */
|
|
||||||
total_bytes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Antwort auf das Blättern im Backup. */
|
|
||||||
export interface BrowseBackupResponse {
|
|
||||||
backup_id: string;
|
|
||||||
path: string;
|
|
||||||
parent_path?: string;
|
|
||||||
entries: BackupContentEntry[];
|
|
||||||
total_entry_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Listet die Verzeichnisse unterhalb eines Pfades. */
|
|
||||||
export async function browseFilesystem(
|
|
||||||
directoryPath: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<BrowseFilesystemResponse> {
|
|
||||||
return requestApi<BrowseFilesystemResponse>(
|
|
||||||
`/filesystem/browse?path=${encodeURIComponent(directoryPath)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Listet den Inhalt eines Backups auf einer Ebene. */
|
|
||||||
export async function browseBackupContents(
|
|
||||||
backupIdentifier: string,
|
|
||||||
contentPath: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<BrowseBackupResponse> {
|
|
||||||
return requestApi<BrowseBackupResponse>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* API-Anbindung der Wiederherstellung (SYNCOVA_API.md §12).
|
|
||||||
*
|
|
||||||
* Die Vorabprüfung ist der Kern, nicht das Zurückschreiben: `validate`
|
|
||||||
* schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* —
|
|
||||||
* insbesondere, ob **jeder benötigte Block noch da ist**. Ein Manifest allein
|
|
||||||
* belegt nur, dass jemand einmal etwas gesichert hat.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
/** Schweregrad eines Befundes der Vorabprüfung. */
|
|
||||||
export type ValidationSeverity = 'blocking' | 'warning' | 'info';
|
|
||||||
|
|
||||||
/** Einzelner Befund der Vorabprüfung. */
|
|
||||||
export interface ValidationFinding {
|
|
||||||
/** Maschinenlesbarer Code. */
|
|
||||||
code: string;
|
|
||||||
/** Schweregrad; nur `blocking` verhindert die Ausführung. */
|
|
||||||
severity: ValidationSeverity;
|
|
||||||
/** Meldung im Klartext. */
|
|
||||||
message: string;
|
|
||||||
/** Betroffenes Objekt, sofern benennbar. */
|
|
||||||
detail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bericht der Vorabprüfung. */
|
|
||||||
export interface ValidationReport {
|
|
||||||
backup_id: string;
|
|
||||||
target_path: string;
|
|
||||||
findings: ValidationFinding[];
|
|
||||||
/** Zahl wiederherzustellender Objekte insgesamt. */
|
|
||||||
entry_count: number;
|
|
||||||
/** Zahl wiederherzustellender Dateien. */
|
|
||||||
file_count: number;
|
|
||||||
/** Zurückzuschreibende Datenmenge. */
|
|
||||||
total_bytes: number;
|
|
||||||
/** Zahl benötigter Blöcke. */
|
|
||||||
unique_chunk_count: number;
|
|
||||||
/** Zahl fehlender Blöcke — jeder einzelne verhindert die Wiederherstellung. */
|
|
||||||
missing_chunk_count: number;
|
|
||||||
/** Freier Platz am Ziel; -1 bedeutet unbekannt. */
|
|
||||||
available_target_bytes: number;
|
|
||||||
checked_at: string;
|
|
||||||
duration_seconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Antwort der Vorabprüfung. */
|
|
||||||
export interface ValidationResponse {
|
|
||||||
/** Darf die Wiederherstellung beginnen? */
|
|
||||||
can_proceed: boolean;
|
|
||||||
/** Verlangt das Ziel eine wörtliche Bestätigung? */
|
|
||||||
requires_overwrite_confirmation: boolean;
|
|
||||||
/** Zusammenfassung in einem Satz. */
|
|
||||||
summary: string;
|
|
||||||
report: ValidationReport | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prüfpunkt einer unterbrochenen Wiederherstellung. */
|
|
||||||
export interface RestoreCheckpoint {
|
|
||||||
/** Zuletzt vollständig geschriebener Pfad. */
|
|
||||||
last_completed_path?: string;
|
|
||||||
files_restored?: number;
|
|
||||||
bytes_restored?: number;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eine Wiederherstellung. */
|
|
||||||
export interface RestoreSession {
|
|
||||||
id: string;
|
|
||||||
backup_id: string;
|
|
||||||
target_type: string;
|
|
||||||
target_path: string;
|
|
||||||
path_prefix?: string;
|
|
||||||
status: string;
|
|
||||||
overwrite_existing: boolean;
|
|
||||||
started_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
duration_seconds?: number;
|
|
||||||
bytes_restored: number;
|
|
||||||
files_restored: number;
|
|
||||||
files_skipped: number;
|
|
||||||
error_code?: string;
|
|
||||||
error_message?: string;
|
|
||||||
validation_report?: ValidationReport;
|
|
||||||
checkpoint?: RestoreCheckpoint;
|
|
||||||
correlation_id: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rumpf beim Anlegen einer Wiederherstellung. */
|
|
||||||
export interface CreateRestoreRequest {
|
|
||||||
backup_id: string;
|
|
||||||
/** Zielart, etwa `filesystem`. */
|
|
||||||
target_type: string;
|
|
||||||
/** Zielverzeichnis. */
|
|
||||||
target_path: string;
|
|
||||||
/** Beschränkung auf einen Teilbaum des Manifests. */
|
|
||||||
path_prefix?: string;
|
|
||||||
/** Erste Hürde: das Kennzeichen. */
|
|
||||||
overwrite_existing?: boolean;
|
|
||||||
/**
|
|
||||||
* Dritte Hürde: der Zielpfad, wörtlich wiederholt.
|
|
||||||
*
|
|
||||||
* Die zweite Hürde ist die Berechtigung `restores.overwrite` und liegt beim
|
|
||||||
* Server. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht
|
|
||||||
* damit nicht aus.
|
|
||||||
*/
|
|
||||||
confirm_overwrite?: string;
|
|
||||||
/** Rechte nicht mit zurückschreiben. */
|
|
||||||
skip_permissions?: boolean;
|
|
||||||
/**
|
|
||||||
* Blockprüfung überspringen.
|
|
||||||
*
|
|
||||||
* Beschleunigt die Vorabprüfung erheblich und **senkt ihre Aussagekraft**:
|
|
||||||
* Ohne sie ist nicht belegt, dass jeder benötigte Block noch da ist. Der
|
|
||||||
* Bericht weist das als Hinweis aus.
|
|
||||||
*/
|
|
||||||
skip_deep_check?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Führt die Vorabprüfung aus. Sie schreibt nichts. */
|
|
||||||
export async function validateRestore(
|
|
||||||
validationRequest: CreateRestoreRequest,
|
|
||||||
): Promise<ValidationResponse> {
|
|
||||||
return requestApi<ValidationResponse>('/restores/validate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: validationRequest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legt eine Wiederherstellung an und startet sie. */
|
|
||||||
export async function createRestore(
|
|
||||||
restoreRequest: CreateRestoreRequest,
|
|
||||||
): Promise<RestoreSession> {
|
|
||||||
return requestApi<RestoreSession>('/restores', {
|
|
||||||
method: 'POST',
|
|
||||||
body: restoreRequest,
|
|
||||||
idempotencyKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Wiederherstellungen. */
|
|
||||||
export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSession[]> {
|
|
||||||
return requestApi<RestoreSession[]>(
|
|
||||||
'/restores?page_size=50',
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt eine einzelne Wiederherstellung. */
|
|
||||||
export async function getRestore(
|
|
||||||
restoreIdentifier: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<RestoreSession> {
|
|
||||||
return requestApi<RestoreSession>(
|
|
||||||
`/restores/${encodeURIComponent(restoreIdentifier)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bricht eine laufende Wiederherstellung ab. */
|
|
||||||
export async function cancelRestore(restoreIdentifier: string): Promise<RestoreSession> {
|
|
||||||
return requestApi<RestoreSession>(
|
|
||||||
`/restores/${encodeURIComponent(restoreIdentifier)}/cancel`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setzt eine unterbrochene Wiederherstellung fort.
|
|
||||||
*
|
|
||||||
* Es gibt bewusst **keinen** automatischen Wiederholungsversuch: Ein zweiter
|
|
||||||
* Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der erste
|
|
||||||
* bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung.
|
|
||||||
*/
|
|
||||||
export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> {
|
|
||||||
return requestApi<RestoreSession>(
|
|
||||||
`/restores/${encodeURIComponent(restoreIdentifier)}/resume`,
|
|
||||||
{ method: 'POST', idempotencyKey: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Security Center.
|
* Security Center.
|
||||||
*
|
*
|
||||||
* Die Seite, die in Phase 12 als „noch nicht verfügbar" stand — mit dem
|
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" stand — mit dem
|
||||||
* Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt
|
* Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt
|
||||||
* gibt es beides.
|
* gibt es beides.
|
||||||
*
|
*
|
||||||
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt
|
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt
|
||||||
* Schweregrad, Erklärung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
|
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
|
||||||
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
|
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { requestApi } from '@/api/client';
|
import { requestApi } from '../../api/client';
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
import { describeApiError } from '@/api/useMutation';
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import {
|
|
||||||
Callout,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
DataTable,
|
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
StatusBadge,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
/** Schweregrad eines Befunds. */
|
/** Schweregrad eines Befunds. */
|
||||||
type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
|
type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
|
||||||
|
|
||||||
/** Ein Sicherheitsbefund. */
|
/** Ein Sicherheitsbefund. */
|
||||||
interface SecurityFinding {
|
interface SecurityFinding {
|
||||||
/** Prüfbereich. */
|
/** Pruefbereich. */
|
||||||
readonly area: string;
|
readonly area: string;
|
||||||
/** Schweregrad. */
|
/** Schweregrad. */
|
||||||
readonly severity: FindingSeverity;
|
readonly severity: FindingSeverity;
|
||||||
/** Überschrift. */
|
/** Ueberschrift. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Erklärung, warum der Zustand ein Problem ist. */
|
/** Erklaerung, warum der Zustand ein Problem ist. */
|
||||||
readonly explanation: string;
|
readonly explanation: string;
|
||||||
/** Das betroffene Objekt. */
|
/** Das betroffene Objekt. */
|
||||||
readonly affected_object: string;
|
readonly affected_object: string;
|
||||||
/** Die nächste Handlung. */
|
/** Die naechste Handlung. */
|
||||||
readonly recommendation: string;
|
readonly recommendation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis eines Prüfbereichs. */
|
/** Ergebnis eines Pruefbereichs. */
|
||||||
interface AreaResult {
|
interface AreaResult {
|
||||||
/** Bezeichner. */
|
/** Bezeichner. */
|
||||||
readonly area: string;
|
readonly area: string;
|
||||||
/** Bezeichnung. */
|
/** Bezeichnung. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Meldet, ob der Bereich geprüft werden konnte. */
|
/** Meldet, ob der Bereich geprueft werden konnte. */
|
||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
/** Erklärt einen nicht prüfbaren Bereich. */
|
/** Erklaert einen nicht pruefbaren Bereich. */
|
||||||
readonly unavailable_reason?: string;
|
readonly unavailable_reason?: string;
|
||||||
/** Befunde des Bereichs. */
|
/** Befunde des Bereichs. */
|
||||||
readonly findings: readonly SecurityFinding[];
|
readonly findings: readonly SecurityFinding[];
|
||||||
@ -72,13 +58,13 @@ interface AreaResult {
|
|||||||
interface SecurityAssessment {
|
interface SecurityAssessment {
|
||||||
/** Gesamtbewertung in Prozent. */
|
/** Gesamtbewertung in Prozent. */
|
||||||
readonly score: number;
|
readonly score: number;
|
||||||
/** Erreichbare Punkte der geprüften Bereiche. */
|
/** Erreichbare Punkte der geprueften Bereiche. */
|
||||||
readonly maximum_score: number;
|
readonly maximum_score: number;
|
||||||
/** Einstufung. */
|
/** Einstufung. */
|
||||||
readonly grade: string;
|
readonly grade: string;
|
||||||
/** Ergebnisse je Bereich. */
|
/** Ergebnisse je Bereich. */
|
||||||
readonly areas: readonly AreaResult[];
|
readonly areas: readonly AreaResult[];
|
||||||
/** Zahl nicht prüfbarer Bereiche. */
|
/** Zahl nicht pruefbarer Bereiche. */
|
||||||
readonly unchecked_area_count: number;
|
readonly unchecked_area_count: number;
|
||||||
/** Zahl kritischer Befunde. */
|
/** Zahl kritischer Befunde. */
|
||||||
readonly critical_finding_count: number;
|
readonly critical_finding_count: number;
|
||||||
@ -86,7 +72,7 @@ interface SecurityAssessment {
|
|||||||
readonly high_finding_count: number;
|
readonly high_finding_count: number;
|
||||||
/** Zusammenfassung. */
|
/** Zusammenfassung. */
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
/** Nicht geprüfte Bereiche. */
|
/** Nicht gepruefte Bereiche. */
|
||||||
readonly unchecked_areas: readonly string[];
|
readonly unchecked_areas: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,111 +94,68 @@ export function SecurityPage(): React.JSX.Element {
|
|||||||
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
|
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Sicherheit</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Sicherheitslage" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
|
||||||
title="Security Center"
|
|
||||||
description="Was geprüft wurde — und was nicht."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loadState === 'loading' ? (
|
|
||||||
<Card>
|
|
||||||
<LoadingState label="Die Sicherheitslage wird ermittelt …" />
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'failed' && loadError !== null ? (
|
|
||||||
<Card>
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(loadError)}
|
|
||||||
requestId={loadError.requestId}
|
|
||||||
onRetry={reload}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ScoreHeader response={data} />
|
<ScoreHeader response={data} />
|
||||||
<FindingList assessment={data.assessment} />
|
<FindingList assessment={data.assessment} />
|
||||||
<AreaTable assessment={data.assessment} />
|
<AreaTable assessment={data.assessment} />
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Eigenschaften der Gesamtbewertung. */
|
||||||
* Zeigt die Gesamtbewertung.
|
interface ScoreHeaderProperties {
|
||||||
*
|
/** Die Antwort des Security Center. */
|
||||||
* Neben der Prozentzahl steht immer die erreichbare Höchstpunktzahl und die
|
readonly response: SecurityResponse;
|
||||||
* Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in die
|
}
|
||||||
* Rechnung ein — ihn als bestanden zu werten wäre Schönfärberei, als
|
|
||||||
* durchgefallen eine Behauptung.
|
/** Zeigt die Gesamtbewertung. */
|
||||||
*
|
function ScoreHeader({ response }: ScoreHeaderProperties): React.JSX.Element {
|
||||||
* Ein kritischer Befund deckelt die Einstufung unabhängig von der Zahl: 88 von
|
|
||||||
* 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend".
|
|
||||||
*/
|
|
||||||
function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element {
|
|
||||||
const { assessment } = response;
|
const { assessment } = response;
|
||||||
const criticalCount = assessment.areas
|
|
||||||
.flatMap((areaResult) => areaResult.findings)
|
|
||||||
.filter((finding) => finding.severity === 'critical').length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className={`score-card score-card--${gradeClassOf(assessment)}`}>
|
||||||
<CardContent className="flex flex-wrap items-start gap-6">
|
<div className="score-card__figure">
|
||||||
<div>
|
<span className="score-card__value">{assessment.score}</span>
|
||||||
<p
|
<span className="score-card__unit">%</span>
|
||||||
className={cn(
|
</div>
|
||||||
'text-4xl font-semibold tabular',
|
|
||||||
criticalCount > 0 ? 'text-critical' : 'text-fg',
|
<div className="score-card__text">
|
||||||
|
<p className="score-card__grade">{assessment.grade}</p>
|
||||||
|
<p className="score-card__summary">{assessment.summary}</p>
|
||||||
|
|
||||||
|
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
|
||||||
|
Handlungsanweisung, nicht nur eine Einschraenkung. */}
|
||||||
|
{assessment.unchecked_areas.length > 0 && (
|
||||||
|
<p className="score-card__unchecked">
|
||||||
|
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{assessment.score}
|
|
||||||
<span className="ml-1 text-lg text-fg-muted">%</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-fg">{assessment.grade}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<p className="text-sm text-fg">{assessment.summary}</p>
|
|
||||||
|
|
||||||
{criticalCount > 0 ? (
|
|
||||||
<Callout tone="critical">
|
|
||||||
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
|
|
||||||
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
|
|
||||||
„unzureichend" — unabhängig von der Punktzahl.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die
|
|
||||||
Handlungsanweisung, nicht nur eine Einschränkung. */}
|
|
||||||
{assessment.unchecked_areas.length > 0 ? (
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
Nicht geprüft: {assessment.unchecked_areas.join(', ')}
|
|
||||||
{assessment.unchecked_areas.length >= 3
|
|
||||||
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Eigenschaften der Befundliste. */
|
||||||
* Zeigt die Befunde nach Schweregrad geordnet.
|
interface FindingListProperties {
|
||||||
*
|
/** Die Sicherheitslage. */
|
||||||
* Jeder Befund trägt Schweregrad, Erklärung, betroffenes Objekt und
|
|
||||||
* Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass
|
|
||||||
* etwas nicht stimmt, und lässt den Betreiber damit allein.
|
|
||||||
*/
|
|
||||||
function FindingList({
|
|
||||||
assessment,
|
|
||||||
}: {
|
|
||||||
readonly assessment: SecurityAssessment;
|
readonly assessment: SecurityAssessment;
|
||||||
}): React.JSX.Element {
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Befunde nach Schweregrad geordnet. */
|
||||||
|
function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
|
||||||
const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings);
|
const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings);
|
||||||
|
|
||||||
const orderedFindings = [...allFindings].sort(
|
const orderedFindings = [...allFindings].sort(
|
||||||
@ -222,113 +165,130 @@ function FindingList({
|
|||||||
|
|
||||||
if (orderedFindings.length === 0) {
|
if (orderedFindings.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<p className="page__state page__state--empty">
|
||||||
<EmptyState
|
Keine Befunde in den geprueften Bereichen.
|
||||||
title="Keine Befunde in den geprüften Bereichen"
|
</p>
|
||||||
description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ul className="finding-list">
|
||||||
<CardHeader title={`Befunde (${orderedFindings.length})`} />
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{orderedFindings.map((finding) => (
|
{orderedFindings.map((finding) => (
|
||||||
<li
|
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}>
|
||||||
key={`${finding.area}-${finding.title}`}
|
<div className="finding__head">
|
||||||
className="rounded-md border border-line p-3"
|
<span className={`badge badge--${severityClassOf(finding.severity)}`}>
|
||||||
>
|
{finding.severity}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</span>
|
||||||
<StatusBadge status={finding.severity} />
|
<h2 className="finding__title">{finding.title}</h2>
|
||||||
<h3 className="font-medium text-fg">{finding.title}</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1.5 text-sm text-fg-muted">{finding.explanation}</p>
|
<p className="finding__explanation">{finding.explanation}</p>
|
||||||
|
|
||||||
<dl className="mt-3 space-y-2 border-t border-line pt-2 text-sm">
|
<dl className="finding__details">
|
||||||
<div className="flex gap-2">
|
<div className="finding__detail">
|
||||||
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
|
<dt>Betroffen</dt>
|
||||||
Betroffen
|
<dd>{finding.affected_object}</dd>
|
||||||
</dt>
|
|
||||||
<dd className="min-w-0 break-all text-fg">{finding.affected_object}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
|
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der
|
||||||
der Befund überhaupt angezeigt wird. */}
|
Befund ueberhaupt angezeigt wird. */}
|
||||||
<div className="flex gap-2">
|
<div className="finding__detail finding__detail--recommendation">
|
||||||
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
|
<dt>Naechster Schritt</dt>
|
||||||
Nächster Schritt
|
<dd>{finding.recommendation}</dd>
|
||||||
</dt>
|
|
||||||
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt die Ergebnisse je Prüfbereich. */
|
/** Eigenschaften der Bereichsuebersicht. */
|
||||||
function AreaTable({
|
interface AreaTableProperties {
|
||||||
assessment,
|
/** Die Sicherheitslage. */
|
||||||
}: {
|
|
||||||
readonly assessment: SecurityAssessment;
|
readonly assessment: SecurityAssessment;
|
||||||
}): React.JSX.Element {
|
}
|
||||||
const areaColumns: ReadonlyArray<TableColumn<AreaResult>> = [
|
|
||||||
{
|
/** Zeigt die Ergebnisse je Pruefbereich. */
|
||||||
key: 'title',
|
function AreaTable({ assessment }: AreaTableProperties): React.JSX.Element {
|
||||||
header: 'Bereich',
|
return (
|
||||||
render: (areaResult) => <span className="font-medium text-fg">{areaResult.title}</span>,
|
<section className="area-overview">
|
||||||
},
|
<h2 className="area-overview__title">Geprueft wurde</h2>
|
||||||
{
|
|
||||||
key: 'result',
|
<div className="table-wrapper">
|
||||||
header: 'Ergebnis',
|
<table className="data-table">
|
||||||
render: (areaResult) =>
|
<thead>
|
||||||
areaResult.available ? (
|
<tr>
|
||||||
<span className="text-sm text-fg">{areaResult.summary}</span>
|
<th scope="col">Bereich</th>
|
||||||
|
<th scope="col">Ergebnis</th>
|
||||||
|
<th scope="col">Punkte</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{assessment.areas.map((areaResult) => (
|
||||||
|
<tr key={areaResult.area}>
|
||||||
|
<td>{areaResult.title}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{areaResult.available ? (
|
||||||
|
areaResult.summary
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<>
|
||||||
<StatusBadge status="neutral" label="nicht prüfbar" />
|
<span className="badge badge--neutral">nicht pruefbar</span>
|
||||||
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
|
<span className="data-table__secondary">{areaResult.unavailable_reason}</span>
|
||||||
</div>
|
</>
|
||||||
),
|
)}
|
||||||
},
|
</td>
|
||||||
{
|
|
||||||
key: 'points',
|
<td className="data-table__number">
|
||||||
header: 'Punkte',
|
{areaResult.available ? (
|
||||||
numeric: true,
|
|
||||||
render: (areaResult) =>
|
|
||||||
areaResult.available ? (
|
|
||||||
`${areaResult.earned_points} / ${areaResult.weight}`
|
`${areaResult.earned_points} / ${areaResult.weight}`
|
||||||
) : (
|
) : (
|
||||||
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
|
// Ein ungeprueft Bereich geht nicht in die Rechnung ein —
|
||||||
// positiv noch negativ.
|
// weder positiv noch negativ.
|
||||||
<span className="text-xs text-fg-subtle">zählt nicht</span>
|
<span className="data-table__unknown">zaehlt nicht</span>
|
||||||
),
|
)}
|
||||||
},
|
</td>
|
||||||
];
|
</tr>
|
||||||
|
))}
|
||||||
return (
|
</tbody>
|
||||||
<Card>
|
</table>
|
||||||
<CardHeader
|
</div>
|
||||||
title="Geprüft wurde"
|
</section>
|
||||||
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
|
|
||||||
/>
|
|
||||||
<DataTable
|
|
||||||
columns={areaColumns}
|
|
||||||
rows={assessment.areas}
|
|
||||||
getRowKey={(areaResult) => areaResult.area}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bildet die Einstufung auf eine Statusfarbe ab. */
|
||||||
|
function gradeClassOf(assessment: SecurityAssessment): string {
|
||||||
|
if (assessment.critical_finding_count > 0) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assessment.score >= 90) {
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assessment.score >= 70) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
|
||||||
|
function severityClassOf(severity: FindingSeverity): string {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'critical';
|
||||||
|
case 'high':
|
||||||
|
return 'high';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Liefert die Ordnungszahl eines Schweregrads. */
|
/** Liefert die Ordnungszahl eines Schweregrads. */
|
||||||
function severityRankOf(severity: FindingSeverity): number {
|
function severityRankOf(severity: FindingSeverity): number {
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
/** Kleine Helfer, die quer durch die Oberfläche gebraucht werden. */
|
|
||||||
|
|
||||||
import { clsx, type ClassValue } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Führt Klassennamen zusammen und löst Tailwind-Konflikte auf.
|
|
||||||
*
|
|
||||||
* Ohne twMerge gewänne bei "p-2 p-4" die Reihenfolge im Stylesheet, nicht die
|
|
||||||
* im Aufruf — eine Komponente ließe sich dann von außen nicht mehr anpassen,
|
|
||||||
* und das fällt erst auf, wenn jemand es versucht.
|
|
||||||
*/
|
|
||||||
export function cn(...classValues: ClassValue[]): string {
|
|
||||||
return twMerge(clsx(classValues));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formatiert eine Byte-Zahl für die Anzeige.
|
|
||||||
*
|
|
||||||
* Bewusst mit binären Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB
|
|
||||||
* vermischt, erzeugt Rückfragen bei jeder Kapazitätsplanung.
|
|
||||||
*/
|
|
||||||
export function formatBytes(byteCount: number | null | undefined): string {
|
|
||||||
// Unbekannt ist nicht null. Eine 0 läse sich wie "nichts abgelegt" —
|
|
||||||
// derselbe Grundsatz wie bei den Kennzahlen aus Phase 13.
|
|
||||||
if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (byteCount === 0) {
|
|
||||||
return '0 B';
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitNames = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
|
||||||
const unitIndex = Math.min(
|
|
||||||
Math.floor(Math.log(Math.abs(byteCount)) / Math.log(1024)),
|
|
||||||
unitNames.length - 1,
|
|
||||||
);
|
|
||||||
const scaledValue = byteCount / Math.pow(1024, unitIndex);
|
|
||||||
|
|
||||||
// Unter 10 eine Nachkommastelle, darüber keine: "1,4 GiB" ist nützlich,
|
|
||||||
// "847,3 GiB" tut nur so genau.
|
|
||||||
const decimalPlaces = unitIndex === 0 ? 0 : scaledValue < 10 ? 1 : 0;
|
|
||||||
|
|
||||||
return `${scaledValue.toLocaleString('de-DE', {
|
|
||||||
minimumFractionDigits: decimalPlaces,
|
|
||||||
maximumFractionDigits: decimalPlaces,
|
|
||||||
})} ${unitNames[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formatiert einen Zeitstempel als deutsches Datum mit Uhrzeit. */
|
|
||||||
export function formatDateTime(isoTimestamp: string | null | undefined): string {
|
|
||||||
if (!isoTimestamp) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedDate = new Date(isoTimestamp);
|
|
||||||
|
|
||||||
if (Number.isNaN(parsedDate.getTime())) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedDate.toLocaleString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formatiert einen Zeitpunkt als Abstand zu jetzt ("vor 3 Stunden").
|
|
||||||
*
|
|
||||||
* Für eine Betriebskonsole ist der Abstand oft die wichtigere Angabe: Ob die
|
|
||||||
* letzte Sicherung um 02:15 lief, sagt wenig — dass sie vor drei Tagen lief,
|
|
||||||
* sagt alles.
|
|
||||||
*/
|
|
||||||
export function formatRelativeTime(isoTimestamp: string | null | undefined): string {
|
|
||||||
if (!isoTimestamp) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedDate = new Date(isoTimestamp);
|
|
||||||
|
|
||||||
if (Number.isNaN(parsedDate.getTime())) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedSeconds = (Date.now() - parsedDate.getTime()) / 1000;
|
|
||||||
const isInFuture = elapsedSeconds < 0;
|
|
||||||
const absoluteSeconds = Math.abs(elapsedSeconds);
|
|
||||||
|
|
||||||
const relativeFormatter = new Intl.RelativeTimeFormat('de-DE', { numeric: 'auto' });
|
|
||||||
|
|
||||||
if (absoluteSeconds < 45) {
|
|
||||||
return isInFuture ? 'gleich' : 'gerade eben';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schwelle, Teiler und Einheit gehören zusammen und stehen deshalb in einem
|
|
||||||
// Eintrag. Drei parallele Arrays wären die Sorte Code, bei der ein
|
|
||||||
// eingeschobener Eintrag still die Zuordnung verschiebt.
|
|
||||||
const timeScales = [
|
|
||||||
{ limit: 3600, divisor: 60, unit: 'minute' },
|
|
||||||
{ limit: 86400, divisor: 3600, unit: 'hour' },
|
|
||||||
{ limit: 2592000, divisor: 86400, unit: 'day' },
|
|
||||||
{ limit: 31536000, divisor: 2592000, unit: 'month' },
|
|
||||||
{ limit: Number.POSITIVE_INFINITY, divisor: 31536000, unit: 'year' },
|
|
||||||
] as const satisfies ReadonlyArray<{
|
|
||||||
limit: number;
|
|
||||||
divisor: number;
|
|
||||||
unit: Intl.RelativeTimeFormatUnit;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
for (const scale of timeScales) {
|
|
||||||
if (absoluteSeconds < scale.limit) {
|
|
||||||
const unitValue = Math.round(absoluteSeconds / scale.divisor);
|
|
||||||
|
|
||||||
return relativeFormatter.format(isInFuture ? unitValue : -unitValue, scale.unit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatDateTime(isoTimestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formatiert eine Dauer in Sekunden als lesbare Angabe. */
|
|
||||||
export function formatDuration(totalSeconds: number | null | undefined): string {
|
|
||||||
if (totalSeconds === null || totalSeconds === undefined || Number.isNaN(totalSeconds)) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalSeconds < 1) {
|
|
||||||
return `${Math.round(totalSeconds * 1000)} ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalSeconds < 60) {
|
|
||||||
return `${totalSeconds.toFixed(totalSeconds < 10 ? 1 : 0)} s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wholeMinutes = Math.floor(totalSeconds / 60);
|
|
||||||
const remainingSeconds = Math.round(totalSeconds % 60);
|
|
||||||
|
|
||||||
if (wholeMinutes < 60) {
|
|
||||||
return `${wholeMinutes} min ${remainingSeconds} s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wholeHours = Math.floor(wholeMinutes / 60);
|
|
||||||
const remainingMinutes = wholeMinutes % 60;
|
|
||||||
|
|
||||||
return `${wholeHours} h ${remainingMinutes} min`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formatiert einen Durchsatz in Byte je Sekunde.
|
|
||||||
*/
|
|
||||||
export function formatThroughput(bytesPerSecond: number | null | undefined): string {
|
|
||||||
if (bytesPerSecond === null || bytesPerSecond === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${formatBytes(bytesPerSecond)}/s`;
|
|
||||||
}
|
|
||||||
@ -1,14 +1,14 @@
|
|||||||
/** Einstiegspunkt der Syncova-Oberfläche. */
|
/** Einstiegspunkt der Syncova-Oberflaeche. */
|
||||||
|
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import './styles/theme.css';
|
import './styles/tokens.css';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
// Fehlt der Wurzelknoten, ist das Dokument fehlerhaft ausgeliefert worden.
|
// Fehlt der Wurzelknoten, ist das Dokument fehlerhaft ausgeliefert worden.
|
||||||
// Ein stiller Abbruch würde nur eine leere Seite hinterlassen.
|
// Ein stiller Abbruch wuerde nur eine leere Seite hinterlassen.
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.');
|
throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.');
|
||||||
}
|
}
|
||||||
|
|||||||
108
apps/web/src/navigation/NavigationSidebar.tsx
Normal file
108
apps/web/src/navigation/NavigationSidebar.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Seitenleiste mit der Bereichsnavigation.
|
||||||
|
*
|
||||||
|
* Eintraege ohne Backend erscheinen deaktiviert statt versteckt: Wer die Anlage
|
||||||
|
* bedient, soll den Ausbaustand sehen — und beim Klick erfahren, was fehlt,
|
||||||
|
* statt in eine leere Maske zu laufen (PROMPT.md §139).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ALL_PAGES, SECTION_LABELS, SECTION_ORDER, mayViewPage } from './pages';
|
||||||
|
import type { PageDefinition } from './pages';
|
||||||
|
|
||||||
|
/** Eigenschaften der Seitenleiste. */
|
||||||
|
interface NavigationSidebarProperties {
|
||||||
|
/** Bezeichner der angezeigten Seite. */
|
||||||
|
readonly currentPageId: string;
|
||||||
|
/** Berechtigungen des angemeldeten Benutzers. */
|
||||||
|
readonly grantedPermissions: readonly string[];
|
||||||
|
/** Wird beim Wechsel auf eine andere Seite gerufen. */
|
||||||
|
readonly onNavigate: (pageIdentifier: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Bereichsnavigation. */
|
||||||
|
export function NavigationSidebar({
|
||||||
|
currentPageId,
|
||||||
|
grantedPermissions,
|
||||||
|
onNavigate,
|
||||||
|
}: NavigationSidebarProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<nav className="navigation" aria-label="Bereiche">
|
||||||
|
{SECTION_ORDER.map((sectionName) => {
|
||||||
|
// Seiten, die der Benutzer ohnehin nicht aufrufen darf, werden nicht
|
||||||
|
// gezeigt. Das ist keine Sicherheitsmassnahme — die liegt auf dem Server
|
||||||
|
// — sondern verhindert eine Oberflaeche voller Sackgassen.
|
||||||
|
const visiblePages = ALL_PAGES.filter(
|
||||||
|
(pageDefinition) =>
|
||||||
|
pageDefinition.section === sectionName && mayViewPage(pageDefinition, grantedPermissions),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visiblePages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="navigation__section" key={sectionName}>
|
||||||
|
<h2 className="navigation__section-title">{SECTION_LABELS[sectionName]}</h2>
|
||||||
|
|
||||||
|
<ul className="navigation__list">
|
||||||
|
{visiblePages.map((pageDefinition) => (
|
||||||
|
<li key={pageDefinition.id}>
|
||||||
|
<NavigationEntry
|
||||||
|
pageDefinition={pageDefinition}
|
||||||
|
isCurrent={pageDefinition.id === currentPageId}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften eines Navigationseintrags. */
|
||||||
|
interface NavigationEntryProperties {
|
||||||
|
/** Die dargestellte Seite. */
|
||||||
|
readonly pageDefinition: PageDefinition;
|
||||||
|
/** Meldet die derzeit angezeigte Seite. */
|
||||||
|
readonly isCurrent: boolean;
|
||||||
|
/** Wird beim Wechsel gerufen. */
|
||||||
|
readonly onNavigate: (pageIdentifier: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen einzelnen Navigationseintrag. */
|
||||||
|
function NavigationEntry({
|
||||||
|
pageDefinition,
|
||||||
|
isCurrent,
|
||||||
|
onNavigate,
|
||||||
|
}: NavigationEntryProperties): React.JSX.Element {
|
||||||
|
const entryClassNames = ['navigation__entry'];
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
entryClassNames.push('navigation__entry--current');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageDefinition.available) {
|
||||||
|
entryClassNames.push('navigation__entry--unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={entryClassNames.join(' ')}
|
||||||
|
type="button"
|
||||||
|
aria-current={isCurrent ? 'page' : undefined}
|
||||||
|
onClick={() => onNavigate(pageDefinition.id)}
|
||||||
|
>
|
||||||
|
<span className="navigation__entry-label">{pageDefinition.label}</span>
|
||||||
|
|
||||||
|
{/* Der Vermerk steht am Eintrag, nicht nur auf der Seite dahinter: Sonst
|
||||||
|
muesste man jeden Bereich einzeln anklicken, um den Ausbaustand zu
|
||||||
|
erkennen. */}
|
||||||
|
{!pageDefinition.available && (
|
||||||
|
<span className="navigation__entry-badge">noch nicht verfuegbar</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,50 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Seite für einen Bereich ohne Bedienoberfläche.
|
* Seite fuer einen Bereich ohne Backend.
|
||||||
*
|
*
|
||||||
* Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft:
|
* Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft:
|
||||||
* Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die
|
* Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die
|
||||||
* nur „in Arbeit" sagt, lässt den Bediener ratlos zurück — und eine, die eine
|
* nur „in Arbeit" sagt, laesst den Bediener ratlos zurueck — und eine, die eine
|
||||||
* leere Tabelle zeigt, lässt ihn glauben, es gäbe nichts zu sehen.
|
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Construction } from 'lucide-react';
|
import type { PageDefinition } from './pages';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { Callout, Card, CardContent } from '@/components/ui';
|
|
||||||
|
|
||||||
export function UnavailablePage({
|
/** Eigenschaften der Hinweisseite. */
|
||||||
title,
|
interface UnavailablePageProperties {
|
||||||
reason,
|
/** Die betroffene Seite. */
|
||||||
alternative,
|
readonly pageDefinition: PageDefinition;
|
||||||
}: {
|
}
|
||||||
readonly title: ReactNode;
|
|
||||||
readonly reason: ReactNode;
|
/** Zeigt den Ausbaustand eines Bereichs. */
|
||||||
/** Wo dieselbe Auskunft heute zu bekommen ist. */
|
export function UnavailablePage({ pageDefinition }: UnavailablePageProperties): React.JSX.Element {
|
||||||
readonly alternative?: ReactNode | undefined;
|
|
||||||
}): React.JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="page">
|
||||||
<PageHeader title={title} />
|
<header className="page__header">
|
||||||
<Card>
|
<h1 className="page__title">{pageDefinition.label}</h1>
|
||||||
<CardContent className="py-10">
|
<span className="page__badge">noch nicht verfuegbar</span>
|
||||||
<div className="mx-auto flex max-w-xl flex-col items-center text-center">
|
</header>
|
||||||
<Construction className="size-7 text-fg-subtle" aria-hidden />
|
|
||||||
<p className="mt-3 text-sm text-fg">{reason}</p>
|
|
||||||
|
|
||||||
{alternative ? (
|
<div className="notice notice--information">
|
||||||
<Callout tone="info" className="mt-5 w-full text-left">
|
<p className="notice__text">{pageDefinition.unavailableReason}</p>
|
||||||
{alternative}
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<p className="mt-5 text-xs text-fg-muted">
|
|
||||||
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten
|
|
||||||
gefüllt. Eine Maske mit erfundenen Zahlen ließe sich im Betrieb
|
|
||||||
nicht von einer echten unterscheiden.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<p className="page__hint">
|
||||||
</>
|
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten gefuellt. Eine Maske mit
|
||||||
|
erfundenen Zahlen liesse sich im Betrieb nicht von einer echten unterscheiden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Tests des Seitenverzeichnisses.
|
* Tests des Seitenverzeichnisses.
|
||||||
*
|
*
|
||||||
* Sie prüfen weniger die Technik als eine Zusage: Jede Seite aus dem
|
* Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem
|
||||||
* Implementierungsplan erscheint, und jede unfertige erklärt sich.
|
* Implementierungsplan erscheint, und jede unfertige erklaert sich.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { ALL_PAGES, findPage, mayViewPage } from './pages';
|
import { ALL_PAGES, findPage, mayViewPage } from './pages';
|
||||||
|
|
||||||
describe('Seitenverzeichnis', () => {
|
describe('Seitenverzeichnis', () => {
|
||||||
it('nennt zu jeder nicht verfügbaren Seite einen Grund', () => {
|
it('nennt zu jeder nicht verfuegbaren Seite einen Grund', () => {
|
||||||
// Ein Bereich, der nur „nicht verfügbar" sagt, lässt den Bediener ratlos
|
// Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos
|
||||||
// zurück. Der Grund ist der eigentliche Inhalt dieser Seiten.
|
// zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten.
|
||||||
for (const pageDefinition of ALL_PAGES) {
|
for (const pageDefinition of ALL_PAGES) {
|
||||||
if (!pageDefinition.available) {
|
if (!pageDefinition.available) {
|
||||||
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy();
|
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy();
|
||||||
@ -20,7 +20,7 @@ describe('Seitenverzeichnis', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gibt keiner verfügbaren Seite einen Nichtverfügbarkeitsgrund', () => {
|
it('gibt keiner verfuegbaren Seite einen Nichtverfuegbarkeitsgrund', () => {
|
||||||
for (const pageDefinition of ALL_PAGES) {
|
for (const pageDefinition of ALL_PAGES) {
|
||||||
if (pageDefinition.available) {
|
if (pageDefinition.available) {
|
||||||
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
|
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
|
||||||
@ -35,7 +35,7 @@ describe('Seitenverzeichnis', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deckt die im Implementierungsplan genannten Bereiche ab', () => {
|
it('deckt die im Implementierungsplan genannten Bereiche ab', () => {
|
||||||
// Der Plan (§14) nennt fünfzehn Seiten. Fehlt eine davon im Verzeichnis,
|
// Der Plan (§14) nennt fuenfzehn Seiten. Fehlt eine davon im Verzeichnis,
|
||||||
// ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht
|
// ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht
|
||||||
// es.
|
// es.
|
||||||
const expectedIdentifiers = [
|
const expectedIdentifiers = [
|
||||||
@ -61,7 +61,7 @@ describe('Seitenverzeichnis', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verbirgt Seiten ohne die nötige Berechtigung', () => {
|
it('verbirgt Seiten ohne die noetige Berechtigung', () => {
|
||||||
const usersPage = findPage('users');
|
const usersPage = findPage('users');
|
||||||
expect(usersPage).toBeDefined();
|
expect(usersPage).toBeDefined();
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user