Compare commits

..

15 Commits

Author SHA1 Message Date
d94debac4d Dokumentation und Aenderungsliste fuer rc9
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 27s
Die Sicherungsart steht in backup-engine.md, weil dort der Unterschied zwischen
voll und inkrementell erklaert ist — mit der Einordnung, die am haeufigsten
verwechselt wird: Der Platzbedarf steigt bei "immer voll" nicht nennenswert,
die Laufzeit schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:06 +02:00
8e98cc7510 Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —

- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").

Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.

Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.

Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.

**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.

**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.

**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.

84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:13:22 +02:00
b78a6fb51c Dokumentation und Aenderungsliste fuer rc8
Some checks failed
CI / Backend (Go) (push) Failing after 32s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
Der Abschnitt "Wohin darf zurueckgeschrieben werden?" im Runbook ist der
wichtigste Zusatz: Dass ein Ziel an ProtectSystem=strict scheitert und nicht an
den Rechten des Verzeichnisses, sieht man dem Fehler nicht an. Die Tabelle nennt
die vier Faelle samt Grund.

Die Beispiel-Einheit in der Installationsanleitung fuehrte in denselben Fehler —
sie nannte nur das Repository in ReadWritePaths. Eine Anleitung, deren
Ergebnis keine Wiederherstellung zulaesst, ist schlimmer als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:55:52 +02:00
20b0919676 Datei- und Ordnerwiederherstellung, Ordnerbaum, Geist Mono im Paket
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 45s
CI / Sicherheitsprüfungen (push) Successful in 27s
**Warum keine Wiederherstellung funktionierte.** Der Dienst laeuft mit
`ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
Sicherungsordner — alles andere ist fuer ihn schreibgeschuetzt. Jedes Ziel
ausserhalb endete mit "mkdir: permission denied", und zwar **nach** der
Vorabpruefung. `/tmp` scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein
eigenes /tmp, und was dort landet, sieht man von aussen gar nicht.

`setup.sh` legt jetzt `/srv/syncova-restore` an und traegt es in
`ReadWritePaths` ein; `--wiederherstellungsziel` ergaenzt weitere. Der Ort liegt
unter /srv und nicht unter /var/lib — Letzteres steht auf der Sperrliste des
Zielschutzes. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig; das
ist mir erst aufgefallen, nachdem ich die Flaeche zunaechst falsch gelegt hatte
und der eigene Zielschutz sie ablehnte.

**Zwei neue Endpunkte** (Vertrag entsprechend erweitert):

- `GET /filesystem/browse` — Verzeichnisse mit der Angabe, ob der **Dienst**
  dort schreiben darf. **Gemessen** durch eine Probedatei, nicht aus den
  Rechtebits geraten: Unter ProtectSystem=strict sagen die Bits nichts ueber
  das aus, was der Namensraum zulaesst. Gesperrte Orte werden gezeigt, nicht
  versteckt — sonst bliebe offen, warum ein Pfad fehlt.
- `GET /backups/{id}/contents` — das Manifest als Ebene eines Baums. Der Baum
  entsteht aus den **Pfaden**, nicht aus Verzeichniseintraegen: Ein Manifest
  kann eine Datei enthalten, deren Elternverzeichnis nicht als eigener Eintrag
  vorliegt, und wer nur `directory`-Eintraege auflistet, verliert ganze
  Teilbaeume. Durch Mutation bestaetigt.

**Auswahl statt Textfeld.** Der Assistent hat jetzt einen Ordnerbaum fuer das
Ziel und einen Browser fuer den Backup-Inhalt. Ordner **und** einzelne Dateien
lassen sich waehlen; beides geht als `path_prefix` in die Anfrage, weil der
Server auf Gleichheit oder Praefix mit Verzeichnisgrenze vergleicht. Bewusste
Grenze: eine Auswahl je Lauf — eine Liste kennt die API nicht, und mehrere
Laeufe vorzutaeuschen ergaebe mehrere Ausgaenge, die niemand mehr erklaeren
kann.

**Integritaetslauf.** "can't access property toLocaleString, chunks_checked is
undefined" — die Ergebnisse liegen unter `details`, und die Felder heissen
`missing_chunks`/`corrupted_chunks`, nicht umgekehrt. Betrifft alle vier
Pruefendpunkte; sie tragen dieselbe Huelle. Derselbe Fehler wie bei
/retention-policies: die Antwortform angenommen statt geprueft.

**Geist Mono liegt jetzt im Paket** (drei Schnitte, 128 KB, OFL-Lizenz dabei).
Ausgeliefert vom eigenen Ursprung — das verlangt die CSP, und ein
Backup-Server, dessen Oberflaeche von einem CDN abhaengt, waere auch ohne CSP
falsch. `font-display: swap`, damit der Text sofort steht.

Nachgewiesen gegen Debian 12: Vollwiederherstellung (5 Dateien), nur ein Ordner
(2 Dateien), nur eine Datei (1 Datei) — alle drei bitgenau. Der Server nennt
/srv/syncova-restore als beschreibbar und /etc, /usr, /var als gesperrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:52:37 +02:00
053e9ae817 Dokumentation und Aenderungsliste fuer rc7
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 26s
docs/web-ui.md beschreibt jetzt das Preset samt der drei begruendeten
Abweichungen, die Sitzung mit ihren zwei Uhren und die Fehlergrenze. Dazu zwei
neue Grenzen: Geist Mono wird nicht mitgeliefert, und die Suche im
Ereignisprotokoll filtert weiterhin im Browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:10:17 +02:00
fef0bd88f7 Preset b5vnF8SMi uebernommen
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Violett als Handlungsfarbe, Radius 0, schattenlos, durchgehend Geist Mono.
Kantig und ruhig — die praegenden Merkmale des Presets.

Drei begruendete Abweichungen, im Code festgehalten:

- **Die Statusfarben bleiben.** Das Preset kennt nur `destructive` und fuenf
  Diagrammfarben; PROMPT §106 verlangt fuenf Bedeutungen. Ohne sie liesse sich
  ein Teilfehler nicht von einem Erfolg unterscheiden — die eine Aussage, auf
  die es in dieser Konsole ankommt. Sie sind an die Neutraltoene des Presets
  angepasst; Rot stammt aus dessen `destructive`.
- **Das dunkle Thema haengt weiter an `[data-theme='dark']`.** Der Umschalter
  setzt dieses Attribut und ist getestet; `.dark` wird zusaetzlich unterstuetzt.
- **Die Schrift laedt nicht nach.** Geist Mono steht zuerst im Stapel und wird
  verwendet, wenn sie auf dem Geraet liegt; sonst greift die System-Monospace.
  Eine Schrift von einem fremden Host zu holen verbietet die CSP der
  Auslieferung — und ein Backup-Server, der fuer seine Oberflaeche ins Internet
  greift, waere auch ohne CSP falsch.

Eine Zuordnung war die Stolperstelle: In shadcn ist `accent` die dezente
Hover-Flaeche und `primary` die Farbe der Handlung. Sie zu verwechseln machte
jede Schaltflaeche grau. Das Preset-`primary` wird deshalb zu `--accent`, das
Preset-`accent` zu `--surface-hover`.

Die Benennung bleibt semantisch (`--surface-card`, `--text-primary`) statt
shadcn-typisch: Die Zuordnung steht an genau einer Stelle, und ein Wechsel des
Presets fasst keine einzige Komponente an.

Karten, Schaltflaechen, Dialoge, Toasts und die Anmeldemaske sind auf
schattenlos umgestellt; Dialog und Toast behalten einen kraeftigen Rand, weil
sie sonst ohne Trennung ueber der Seite schwebten — im dunklen Thema ist der
normale Rand schwarz. Die Seitenleiste bekommt die eigene Flaeche des Presets.

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
ausgelieferte CSS traegt Akzent, Radius 0 und Geist Mono, /retention zeigt
0 eigene Regeln und 6 anklickbare Vorlagen.

84 Tests gruen, tsc sauber, eslint ohne Warnung. CSS 28,7 KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:07:55 +02:00
77e61dc2b3 Blackscreen auf /retention behoben, Aussehen ueberarbeitet
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 26s
**Der schwarze Bildschirm ist gefunden.** `GET /retention-policies` liefert ein
Objekt `{ policies, predefined }` — als einziger von zehn geprueften
Listenendpunkten. Die Oberflaeche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React haengte den ganzen Baum aus, und uebrig blieb eine
leere Seite. Im dunklen Thema schwarz.

Der Regressionstest fuettert die **echte** Antwortform. Ein Test mit einem Array
haette den Fehler nie gefunden — und genau das war passiert. Durch Mutation als
fangend bestaetigt ("repositories.map is not a function").

Nebenbei ergab sich ein Modul: Der Endpunkt liefert **sechs mitgelieferte
Vorlagen** mit. Sie erscheinen jetzt als Kacheln, ein Klick legt daraus eine
eigene Regel an.

**Aussehen.** Kraeftigerer Akzent (Indigo statt gedecktem Blau), waermere
Neutraltoene, groessere Radien, Schatten statt Rahmen, wo Tiefe gemeint ist:
Ein Rahmen trennt, ein Schatten hebt — Karten sollen heben, Tabellenzeilen
trennen. Kompaktere Statusabzeichen und Tabellenkoepfe.

**Texte gekuerzt.** 33 Erlaeuterungen von Absaetzen auf einen Satz. Der Inhalt
bleibt richtig; er steht nur nicht mehr zwischen den Bedienelementen. Was
ausfuehrlich erklaert werden muss, gehoert in docs/.

**Schnellzugriffe auf der Uebersicht.** Acht Kacheln, die die Handlung nennen
statt des Bereichs ("Auftrag anlegen" statt "Sicherungsauftraege"). Gezeigt wird
nur, was die Rolle darf — eine Kachel, die in eine Fehlermeldung fuehrt, ist
schlimmer als keine. Als Schaltflaechen umgesetzt, nicht als div mit
Klick-Handler: Der Unterschied ist Tastaturbedienbarkeit.

84 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:56:51 +02:00
b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
  verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
  Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.

Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.

Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).

**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.

**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.

Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.

82 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:42:37 +02:00
698f3a17d9 Dokumentation und Aenderungsliste fuer rc6
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 27s
docs/web-ui.md beschrieb die Oberflaeche aus Phase 12 — eine, die es nicht mehr
gibt. Eine Anleitung, die auf Bereiche verweist, die anders heissen und anders
funktionieren, ist schlimmer als keine: Der Leser sucht den Fehler bei sich.
Neu geschrieben.

Eine Aussage darin habe ich beim Nachpruefen korrigiert: `/api/v1/events/stream`
steht zwar in SYNCOVA_API.md, ist aber **auch serverseitig** nicht umgesetzt.
"Nicht angebunden" haette den Mangel der Oberflaeche zugeschoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:05:15 +02:00
6e23696fcb Weboberflaeche: letzte Seiten auf das Design-System gezogen
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:50:15 +02:00
ccfff87d3f Weboberflaeche: vollstaendige Verwaltungskonsole
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Von 95 fachlichen Endpunkten erreicht die Oberflaeche jetzt 89 statt 23.
Schreibend waren es neun (vier davon An- und Abmeldung) — jetzt ist jede
Handlung der Anlage bedienbar.

Neu bedienbar:

- **Wiederherstellung** als vierstufiger Assistent. Die Vorabpruefung ist ein
  eigener Schritt, weil sie den Unterschied zwischen Hoffnung und Nachweis
  macht: Sie schreibt nichts und stellt fest, ob **jeder benoetigte Block noch
  da ist**. Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert
  hat. Die drei Huerden vor dem Ueberschreiben sind sichtbar umgesetzt; laeuft
  die dritte ins Leere, entfaellt sie — ein Ritual ohne Anlass gewoehnt das
  Wegklicken an.
- **Wiederherstellungspunkte** mit Bewertung, Schutz, Ransomware-Einschaetzung,
  Legal Hold, Fristverlaengerung und Loeschung. Eine unbelastbare Prozentzahl
  wird als Vermutung gekennzeichnet, ungemessene Eingangsgroessen erscheinen als
  "ungemessen" statt als null Punkte.
- **Pruefung** mit allen fuenf Pruefarten. Zustand und Ergebnis stehen
  nebeneinander: Eine gescheiterte Pruefung ist kein Befund am Backup.
- **Repositories** mit Integritaetslauf, Katalog-Neuaufbau, Gesundheitspruefung
  und gemessener Durchsetzungsstufe. Die Uebernahme sagt ausdruecklich, dass
  hier nichts angelegt wird.
- **Aufbewahrung** mit Regeln und Vorschau vor dem Loeschen.
- **Proxmox** (neun Endpunkte, bisher ohne jede Oberflaeche) und **Agenten**
  samt einmaliger Anzeige des Aufnahme-Tokens.
- **Benutzer, Rollen, Benachrichtigungswege, eigener zweiter Faktor.**

Zwei Funde beim Nachweis gegen den laufenden Dienst:

- **Die Einstufung heisst `successful`, nicht `unverified`.** Das Vokabular
  lautet failed/corrupted/successful/verified/recoverable. `successful` ist die
  Falle: Es bedeutet "der Lauf ist durchgelaufen" — nicht "wiederherstellbar".
  Es ist deshalb **neutral**, nicht gruen; ein gruenes Abzeichen laese sich als
  "geprueft und in Ordnung", und genau diese Verwechslung soll die Anlage
  verhindern. Regressionstest, durch Mutation als fangend bestaetigt.
- **Der Geheimnis-Scanner griff korrekt** beim Platzhaltertext des
  SSH-Schluesselfelds. Gekennzeichnet statt das Muster aufzuweichen.

Die Statuszuordnung steht an genau einer Stelle und faellt fuer unbekannte
Werte auf neutral zurueck, nie auf gruen: Ein neuer Serverzustand darf nicht
als "in Ordnung" durchgehen.

Nachgewiesen im Container gegen Debian 12 mit echtem PostgreSQL und nginx,
ueber genau die Aufrufe, die die Konsole macht: Repository uebernommen,
Durchsetzungsstufe gemessen (advisory — korrekt auf overlayfs), Auftrag
angelegt, Lauf 202, zweiter Anstoss 409, Sicherung erfolgreich (2 Objekte,
3.000.006 Byte, 0 uebergangen), Blockpruefung clean (5 Bloecke), Vorabpruefung
"wiederherstellbar: 2 Dateien, 2,9 MiB", Wiederherstellung nach /etc
abgewiesen.

76 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 460 KB (136 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:35:05 +02:00
b3f0a99243 Weboberflaeche: Design-Fundament und bedienbare Auftraege
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 27s
Ausgangslage, gemessen statt geschaetzt: Von 99 fachlichen Endpunkten rief die
Oberflaeche 23 auf. Schreibend waren es neun, vier davon An- und Abmeldung.
Real verwaltbar war: einen Auftrag anlegen, einen Bericht erzeugen, eine
Meldung bestaetigen. Das ist ein Leseinstrument, keine Verwaltungskonsole.

Dieser Schritt legt das Fundament und macht den ersten Bereich vollstaendig
bedienbar.

Fundament:

- Tailwind v4 und Radix-Primitive (shadcn-Muster). Alles gebuendelt, keine
  externen Ressourcen — die CSP der Auslieferung laesst sie ohnehin nicht zu.
- Farbsystem nach PROMPT.md §106: Semantische Farben ausschliesslich fuer
  Status, sonst neutral. Die Zuordnung der Fachbegriffe auf die fuenf
  Bedeutungen steht an genau **einer** Stelle (StatusBadge). Verteilt ueber die
  Seiten erschiene frueher oder spaeter irgendwo "partial_failure" gruen, und
  ein Betreiber haelt einen Teilfehler dann fuer einen Erfolg. Ein unbekannter
  Zustand wird neutral dargestellt, nie gruen.
- Neue Seitenhuelle mit fuenf Bereichen, einklappbarer Seitenleiste, Schublade
  auf schmalen Geraeten und Dark Mode ueber ein Attribut am Wurzelelement (nicht
  allein ueber die Medienabfrage — eine Konsole, die nachts waehrend einer
  Stoerung von selbst umschaltet, ist laestig).
- `useMutation` fuer schreibende Aufrufe: Doppelklickschutz, Vorgangsnummer bis
  in die Meldung, kein setState nach dem Aushaengen. `describeApiError`
  uebersetzt die bekannten Fehlercodes in Saetze **mit Abhilfe**.
- Der API-Client sendet jetzt `Idempotency-Key`. Ohne ihn erzeugt ein
  Doppelklick zwei Auftraege — und bei einer Wiederherstellung zwei
  gleichzeitige Laeufe in dasselbe Ziel.
- Fehlermeldungen nennen immer die `request_id`, kopierbar.

Auftraege (Endpunkte, die vorher keine Oberflaeche hatten):

- Lauf anstossen, anhalten, fortsetzen, loeschen, laufenden Lauf abbrechen.
- Detailseite mit Laufhistorie: Fehlercode, Fehlerklasse und die Auskunft, ob
  eine Wiederholung ueberhaupt etwas bringt — ein Anmeldefehler behebt sich
  nicht durch Warten.
- **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der 409 wird
  als Hinweis gezeigt, nicht als Fehlschlag: Der Auftrag laeuft ja, und genau
  das wollte der Betreiber.
- **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben bestehen;
  sie gehoeren zum Repository, nicht zum Auftrag. Ohne diesen Hinweis loescht
  jemand einen Auftrag in der Annahme, Platz zu schaffen.

Der Wiederherstellungs-Assistent ist gebaut (vier Schritte, Vorabpruefung als
eigener Schritt, die drei Huerden vor dem Ueberschreiben sichtbar umgesetzt),
aber noch nicht in eine Seite eingebunden.

Drei Lint-Befunde behoben, alle dieselbe Sorte wie in Phase 8 und 12:
setState im Effektkoerper und ein Schreibzugriff auf eine Referenz waehrend des
Renderns. Der Bestaetigungsdialog haelt seinen Zustand jetzt im Portalinhalt —
beim Schliessen verschwindet er von selbst, ein Zuruecksetzen im Effekt
entfaellt, und die Huerde steht beim naechsten Oeffnen wieder.

Die noch nicht umgebauten Seiten behalten vorerst das alte Stylesheet. Es faellt
weg, sobald die letzte umgebaut ist.

69 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 373 KB (115 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:25:42 +02:00
b6668c600d Weboberflaeche richtet sich mit ein (rc5)
Some checks failed
CI / Backend (Go) (push) Failing after 29s
CI / Frontend (React/TypeScript) (push) Successful in 33s
CI / Sicherheitsprüfungen (push) Successful in 24s
Bisher endete setup.sh mit einer laufenden API auf 127.0.0.1:8080 und der
Aufgabe, einen Webserver von Hand davorzusetzen. Das war der haeufigste Punkt,
an dem eine Einrichtung liegen blieb.

setup.sh richtet jetzt nginx ein und stellt ein selbst signiertes Zertifikat
aus. Es gilt fuer Rechnernamen, vollstaendigen Namen und jede globale
IPv4-Adresse (subjectAltName — moderne Browser lesen den CN nicht mehr), 3650
Tage; der SHA-256-Fingerabdruck wird genannt.

Drei Entscheidungen:

- Die API bleibt an 127.0.0.1:8080 gebunden. Sie auf alle Schnittstellen zu
  legen waere der kuerzere Weg und der falsche: Die Verschluesselung liesse
  sich dann umgehen, indem man Port 8080 direkt anspricht.
- Die Firewall wird gemeldet, nicht geaendert. Eine Einrichtung, die
  selbsttaetig einen Port ins Netz oeffnet, hebelt genau die Entscheidung aus,
  fuer die jemand die Firewall aufgesetzt hat.
- Scheitert die Oberflaeche, scheitert nicht die Einrichtung. Geprueft wird mit
  nginx -t, bevor die Konfiguration uebernommen wird; haelt sie nicht, wird sie
  entfernt und der Nachholweg gezeigt.

Zwei Funde 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.
- setup.sh kopierte nur diagnose.sh neben die Programme. Der eigene Hinweis
  "Spaeter nachholen: /opt/syncova/setup.sh --weboberflaeche" verwies damit auf
  eine Datei, die es nicht gab; schwerer wiegt uninstall.sh — wer das
  ausgepackte Paket aufraeumte, haette die Anlage nie wieder entfernen koennen.
  Jetzt kommen alle vier Skripte mit.

Nachgewiesen im Container gegen Debian 12 mit nginx 1.22: Neuinstallation von
Grund auf, Oberflaeche und /api/ von aussen ueber HTTPS erreichbar (200),
SPA-Fallback traegt, Anmeldung und Repository-Anlage durch nginx hindurch,
Durchsetzungsstufe gemessen, HTTP leitet mit 301 auf HTTPS, Fingerabdruck
stimmt mit dem genannten ueberein, Neuausstellung des Zertifikats geprueft.

Regressionstests fuer beide Funde, beide durch Mutation als fangend bestaetigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:53:15 +02:00
22763f927f Fehler #2: Bereitschaftspruefung haengt an curl
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 33s
CI / Sicherheitsprüfungen (push) Successful in 23s
Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht als
betriebsbereit". Der Dienst lief einwandfrei — das Protokoll im Bericht zeigt
einen vollstaendigen Start, und genau fuenfzehn Sekunden spaeter beendet ihn der
Rueckbau. Fuenfzehn Sekunden sind genau fuenfzehn Pruefversuche.

Die Ursache stand ebenfalls im Bericht, eine Zeile weiter oben:

  ## Gesundheit
  curl ist nicht vorhanden.

setup.sh prueft die Betriebsbereitschaft ausschliesslich mit curl. Auf einem
schlanken Serverabbild ist der nicht installiert; das ist der Normalfall und
nicht die Ausnahme. Die Pruefung kam nicht an den Dienst heran, hielt das fuer
einen gescheiterten Start und baute eine funktionierende Anlage zurueck.

Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.

Jetzt drei Wege: curl, sonst wget, sonst /dev/tcp der Bash — letzteres gehoert
zur Shell selbst und ist damit ueberall vorhanden. Betrifft setup.sh, update.sh
und diagnose.sh; der Diagnosebericht nennt zusaetzlich, womit er gemessen hat.

Real nachgewiesen auf einem System ohne curl UND ohne wget: rc3 bricht ab, die
korrigierte Fassung laeuft durch. Regressionstest vorhanden und als fangend
geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:22:47 +02:00
a37631c501 Fehler #1: Repository unter /tmp macht den Dienst startunfaehig
Some checks failed
CI / Backend (Go) (push) Failing after 32s
CI / Frontend (React/TypeScript) (push) Successful in 35s
CI / Sicherheitsprüfungen (push) Successful in 24s
Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht als
betriebsbereit". Der Diagnosebericht enthielt die Ursache woertlich:

  syncova-api.service: Failed to set up mount namespacing:
      /tmp/syncova-repository: No such file or directory
  status=226/NAMESPACE

Die Diensteinheit setzt PrivateTmp=yes. Der Dienst bekommt damit ein eigenes
/tmp, und der in ReadWritePaths genannte Ablageort existiert in seiner Sicht
nicht — systemd bricht ab, bevor das Programm ueberhaupt laeuft.

Der technische Fehler ist der kleinere. Der groessere ist, dass setup.sh einen
Ablageort unter /tmp ueberhaupt angenommen hat: systemd-tmpfiles raeumt dort
regelmaessig auf, und auf einem tmpfs ist nach einem Neustart nichts mehr da.
Ein Backupsystem, das jede Nacht Erfolg meldet und keine Daten hat, ist
schlimmer als gar keines.

Deshalb:

- Fluechtige Ablageorte werden abgelehnt: /tmp, /var/tmp, /dev/shm, /run und
  jedes tmpfs oder ramfs. Geprueft wird VOR der Datenbankeinrichtung — ein
  unbeaufsichtigter Lauf scheitert damit in Sekunden statt nach Minuten und
  einem Rueckbau.
- SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja laesst es ausdruecklich zu; dann
  wird PrivateTmp abgeschaltet, sonst startet der Dienst nie. Real geprueft:
  Einheit traegt PrivateTmp=no, Dienst laeuft.
- Kommt der Dienst nicht hoch, liefert setup.sh die letzten Journalzeilen gleich
  mit und erklaert 226/NAMESPACE. Der Verweis auf journalctl allein war
  wertlos: Beim Rueckbau ist der Dienst weg, und der Meldende brauchte einen
  zweiten Anlauf, um ueberhaupt zu erfahren, was los war.

Regressionstest vorhanden und als fangend geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:14:29 +02:00
126 changed files with 18063 additions and 4770 deletions

View File

@ -1,5 +1,286 @@
# Ä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
Behebt [Issue #2](https://git.jfritzsche.de/jf/syncova-backup/issues/2): Die
Einrichtung brach mit „Der Dienst meldet sich nicht als betriebsbereit" ab,
obwohl der Dienst einwandfrei lief.
- **Die Bereitschaftsprüfung hängt nicht mehr an `curl`.** Sie weicht auf `wget`
aus und zuletzt auf die Bash selbst (`/dev/tcp`), die überall vorhanden ist.
Auf einem schlanken Serverabbild ist `curl` nicht installiert — das ist der
Normalfall, nicht die Ausnahme. Ein Einrichtungsskript darf nicht
voraussetzen, was es nicht selbst mitbringt.
- Betrifft `setup.sh`, `update.sh` und `diagnose.sh`. Der Diagnosebericht nennt
jetzt zusätzlich, **womit** er gemessen hat.
## V1 — Release Candidate 3, 17. August 2026
Behebt [Issue #1](https://git.jfritzsche.de/jf/syncova-backup/issues/1): Die
Einrichtung brach ab, wenn das Repository unter `/tmp` liegen sollte.
- **Ein flüchtiger Ablageort wird abgelehnt** — `/tmp`, `/var/tmp`, `/dev/shm`,
`/run` und jedes tmpfs. `systemd-tmpfiles` räumt dort auf, ein tmpfs ist nach
einem Neustart leer: Die Sicherungen verschwänden von selbst, ohne Meldung,
bis jemand sie braucht. Geprüft wird **vor** der Datenbankeinrichtung, damit
ein unbeaufsichtigter Lauf in Sekunden scheitert statt nach Minuten.
Für Wegwerf-Umgebungen: `SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja` — dann
weicht `PrivateTmp`, sonst könnte der Dienst nicht starten.
- **Der Abbruch zeigt jetzt den Grund.** Kommt der Dienst nicht hoch, liefert
`setup.sh` die letzten Journalzeilen gleich mit und erklärt `226/NAMESPACE`.
Vorher verwies er nur auf `journalctl` — und beim Rückbau war der Dienst dann
schon weg.
## V1 — Release Candidate 2, 17. August 2026 ## V1 — Release Candidate 2, 17. August 2026
Ergänzt gegenüber `rc1`: Ergänzt gegenüber `rc1`:

View File

@ -0,0 +1,422 @@
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
}

View File

@ -0,0 +1,90 @@
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)
}
}

View File

@ -122,3 +122,5 @@ 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

View File

@ -89,6 +89,14 @@ 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"`
} }
@ -127,6 +135,14 @@ 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.

View File

@ -57,6 +57,8 @@ 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,
@ -152,6 +154,8 @@ 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,
@ -190,3 +194,33 @@ 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
}

View File

@ -377,6 +377,19 @@ 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).

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,32 @@
"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",

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +1,120 @@
/** /**
* Wurzelkomponente der Syncova-Oberflaeche. * Wurzelkomponente der Syncova-Oberfläche.
* *
* Die Anwendung entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und * Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt
* verteilt die angemeldete Ansicht auf die Bereiche aus PROMPT.md §28. * letztere auf die Bereiche aus PROMPT.md §28.
* *
* Der Umgang mit unfertigen Bereichen ist die eine Entscheidung, die diese Datei * Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf
* traegt: Sie erscheinen im Menue, aber deaktiviert und mit der Angabe, was * (`jobs/<id>`). Das reicht für diese Konsole und kostet rund fünfzig Zeilen —
* fehlt. Ein Menue nur aus fertigen Bereichen verschweigt den Ausbaustand; eines * eine Router-Bibliothek wäre hier mehr Abhängigkeit als Nutzen.
* mit leeren Masken taeuscht ihn vor (PROMPT.md §139).
*/ */
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { setAccessTokenProvider } from './api/client'; import { setAccessTokenProvider } from './api/client';
import { SystemHealthPanel } from './features/health/SystemHealthPanel'; import { AppShell } from './components/layout/AppShell';
import { JobsPanel } from './features/jobs/JobsPanel'; import { ErrorBoundary } from './components/layout/ErrorBoundary';
import { LoginPage } from './features/auth/LoginPage'; import { LoadingState, ToastProvider } from './components/ui';
import { getAccessToken, logout } from './features/auth/authApi';
import { DashboardPage } from './features/dashboard/DashboardPage';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { import {
AgentsPage, fetchCurrentUser,
EventsPage, getAccessToken,
RepositoriesPage, hasResumableSession,
RestoresPage, logout,
} from './features/inventory/InventoryPages'; } from './features/auth/authApi';
import { RolesPage, UsersPage } from './features/identity/IdentityPages'; import { useSession } from './features/auth/useSession';
import { MetricsPage } from './features/metrics/MetricsPage'; import { LoginPage } from './features/auth/LoginPage';
import { AlertsPage } from './features/alerts/AlertsPage'; import { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage';
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { RolesPage } from './features/administration/RolesPage';
import { SettingsPage } from './features/administration/SettingsPage';
import { UsersPage } from './features/administration/UsersPage';
import { EventsPage } from './features/administration/EventsPage';
import { AgentsPage } from './features/infrastructure/AgentsPage';
import { ProxmoxPage } from './features/infrastructure/ProxmoxPage';
import { ProtectedSystemsPage } from './features/infrastructure/ProtectedSystemsPage';
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 { 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 './App.css'; import './styles/theme.css';
// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls. // Der API-Client erhält 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();
if (authenticatedUser === null) { // Das gerade geöffnete Objekt innerhalb einer Seite, etwa ein Auftrag.
return <LoginPage onAuthenticated={setAuthenticatedUser} />; 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) {
return (
<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);
@ -56,121 +122,190 @@ 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 (
<div className="app-shell"> <ToastProvider>
<header className="app-shell__header"> <AppShell
<span className="app-shell__brand">Syncova</span> currentPageId={currentPageId}
onNavigate={handleNavigate}
<div className="app-shell__account"> grantedPermissions={grantedPermissions}
<span className="app-shell__username">{authenticatedUser.username}</span> userName={authenticatedUser.username}
sessionClock={sessionClock}
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird onLogout={() => void handleLogout()}
benannt, statt ihn zu verschweigen (PROMPT.md §90). */} >
{!authenticatedUser.mfa_enabled && ( {/* Die Grenze sitzt um den Inhalt: Ein Fehler auf einer Seite lässt
<span Menü und Kopfzeile stehen, statt die Konsole zu schwärzen. */}
className="app-shell__warning" <ErrorBoundary resetKey={`${currentPageId}|${openObjectIdentifier ?? ''}`}>
title="Fuer dieses Konto ist kein zweiter Faktor eingerichtet." <CurrentPageContent
> currentPageId={currentPageId}
MFA fehlt grantedPermissions={grantedPermissions}
</span> currentUser={authenticatedUser}
)} openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
<button className="app-shell__logout" type="button" onClick={() => void handleLogout()}> onNavigate={handleNavigate}
Abmelden />
</button> </ErrorBoundary>
</div> </AppShell>
</header> </ToastProvider>
<div className="app-shell__body">
<NavigationSidebar
currentPageId={currentPageId}
grantedPermissions={grantedPermissions}
onNavigate={navigateToPage}
/>
<main className="app-shell__main">
<CurrentPageContent currentPageId={currentPageId} grantedPermissions={grantedPermissions} />
</main>
</div>
</div>
); );
} }
/** Eigenschaften des Seiteninhalts. */ /** Zeigt den Inhalt der gewählten Seite. */
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,
}: CurrentPageContentProperties): React.JSX.Element { currentUser,
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 Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne // Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche // sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche
// Auskunft (PROMPT.md §42). // Auskunft (PROMPT.md §42).
if (!mayViewPage(pageDefinition, grantedPermissions)) { if (!mayViewPage(pageDefinition, grantedPermissions)) {
return ( return (
<section className="page"> <UnavailablePage
<header className="page__header"> title={pageDefinition.label}
<h1 className="page__title">{pageDefinition.label}</h1> 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."
</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 <UnavailablePage pageDefinition={pageDefinition} />; return (
<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
<SystemHealthPanel /> grantedPermissions={grantedPermissions}
onNavigate={onNavigate}
/>
<div className="mt-4">
<SystemHealthPanel />
</div>
</> </>
); );
case 'jobs': case 'jobs':
return <JobsPanel />; return openObjectIdentifier ? (
case 'metrics': <JobDetailPage
return <MetricsPage />; jobIdentifier={openObjectIdentifier}
case 'alerts': grantedPermissions={grantedPermissions}
return <AlertsPage />; onBack={() => onOpenObject(null)}
case 'security': />
return <SecurityPage />; ) : (
case 'reports': <JobsPage grantedPermissions={grantedPermissions} onOpenJob={onOpenObject} />
return <ReportsPage />; );
case 'recovery-points': case 'recovery-points':
return <RecoveryPointsPage />; return openObjectIdentifier ? (
<BackupDetailPage
backupIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions}
onBack={() => onOpenObject(null)}
onDeleted={() => onOpenObject(null)}
/>
) : (
<RecoveryPointsPage onOpenBackup={onOpenObject} />
);
case 'restores': case 'restores':
return <RestoresPage />; 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': case 'repositories':
return <RepositoriesPage />; return <RepositoriesPage grantedPermissions={grantedPermissions} />;
case 'retention':
return <RetentionPage grantedPermissions={grantedPermissions} />;
case 'agents': case 'agents':
return <AgentsPage />; return <AgentsPage grantedPermissions={grantedPermissions} />;
case 'proxmox':
return <ProxmoxPage grantedPermissions={grantedPermissions} />;
case 'protected-systems':
return <ProtectedSystemsPage />;
case 'events': case 'events':
return <EventsPage />; return <EventsPage />;
case 'alerts':
return <AlertsPage />;
case 'metrics':
return <MetricsPage />;
case 'reports':
return <ReportsPage />;
case 'security':
return <SecurityPage />;
case 'users': case 'users':
return <UsersPage />; return <UsersPage grantedPermissions={grantedPermissions} />;
case 'roles': case 'roles':
return <RolesPage />; return <RolesPage grantedPermissions={grantedPermissions} />;
case 'settings':
return (
<SettingsPage
grantedPermissions={grantedPermissions}
currentUser={currentUser}
/>
);
default: default:
return <DashboardPage />; // Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das
// 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."
/>
);
} }
} }

View File

@ -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 wuerde. */ /** Baut eine Antwort, wie sie das Backend liefern würde. */
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-Huelle zurueck', async () => { it('gibt die Nutzlast aus der Standard-Hülle zurück', 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 liesse sich eine Operation nicht Ende-zu-Ende verfolgen. // Ohne Correlation ID ließe 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 verstaendlich', async () => { it('meldet eine nicht erreichbare API verständlich', 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 ausserhalb des Vertrags darf nicht stillschweigend // Eine Antwort außerhalb 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 Fehlerkoerper ab', async () => { it('lehnt eine Fehlerantwort ohne Fehlerkörper 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 unveraendert durch', async () => { it('reicht einen Abbruch unverändert 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 Aufraeumens und kein Fehlerfall. // Ein Abbruch ist Folge des Aufräumens 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),
); );

View File

@ -1,8 +1,8 @@
/** /**
* HTTP-Client fuer die Syncova-API. * HTTP-Client für die Syncova-API.
* *
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als * Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es * ApiError. Aufrufer müssen 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,14 +12,24 @@ 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, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ /** Header, über 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 traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender * Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann. * einen Vorfall gegenüber dem Betreiber eindeutig benennen kann.
*/ */
export class ApiError extends Error { export class ApiError extends Error {
/** Stabiler maschinenlesbarer Fehlercode. */ /** Stabiler maschinenlesbarer Fehlercode. */
@ -50,8 +60,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 erhaelt sie ueber * Der Client kennt die Anmeldelogik bewusst nicht, sondern erhält sie über
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und * diese Funktion. Andernfalls entstünde 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;
@ -61,24 +71,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void
accessTokenProvider = tokenProvider; accessTokenProvider = tokenProvider;
} }
/** Fehlercode fuer eine nicht erreichbare API. */ /** Fehlercode für eine nicht erreichbare API. */
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE'; export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
/** Fehlercode fuer eine unverstaendliche Antwort. */ /** Fehlercode für eine unverständliche Antwort. */
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE'; export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
/** /**
* Erzeugt eine Correlation ID fuer einen Request. * Erzeugt eine Correlation ID für einen Request.
* *
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der * crypto.randomUUID ist in allen unterstützten Browsern verfügbar; der
* Rueckfall deckt aeltere Testumgebungen ab. * Rückfall deckt ältere 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();
} }
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein // Rückfall 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)}`;
} }
@ -87,14 +97,22 @@ 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 Anfragekoerper, der als JSON gesendet wird. */ /** Optionaler Anfragekörper, 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;
} }
/** /**
* Fuehrt eine Anfrage gegen die Syncova-API aus. * Führt 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.
@ -116,8 +134,15 @@ 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 ueber den Zugriff. // unauthentifiziert - der Server entscheidet dann über den Zugriff.
const accessToken = accessTokenProvider(); const accessToken = accessTokenProvider();
if (accessToken !== null) { if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`; requestHeaders['Authorization'] = `Bearer ${accessToken}`;
@ -129,7 +154,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 laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs. // Die Sitzung läuft über ein Cookie bzw. einen Token desselben Ursprungs.
credentials: 'same-origin', credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}), ...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
}); });
@ -141,13 +166,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 pruefen.', message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.',
statusCode: 0, statusCode: 0,
requestId: correlationId, requestId: correlationId,
}); });
} }
// 204 traegt per Definition keinen Koerper. // 204 trägt per Definition keinen Körper.
if (httpResponse.status === 204) { if (httpResponse.status === 204) {
return undefined as TPayload; return undefined as TPayload;
} }
@ -158,19 +183,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 unverstaendlich.', message: 'Die Antwort des Servers war unverständlich.',
statusCode: httpResponse.status, statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
}); });
} }
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status. // Maßgeblich ist die Antworthülle, 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 Huelle den Inhalt. GET /api/v1/health nutzt genau diese // Betriebszustand, die Hülle 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 vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden // aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge // Status außerhalb 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) {
@ -199,17 +224,17 @@ export async function requestApi<TPayload>(
} }
/** /**
* Laedt eine Datei von der API herunter. * Lädt eine Datei von der API herunter.
* *
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine** * Sie steht neben requestApi und nicht darin: Eine Datei trägt **keine**
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi * Antworthülle, sondern ist der Inhalt selbst. Würde 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
* unverstaendliche Antwort — obwohl alles in Ordnung ist. * unverständliche Antwort — obwohl alles in Ordnung ist.
* *
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage, * Der Fehlerfall geht dagegen sehr wohl durch die Hülle: Scheitert die Anfrage,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die * antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, 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 saehe statt einer Meldung * „bericht.pdf" im Download-Ordner, und der Anwender sähe statt einer Meldung
* eine kaputte Datei. * eine kaputte Datei.
*/ */
export async function downloadApiFile( export async function downloadApiFile(
@ -247,7 +272,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 pruefen.', message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.',
statusCode: 0, statusCode: 0,
requestId: correlationId, requestId: correlationId,
}); });
@ -285,8 +310,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 waehlt einen — * Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer wählt einen —
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner. * ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner.
*/ */
function parseFileNameFromDisposition(dispositionHeader: string | null): string { function parseFileNameFromDisposition(dispositionHeader: string | null): string {
if (dispositionHeader === null) { if (dispositionHeader === null) {

View File

@ -1,14 +1,14 @@
/** /**
* Allgemeiner Lade-Hook fuer API-Ressourcen. * Allgemeiner Lade-Hook für 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 ausdruecklich „laedt" oder „Fehler" — niemals ein * vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse * leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe
* (PROMPT.md §139). * (PROMPT.md §139).
* *
* Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite. * Der Hook ersetzt die Wiederholung derselben dreißig 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 waere ein zweiter Ort, an dem sie fehlen kann. * eigener Fehlerbehandlung wäre 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;
/** Laedt die Ressource erneut. */ /** Lädt die Ressource erneut. */
readonly reload: () => void; readonly reload: () => void;
} }
/** /**
* Laedt eine Ressource und haelt ihren Zustand. * Lädt eine Ressource und hält ihren Zustand.
* *
* @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal. * @param loadResource Ladefunktion; sie erhält ein Abbruchsignal.
* @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner * @param dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner
* Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge * Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge
* ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem * ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt 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 traegt den Schluessel, unter dem es entstanden ist. Daraus // Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus
// laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen: // lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
// Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein // Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein
// setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus, // setState im Effektkörper löste dagegen eine zweite Renderrunde aus,
// bevor ueberhaupt etwas geladen wurde. // bevor überhaupt 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 Aufraeumens. // Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens.
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 Abhaengigkeiten: Eine bei jedem Rendern // loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern
// neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der // neu gebildete Funktion löste sonst eine Endlosschleife aus. Der
// effectiveKey steuert das Neuladen ausdruecklich. // effectiveKey steuert das Neuladen ausdrücklich.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveKey]); }, [effectiveKey]);
// Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die // Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die // vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die
// Tabelle also nicht aufblitzen. // Tabelle also nicht aufblitzen.
if (loadResult === null || loadResult.key !== effectiveKey) { if (loadResult === null || loadResult.key !== effectiveKey) {
return { return {

View File

@ -0,0 +1,204 @@
/**
* 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.

View File

@ -0,0 +1,92 @@
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.

View File

@ -1,64 +0,0 @@
/**
* 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>;
}

View File

@ -1,20 +0,0 @@
/* 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);
}

View File

@ -1,44 +0,0 @@
/**
* 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>
);
}

View File

@ -0,0 +1,284 @@
/**
* 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>
);
}

View File

@ -0,0 +1,112 @@
/**
* 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>
);
}
}

View File

@ -0,0 +1,42 @@
/** 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>
);
}

View File

@ -0,0 +1,79 @@
/**
* 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 };

View File

@ -0,0 +1,74 @@
/** 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}
/>
);
}

View File

@ -0,0 +1,141 @@
/**
* 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>
);
}

View File

@ -0,0 +1,244 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,166 @@
/**
* 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>
);
}

View File

@ -0,0 +1,193 @@
/**
* 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>
);
}

View File

@ -0,0 +1,74 @@
/**
* 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);
}
});
});

View File

@ -0,0 +1,197 @@
/**
* 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>
);
}

View File

@ -0,0 +1,176 @@
/**
* 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>
);
}

View File

@ -0,0 +1,19 @@
/** 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';

View File

@ -0,0 +1,220 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,365 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,405 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,453 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,252 @@
/**
* 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 },
});
}

View File

@ -1,31 +1,46 @@
/** /**
* Meldungen. * Meldungen.
* *
* Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der * Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
* Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es * **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiß,
* wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf * was überhaupt geprüft wird.** Ohne die Liste bliebe offen, ob „keine
* geachtet wird. * Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprüft".
* *
* Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist * „Zur Kenntnis nehmen" schließt eine Meldung nicht. Sie bleibt offen und in
* erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird. * der Liste, bis ihre Ursache verschwindet — sonst verschwände der Zustand aus
* 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 { EmptyState, ErrorState, LoadingState } from '../../components/PageState'; import { describeApiError, useMutation } from '@/api/useMutation';
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, AlertSeverity } from './alertsApi'; import type { Alert, AlertOverview } 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),
@ -44,264 +59,250 @@ export function AlertsPage(): React.JSX.Element {
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter)); const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
/** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */ const acknowledgeMutation = useMutation(
async function runAlertAction(action: () => Promise<void>): Promise<void> { (alertIdentifier: string) => acknowledgeAlert(alertIdentifier, ''),
try { {
await action(); onSuccess: () => {
setActionError(null); toast.showSuccess(
setActionCounter((previousCounter) => previousCounter + 1); 'Zur Kenntnis genommen',
} catch (caughtError) { 'Die Meldung bleibt offen, bis ihre Ursache verschwindet.',
setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler'); );
} setActionCounter((previousCounter) => previousCounter + 1);
} },
onError: (apiError) =>
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"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Meldungen</h1> title="Meldungen"
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 ? (
<span className="page__meta"> <SummaryBar overview={overviewResource.data} />
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '} ) : null}
Regeln werden geprueft
</span>
)}
</header>
{overviewResource.data !== null && <SummaryBar overview={overviewResource.data} />} <Card className="mb-4">
<div className="border-b border-line px-5 py-3">
<div className="filter-bar"> <CheckboxField
<label className="filter-bar__checkbox">
<input
type="checkbox"
checked={onlyActive} checked={onlyActive}
onChange={(changeEvent) => setOnlyActive(changeEvent.target.checked)} onCheckedChange={setOnlyActive}
label="Nur unerledigte"
/> />
Nur unerledigte
</label>
</div>
{actionError !== null && (
<div className="notice notice--critical" role="alert">
<p className="notice__text">{actionError}</p>
</div> </div>
)}
{loadState === 'loading' && <LoadingState what="Die Meldungen" />} {loadState === 'loading' ? <LoadingState label="Die Meldungen werden geladen …" /> : null}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loaded' && data !== null && data.length === 0 && ( {loadState === 'failed' && loadError !== null ? (
<EmptyState <ErrorState
message={ message={describeApiError(loadError)}
onlyActive requestId={loadError.requestId}
? 'Keine unerledigten Meldungen. Die Regeln unten sagen, worauf geachtet wird.' onRetry={reload}
: 'Es gibt keine Meldungen.' />
} ) : null}
/>
)}
{loadState === 'loaded' && data !== null && data.length > 0 && ( {loadState === 'loaded' && data !== null && data.length === 0 ? (
<ul className="alert-list"> <EmptyState
{data.map((alert) => ( title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
<AlertCard description="Die Liste „Worauf geachtet wird“ unten sagt, was geprüft wird — erst damit ist eine leere Liste eine gute Nachricht."
key={alert.id} />
alert={alert} ) : null}
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))}
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))}
/>
))}
</ul>
)}
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />} {loadState === 'loaded' && data !== null && data.length > 0 ? (
</section> <ul className="divide-y divide-line">
{data.map((alert) => (
<AlertRow
key={alert.id}
alert={alert}
isBusy={acknowledgeMutation.isRunning || resolveMutation.isRunning}
onAcknowledge={() => void acknowledgeMutation.run(alert.id)}
onResolve={() => void resolveMutation.run(alert.id)}
/>
))}
</ul>
) : null}
</Card>
{overviewResource.data !== null ? <RuleList overview={overviewResource.data} /> : null}
</>
); );
} }
/** 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 }: SummaryBarProperties): React.JSX.Element { function SummaryBar({ overview }: { readonly overview: AlertOverview }): 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="summary-bar"> <div className="mb-4 grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<SummaryFigure {figures.map((figure) => (
label="Kritisch" <Card key={figure.label} className="p-4">
value={summary.critical_count} <p
severity={summary.critical_count > 0 ? 'critical' : undefined} className={cn(
/> 'text-2xl font-semibold tabular',
<SummaryFigure // Farbe nur, wenn der Wert eine Aussage trägt: Null kritische
label="Ernst" // Meldungen sind neutral, nicht grün — grün hieße "geprüft
value={summary.high_count} // und in Ordnung", und das sagt diese Zahl allein nicht.
severity={summary.high_count > 0 ? 'high' : undefined} figure.tone && figure.value > 0
/> ? figure.tone === 'critical'
<SummaryFigure label="Unbearbeitet" value={summary.open_count} /> ? 'text-critical'
<SummaryFigure label="Zur Kenntnis genommen" value={summary.acknowledged_count} /> : 'text-high'
<SummaryFigure label="Erledigt (24 h)" value={summary.resolved_last_day} /> : 'text-fg',
)}
>
{figure.value}
</p>
<p className="mt-0.5 text-xs text-fg-muted">{figure.label}</p>
</Card>
))}
</div> </div>
); );
} }
/** Eigenschaften einer Kennzahl der Zusammenfassung. */
interface SummaryFigureProperties {
/** Beschriftung. */
readonly label: string;
/** Der Wert. */
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;
/** Nimmt die Meldung zur Kenntnis. */
readonly onAcknowledge: () => void;
/** Schliesst die Meldung. */
readonly onResolve: () => void;
}
/** Zeigt eine einzelne Meldung. */ /** Zeigt eine einzelne Meldung. */
function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProperties): React.JSX.Element { function AlertRow({
alert,
isBusy,
onAcknowledge,
onResolve,
}: {
readonly alert: Alert;
readonly isBusy: boolean;
readonly onAcknowledge: () => void;
readonly onResolve: () => void;
}): React.JSX.Element {
const isResolved = alert.status === 'resolved'; const isResolved = alert.status === 'resolved';
return ( return (
<li className={`alert-card alert-card--${alert.severity}${isResolved ? ' alert-card--resolved' : ''}`}> <li className={cn('px-5 py-4', isResolved && 'opacity-60')}>
<div className="alert-card__head"> <div className="flex flex-wrap items-start justify-between gap-3">
<span className={`badge badge--${severityClassOf(alert.severity)}`}>{alert.severity}</span> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={alert.severity} />
<h3 className="min-w-0 font-medium text-fg">{alert.title}</h3>
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
zwanzigmal ein Zustand. */}
{alert.occurrence_count > 1 ? (
<span className="rounded bg-sunken px-1.5 py-0.5 text-xs text-fg-muted">
{alert.occurrence_count}× aufgetreten
</span>
) : null}
</div>
<h2 className="alert-card__title">{alert.title}</h2> <p className="mt-1.5 text-sm text-fg">{alert.message}</p>
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall, <p className="mt-1.5 text-xs text-fg-muted">
zwanzigmal ein Zustand. */} Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '}
{alert.occurrence_count > 1 && ( {formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name}
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span> </p>
)}
</div>
<p className="alert-card__message">{alert.message}</p> {alert.status === 'acknowledged' ? (
<p className="mt-1 text-xs text-fg-muted">
Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die
Meldung <strong>bleibt offen</strong>, bis ihre Ursache verschwindet.
</p>
) : null}
<p className="alert-card__meta"> {isResolved ? (
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} · <p className="mt-1 text-xs text-fg-muted">
Regel {alert.rule_name} Erledigt am {formatDateTime(alert.resolved_at)}
</p> {alert.resolution_note !== undefined && alert.resolution_note !== ''
? ` — ${alert.resolution_note}`
{alert.status === 'acknowledged' && ( : ''}
<p className="alert-card__meta"> </p>
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt ) : null}
offen, bis ihre Ursache verschwindet.
</p>
)}
{isResolved && (
<p className="alert-card__meta">
Erledigt am {formatTimestamp(alert.resolved_at)}
{alert.resolution_note !== undefined && alert.resolution_note !== ''
? ` — ${alert.resolution_note}`
: ''}
</p>
)}
{!isResolved && (
<div className="alert-card__actions">
{alert.status === 'open' && (
<button className="button button--secondary" type="button" onClick={onAcknowledge}>
Zur Kenntnis nehmen
</button>
)}
<button className="button button--secondary" type="button" onClick={onResolve}>
Schliessen
</button>
</div> </div>
)}
{!isResolved ? (
<div className="flex shrink-0 gap-2">
{alert.status === 'open' ? (
<Button size="sm" disabled={isBusy} onClick={onAcknowledge}>
Zur Kenntnis nehmen
</Button>
) : null}
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
Schließen
</Button>
</div>
) : null}
</div>
</li> </li>
); );
} }
/** Eigenschaften der Regelliste. */
interface RuleListProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/** /**
* Zeigt, worauf geachtet wird. * Zeigt, worauf geachtet wird.
* *
* Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in * Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt
* Ordnung" oder „es wird nichts geprueft". * den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten
* Regel ausdrücklich, dass sie nicht greift, und warum.
*/ */
function RuleList({ overview }: RuleListProperties): React.JSX.Element { function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
return ( return (
<section className="rule-list"> <Card>
<h2 className="rule-list__title">Worauf geachtet wird</h2> <CardHeader
title="Worauf geachtet wird"
<ul className="rule-list__items"> description="Erst mit dieser Liste ist eine leere Meldungsliste eine gute Nachricht."
{overview.rules.map((rule) => ( />
<li <CardContent>
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'} <ul className="space-y-3">
key={rule.name} {overview.rules.map((rule) => (
> <li
<div className="rule-list__head"> key={rule.name}
<span className="rule-list__name">{rule.title}</span> className={cn(
'rounded-md border border-line p-3',
{rule.available ? ( !rule.available && 'opacity-70',
<span className={`badge badge--${severityClassOf(rule.severity)}`}>{rule.severity}</span>
) : (
<span className="badge badge--neutral">wird nicht geprueft</span>
)} )}
</div> >
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-fg">{rule.title}</span>
{rule.available ? (
<StatusBadge status={rule.severity} />
) : (
<StatusBadge status="neutral" label="wird nicht geprüft" />
)}
</div>
<p className="mt-1 text-sm text-fg-muted">
{rule.available ? rule.description : rule.unavailable_reason}
</p>
</li>
))}
</ul>
<p className="rule-list__description"> {overview.available_rule_count < overview.rules.length ? (
{rule.available ? rule.description : rule.unavailable_reason} <Callout tone="warning" className="mt-4">
</p> {overview.rules.length - overview.available_rule_count} Regeln werden
</li> nicht ausgewertet. Rechnen Sie sie nicht als „in Ordnung" — sie
))} schweigen, weil ihnen die Datengrundlage fehlt.
</ul> </Callout>
</section> ) : 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',
});
}

View File

@ -1,5 +1,5 @@
/** /**
* Zugriff auf Meldungen und Benachrichtigungskanaele. * Zugriff auf Meldungen und Benachrichtigungskanäle.
*/ */
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 {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
readonly id: string; readonly id: string;
/** Ausloesende Regel. */ /** Auslösende 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;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Befund und naechste Handlung. */ /** Befund und nächste 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 Bestaetigenden. */ /** Bemerkung des Bestätigenden. */
readonly acknowledgement_note?: string; readonly acknowledgement_note?: string;
/** Zeitpunkt der Aufloesung in UTC. */ /** Zeitpunkt der Auflösung in UTC. */
readonly resolved_at?: string; readonly resolved_at?: string;
/** Begruendung der Aufloesung. */ /** Begründung der Auflösung. */
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;
/** Erklaerung. */ /** Erklärung. */
readonly description: string; readonly description: string;
/** Schweregrad ausgeloester Meldungen. */ /** Schweregrad ausgelöster Meldungen. */
readonly severity: AlertSeverity; readonly severity: AlertSeverity;
/** Meldet, ob die Regel ausloesen kann. */ /** Meldet, ob die Regel auslösen kann. */
readonly available: boolean; readonly available: boolean;
/** Erklaert eine Regel ohne Datengrundlage. */ /** Erklärt 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 Meldungsuebersicht. */ /** Antwort der Meldungsübersicht. */
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 ausloesbaren Regeln. */ /** Zahl der auslösbaren Regeln. */
readonly available_rule_count: number; readonly available_rule_count: number;
} }
/** Laedt die Meldungen. */ /** Lädt 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(
); );
} }
/** Laedt die Meldungslage samt Regelwerk. */ /** Lädt 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
}); });
} }
/** Schliesst eine Meldung von Hand. */ /** Schließt 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',

View File

@ -1,102 +0,0 @@
/* 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;
}

View File

@ -12,7 +12,7 @@ function buildResponse(responseBody: unknown, statusCode = 200): Response {
}); });
} }
/** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */ /** Ein vollständiger Benutzer, wie ihn die API zurückgibt. */
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 zurueckgibt. */ /** Ein Tokenpaar, wie es die API zurückgibt. */
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('schliesst die Anmeldung nach gueltigem Code ab', async () => { it('schließt die Anmeldung nach gültigem 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: 'Bestaetigen' })); await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
await waitFor(() => { await waitFor(() => {
expect(handleAuthenticated).toHaveBeenCalled(); expect(handleAuthenticated).toHaveBeenCalled();
}); });
}); });
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => { it('zeigt die Fehlermeldung des Servers verständlich 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 waere auf einem gemeinsam genutzten // Ein stehengebliebenes Passwort wäre 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 zurueck', async () => { it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurück', 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: 'Bestaetigen' })); await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
// Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt. // Ohne Rückkehr 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();
}); });

View File

@ -9,7 +9,6 @@ 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 {
@ -30,7 +29,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 verstaendliche Meldung (PROMPT.md §124). */ /** Wandelt einen Fehler in eine verständliche 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;
@ -60,7 +59,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 unvollstaendig. Bitte erneut versuchen.'); setErrorMessage('Die Antwort des Servers war unvollständig. 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.
@ -89,7 +88,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
return; return;
} }
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.'); setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
} catch (caughtError) { } catch (caughtError) {
setErrorMessage(describeError(caughtError)); setErrorMessage(describeError(caughtError));
setMfaCode(''); setMfaCode('');
@ -106,20 +105,20 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
} }
return ( return (
<div className="login-page"> <div className="flex min-h-screen items-center justify-center bg-page p-4">
<main className="login-card"> <main className="w-full max-w-sm border border-line-strong bg-card p-6">
<h1 className="login-card__title">Syncova</h1> <h1 className="text-lg font-semibold text-fg">Syncova</h1>
{loginStep === 'credentials' ? ( {loginStep === 'credentials' ? (
<form className="login-form" onSubmit={handleCredentialsSubmit}> <form className="mt-5 space-y-4" onSubmit={handleCredentialsSubmit}>
<p className="login-card__subtitle">Bitte anmelden</p> <p className="mt-1 text-sm text-fg-muted">Bitte anmelden</p>
<label className="login-form__label" htmlFor="username"> <label className="block text-sm font-medium text-fg" htmlFor="username">
Benutzername Benutzername
</label> </label>
<input <input
id="username" id="username"
className="login-form__input" 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"
type="text" type="text"
value={username} value={username}
onChange={(changeEvent) => setUsername(changeEvent.target.value)} onChange={(changeEvent) => setUsername(changeEvent.target.value)}
@ -128,12 +127,12 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
required required
/> />
<label className="login-form__label" htmlFor="password"> <label className="block text-sm font-medium text-fg" htmlFor="password">
Passwort Passwort
</label> </label>
<input <input
id="password" id="password"
className="login-form__input" 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"
type="password" type="password"
value={password} value={password}
onChange={(changeEvent) => setPassword(changeEvent.target.value)} onChange={(changeEvent) => setPassword(changeEvent.target.value)}
@ -142,33 +141,33 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
/> />
{errorMessage && ( {errorMessage && (
<p className="login-form__error" role="alert"> <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{errorMessage} {errorMessage}
</p> </p>
)} )}
<button className="login-form__button" type="submit" disabled={isSubmitting}> <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}>
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'} {isSubmitting ? 'Anmeldung läuft …' : 'Anmelden'}
</button> </button>
</form> </form>
) : ( ) : (
<form className="login-form" onSubmit={handleMFASubmit}> <form className="mt-5 space-y-4" onSubmit={handleMFASubmit}>
<p className="login-card__subtitle">Zweiter Faktor</p> <p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
<p className="login-form__hint"> <p className="mt-1.5 text-xs text-fg-muted">
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
Wiederherstellungscode moeglich. Wiederherstellungscode möglich.
</p> </p>
<label className="login-form__label" htmlFor="mfa-code"> <label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
Code Code
</label> </label>
<input <input
id="mfa-code" id="mfa-code"
className="login-form__input login-form__input--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]"
type="text" type="text"
value={mfaCode} value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)} onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
// one-time-code laesst Mobilgeraete den Code aus der SMS/App vorschlagen. // one-time-code lässt Mobilgeräte den Code aus der SMS/App vorschlagen.
autoComplete="one-time-code" autoComplete="one-time-code"
inputMode="text" inputMode="text"
autoFocus autoFocus
@ -176,17 +175,17 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
/> />
{errorMessage && ( {errorMessage && (
<p className="login-form__error" role="alert"> <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{errorMessage} {errorMessage}
</p> </p>
)} )}
<button className="login-form__button" type="submit" disabled={isSubmitting}> <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}>
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'} {isSubmitting ? 'Prüfung läuft …' : 'Bestätigen'}
</button> </button>
<button <button
className="login-form__secondary" className="w-full rounded-md px-4 py-2 text-sm text-fg-muted transition-colors hover:bg-hover hover:text-fg"
type="button" type="button"
onClick={() => { onClick={() => {
setLoginStep('credentials'); setLoginStep('credentials');

View File

@ -1,13 +1,16 @@
/** /**
* Anmeldefunktionen gegen die Syncova-API. * Anmeldefunktionen gegen die Syncova-API.
* *
* Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in * Die Tokens liegen im Arbeitsspeicher **und** im Sitzungsspeicher des Tabs.
* localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und * Warum das eine Abkehr von der ursprünglichen Entscheidung ist und was sie
* ueberstuenden das Schliessen des Browsers (PROMPT.md §45). * trägt, steht in `sessionStore.ts` — kurz: Ohne Ablage warf jedes Neuladen den
* 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;
@ -15,27 +18,53 @@ 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;
}
const storedSession = readStoredSession();
if (storedSession === null) {
return null;
}
currentAccessToken = storedSession.accessToken;
currentRefreshToken = storedSession.refreshToken;
return currentAccessToken; 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, enthaelt das Ergebnis eine * Ist ein zweiter Faktor eingerichtet, enthält 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> {
@ -51,7 +80,7 @@ export async function login(username: string, password: string): Promise<LoginRe
return loginResult; return loginResult;
} }
/** Schliesst eine Anmeldung mit dem zweiten Faktor ab. */ /** Schließt 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',
@ -76,7 +105,7 @@ export async function logout(): Promise<void> {
} }
} }
/** Laedt den aktuell angemeldeten Benutzer. */ /** Lädt den aktuell angemeldeten Benutzer. */
export async function fetchCurrentUser(): Promise<CurrentUser> { export async function fetchCurrentUser(): Promise<CurrentUser> {
return requestApi<CurrentUser>('/me'); return requestApi<CurrentUser>('/me');
} }

View File

@ -0,0 +1,102 @@
/**
* 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();
});
});

View File

@ -0,0 +1,181 @@
/**
* 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));
}

View File

@ -0,0 +1,172 @@
/**
* 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')}`;
}

View File

@ -0,0 +1,591 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,201 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,287 @@
/**
* 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' },
);
}

View File

@ -1,5 +1,5 @@
/** /**
* Tests der Uebersicht. * Tests der Übersicht.
* *
* 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 uebergebenen Kennzahlen. */ /** Baut eine Antwort mit den übergebenen 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('Uebersicht', () => { describe('Übersicht', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('fetch', vi.fn());
}); });
@ -30,7 +30,7 @@ describe('Uebersicht', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('zeigt eine Kennzahl samt Erlaeuterung', async () => { it('zeigt eine Kennzahl samt Erläuterung', async () => {
vi.mocked(fetch).mockResolvedValue( vi.mocked(fetch).mockResolvedValue(
buildDashboardResponse({ buildDashboardResponse({
available_count: 1, available_count: 1,
@ -42,7 +42,7 @@ describe('Uebersicht', () => {
available: true, available: true,
value: 87.5, value: 87.5,
unit: '%', unit: '%',
detail: '7 von 8 Laeufen vollstaendig erfolgreich.', detail: '7 von 8 Läufen vollständig erfolgreich.',
severity: 'high', severity: 'high',
}, },
], ],
@ -53,7 +53,7 @@ describe('Uebersicht', () => {
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 Laeufen vollstaendig erfolgreich.')).toBeInTheDocument(); expect(screen.getByText('7 von 8 Läufen vollständig 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('Uebersicht', () => {
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 hiesse „keine Probleme" und // Der entscheidende Teil: keine Null. Eine Null hieße „keine Probleme" und
// wuerde bedeuten „es wird nicht geprueft". // würde bedeuten „es wird nicht geprüft".
expect(screen.queryByText('0')).not.toBeInTheDocument(); expect(screen.queryByText('0')).not.toBeInTheDocument();
expect(screen.getByText('noch nicht verfuegbar')).toBeInTheDocument(); expect(screen.getByText('noch nicht verfügbar')).toBeInTheDocument();
}); });
it('meldet eine nicht bezifferbare Kennzahl als solche', async () => { it('meldet eine nicht bezifferbare Kennzahl als solche', async () => {
@ -94,7 +94,7 @@ describe('Uebersicht', () => {
key: 'storage', key: 'storage',
title: 'Speicherbelegung', title: 'Speicherbelegung',
available: true, available: true,
detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.', detail: 'Die Gesamtkapazität ist nicht hinterlegt.',
severity: 'information', severity: 'information',
}, },
], ],
@ -103,7 +103,7 @@ describe('Uebersicht', () => {
render(<DashboardPage />); render(<DashboardPage />);
// Verfuegbar, aber ohne Wert: Auch hier darf keine Null stehen. // Verfügbar, 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();
}); });

View File

@ -1,114 +1,159 @@
/** /**
* Uebersicht der Anlage. * Übersicht der Anlage.
* *
* Der Plan (§14) nennt zehn Kennzahlen. Sieben haben eine Datengrundlage, drei * Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
* nicht — und die drei erscheinen trotzdem, mit der Angabe, was fehlt. Ein * Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht
* weggelassenes Feld sieht aus wie ein vergessenes; ein mit einer Null * aus wie ein vergessenes; ein mit einer Null gefülltes wäre eine erfundene
* gefuelltes waere eine erfundene Statistik (PROMPT.md §139). * 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 { ErrorState, LoadingState } from '../../components/PageState'; import { describeApiError } from '@/api/useMutation';
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';
/** Zeigt die Uebersicht. */ export function DashboardPage({
export function DashboardPage(): React.JSX.Element { grantedPermissions = [],
const loadDashboard = useCallback((abortSignal: AbortSignal) => fetchDashboard(abortSignal), []); onNavigate,
}: {
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"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Uebersicht</h1> title="Übersicht"
description={
data !== null
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
: undefined
}
/>
{data !== null && ( {onNavigate ? (
<span className="page__meta"> <QuickActions grantedPermissions={grantedPermissions} onNavigate={onNavigate} />
{data.available_count} von {data.widgets.length} Kennzahlen haben eine Datengrundlage ) : null}
</span>
)}
</header>
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />} {loadState === 'loading' ? (
{loadState === 'failed' && loadError !== null && ( <Card>
<ErrorState error={loadError} onRetry={reload} /> <LoadingState label="Die Kennzahlen werden geladen …" />
)} </Card>
) : null}
{loadState === 'loaded' && data !== null && ( {loadState === 'failed' && loadError !== null ? (
<div className="widget-grid"> <Card>
<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> </>
); );
} }
/** Eigenschaften einer Kennzahlkachel. */ /** Randfarbe je Schweregrad. Nur die Linie, nicht die Fläche. */
interface WidgetCardProperties { const severityBorderClasses: Readonly<Record<string, string>> = {
/** Die dargestellte Kennzahl. */ healthy: 'border-l-healthy',
readonly widget: DashboardWidget; info: 'border-l-info',
} warning: 'border-l-warning',
high: 'border-l-high',
critical: 'border-l-critical',
};
/** Zeigt eine einzelne Kennzahl. */ /** Zeigt eine einzelne Kennzahl. */
function WidgetCard({ widget }: WidgetCardProperties): React.JSX.Element { function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX.Element {
if (!widget.available) { if (!widget.available) {
return ( return (
<article className="widget widget--unavailable"> <Card className="border-l-4 border-l-neutral p-4">
<h2 className="widget__title">{widget.title}</h2> <p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="widget__unavailable">noch nicht verfuegbar</p> <p className="mt-2 text-sm text-fg-subtle">noch nicht verfügbar</p>
<p className="widget__detail">{widget.unavailable_reason}</p> <p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
</article> </Card>
); );
} }
return ( return (
<article className={`widget widget--${widget.severity}`}> <Card
<h2 className="widget__title">{widget.title}</h2> className={cn(
'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="widget__value"> <p className="mt-2 flex items-baseline gap-1.5">
{widget.value === undefined ? ( {widget.value === undefined ? (
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden // Kein Wert heißt nicht null. Eine Kennzahl, die sich nicht bilden
// laesst, sagt das — der Grund steht im Detailtext darunter. // lässt, sagt das — der Grund steht im Detailtext darunter.
<span className="widget__value-missing">nicht bezifferbar</span> <span className="text-sm text-fg-muted">nicht bezifferbar</span>
) : ( ) : (
<> <>
{formatWidgetValue(widget.value)} <span className="text-2xl font-semibold tabular text-fg">
{widget.unit !== undefined && <span className="widget__unit"> {widget.unit}</span>} {formatWidgetValue(widget.value)}
</span>
{widget.unit !== undefined ? (
<span className="text-sm text-fg-muted">{widget.unit}</span>
) : null}
</> </>
)} )}
</p> </p>
{widget.detail !== undefined && <p className="widget__detail">{widget.detail}</p>} {widget.detail !== undefined ? (
<p className="mt-1 text-xs text-fg-muted">{widget.detail}</p>
) : null}
{widget.breakdown !== undefined && ( {widget.breakdown !== undefined ? (
<dl className="widget__breakdown"> <dl className="mt-3 space-y-1 border-t border-line pt-2">
{Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => ( {Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => (
<div className="widget__breakdown-item" key={breakdownKey}> <div className="flex justify-between gap-2 text-xs" key={breakdownKey}>
<dt>{formatBreakdownLabel(breakdownKey)}</dt> <dt className="truncate text-fg-muted">{formatBreakdownLabel(breakdownKey)}</dt>
<dd>{breakdownValue.toLocaleString('de-DE')}</dd> <dd className="tabular text-fg">{breakdownValue.toLocaleString('de-DE')}</dd>
</div> </div>
))} ))}
</dl> </dl>
)} ) : null}
</article> </Card>
); );
} }
/** 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 Ueberlegung wie // sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Überlegung 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
// weiss, und auch nicht weniger. // weiß, 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 Laeufen scheiterten. // mehr als „100 %", wenn drei von tausend Läufen scheiterten.
if (Number.isInteger(widgetValue)) { if (Number.isInteger(widgetValue)) {
return widgetValue.toLocaleString('de-DE'); return widgetValue.toLocaleString('de-DE');
} }
@ -116,7 +161,7 @@ function formatWidgetValue(widgetValue: number): string {
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 }); return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
} }
/** Macht aus einem Schluessel der Aufschluesselung eine Beschriftung. */ /** Macht aus einem Schlüssel der Aufschlüsselung eine Beschriftung. */
function formatBreakdownLabel(breakdownKey: string): string { function formatBreakdownLabel(breakdownKey: string): string {
const readableLabel = breakdownKey.replace(/_/g, ' '); const readableLabel = breakdownKey.replace(/_/g, ' ');

View File

@ -0,0 +1,140 @@
/**
* 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>
);
}

View File

@ -4,35 +4,58 @@
* 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 { EmptyState, ErrorState, LoadingState } from '../../components/PageState'; import { describeApiError } from '@/api/useMutation';
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';
/** Auswahlmoeglichkeiten des Einstufungsfilters. */ /** Auswahlmöglichkeiten 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: 'Geprueft' }, { value: 'verified', label: 'Geprüft' },
{ value: 'successful', label: 'Ungeprueft' }, { value: 'successful', label: 'Ungeprüft' },
{ value: 'corrupted', label: 'Beschaedigt' }, { value: 'corrupted', label: 'Beschädigt' },
]; ];
/** Zeigt die Wiederherstellungspunkte. */ export function RecoveryPointsPage({
export function RecoveryPointsPage(): React.JSX.Element { onOpenBackup,
}: {
/** Ö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 Schluessel bildet die Filter ab: Aendert er sich, wird neu geladen. // Der Schlüssel bildet die Filter ab: Ändert 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({ classification: classificationFilter, includeDeleted, onlyProtected }, abortSignal), fetchRecoveryPoints(
{ classification: classificationFilter, includeDeleted, onlyProtected },
abortSignal,
),
[classificationFilter, includeDeleted, onlyProtected], [classificationFilter, includeDeleted, onlyProtected],
); );
@ -41,200 +64,150 @@ export function RecoveryPointsPage(): React.JSX.Element {
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"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Wiederherstellungspunkte</h1> title="Wiederherstellungspunkte"
description={
data !== null
? `${data.length} Punkte. Nur ein durchgeführter Wiederherstellungstest hebt auf „wiederherstellbar“.`
: undefined
}
/>
{data !== null && <span className="page__meta">{data.length} Punkte</span>} <Card>
</header> <div className="flex flex-wrap items-end gap-4 border-b border-line px-5 py-3">
<SelectInput
<div className="filter-bar"> label="Einstufung"
<label className="filter-bar__field"> className="w-64"
<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((filterOption) => ( {CLASSIFICATION_OPTIONS.map((option) => (
<option key={filterOption.value} value={filterOption.value}> <option key={option.value} value={option.value}>
{filterOption.label} {option.label}
</option> </option>
))} ))}
</select> </SelectInput>
</label>
<label className="filter-bar__checkbox"> <div className="flex flex-wrap gap-4 pb-2">
<input <CheckboxField
type="checkbox" checked={includeDeleted}
checked={onlyProtected} onCheckedChange={setIncludeDeleted}
onChange={(changeEvent) => setOnlyProtected(changeEvent.target.checked)} label="Gelöschte einschließen"
/> />
Nur geschuetzte <CheckboxField
</label> checked={onlyProtected}
onCheckedChange={setOnlyProtected}
{/* Geloeschte Punkte erscheinen nur auf Nachfrage — die Frage „warum ist label="Nur geschützte"
das Backup von vorletzter Woche weg?" muss aber beantwortbar sein. */} />
<label className="filter-bar__checkbox"> </div>
<input
type="checkbox"
checked={includeDeleted}
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)}
/>
Geloeschte einbeziehen
</label>
</div>
{loadState === 'loading' && <LoadingState what="Die Wiederherstellungspunkte" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{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> </div>
)}
</section> {loadState === 'failed' && loadError !== null ? (
<ErrorState
message={describeApiError(loadError)}
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>
</>
); );
} }
/** 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}`;
}

View File

@ -1,5 +1,5 @@
/** /**
* Zugriff auf die Uebersicht und die Wiederherstellungspunkte. * Zugriff auf die Übersicht 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 Uebersicht. */ /** Eine Kennzahl der Übersicht. */
export interface DashboardWidget { export interface DashboardWidget {
/** Maschinenlesbarer Bezeichner. */ /** Maschinenlesbarer Bezeichner. */
readonly key: string; readonly key: string;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Meldet, ob es eine Datengrundlage gibt. */ /** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean; readonly available: boolean;
/** Erklaert eine fehlende Datengrundlage. */ /** Erklärt eine fehlende Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
/** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */ /** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */
readonly value?: number; readonly value?: number;
/** Einheit der Hauptzahl. */ /** Einheit der Hauptzahl. */
readonly unit?: string; readonly unit?: string;
/** Erlaeuterung in einem Satz. */ /** Erläuterung in einem Satz. */
readonly detail?: string; readonly detail?: string;
/** Statusfarbe. */ /** Statusfarbe. */
readonly severity: WidgetSeverity; readonly severity: WidgetSeverity;
/** Ergaenzende Einzelwerte. */ /** Ergänzende Einzelwerte. */
readonly breakdown?: Record<string, number>; readonly breakdown?: Record<string, number>;
} }
/** Die Uebersicht der Anlage. */ /** Die Übersicht 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 Uebersicht. */ /** Ein Wiederherstellungspunkt in der Übersicht. */
export interface RecoveryPoint { export interface RecoveryPoint {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher 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 Integritaetspruefung in UTC. */ /** Letzte Integritätsprüfung 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 Loeschung derzeit unzulaessig ist. */ /** Meldet, ob eine Löschung derzeit unzulässig ist. */
readonly is_protected: boolean; readonly is_protected: boolean;
/** Zeitpunkt der Loeschung in UTC. */ /** Zeitpunkt der Löschung in UTC. */
readonly deleted_at?: string; readonly deleted_at?: string;
/** Begruendung der Loeschung. */ /** Begründung der Löschung. */
readonly deletion_reason?: string; readonly deletion_reason?: string;
} }
/** Filter der Liste von Wiederherstellungspunkten. */ /** Filter der Liste von Wiederherstellungspunkten. */
export interface RecoveryPointFilter { export interface RecoveryPointFilter {
/** Beschraenkung auf ein Repository. */ /** Beschränkung auf ein Repository. */
readonly repositoryId?: string; readonly repositoryId?: string;
/** Beschraenkung auf eine Einstufung. */ /** Beschränkung auf eine Einstufung. */
readonly classification?: string; readonly classification?: string;
/** Nimmt geloeschte Punkte auf. */ /** Nimmt gelöschte Punkte auf. */
readonly includeDeleted?: boolean; readonly includeDeleted?: boolean;
/** Beschraenkt auf geschuetzte Punkte. */ /** Beschränkt auf geschützte Punkte. */
readonly onlyProtected?: boolean; readonly onlyProtected?: boolean;
/** Angeforderte Seite. */ /** Angeforderte Seite. */
readonly page?: number; readonly page?: number;
} }
/** Laedt die Uebersicht. */ /** Lädt die Übersicht. */
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 } : {});
} }
/** Laedt eine Seite von Wiederherstellungspunkten. */ /** Lädt eine Seite von Wiederherstellungspunkten. */
export async function fetchRecoveryPoints( export async function fetchRecoveryPoints(
filter: RecoveryPointFilter = {}, filter: RecoveryPointFilter = {},
abortSignal?: AbortSignal, abortSignal?: AbortSignal,

View File

@ -1,111 +0,0 @@
/* 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;
}

View File

@ -39,7 +39,7 @@ describe('SystemHealthPanel', () => {
expect(screen.getByText('database')).toBeInTheDocument(); expect(screen.getByText('database')).toBeInTheDocument();
}); });
// Der Zustand wird zusaetzlich zur Farbe als Text ausgegeben (PROMPT.md §107). // Der Zustand wird zusätzlich 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 beschoenigt werden (PROMPT.md §140). // Ein Problem darf niemals beschönigt 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 verstaendliche Meldung, wenn die API nicht erreichbar ist', async () => { it('zeigt eine verständliche 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 erklaerende Meldung (PROMPT.md §124). // Statt eines rohen Fehlercodes erscheint eine erklärende 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, haelt die Komponente im Ladezustand. // Eine Anfrage, die nie antwortet, hält die Komponente im Ladezustand.
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
render(<SystemHealthPanel />); render(<SystemHealthPanel />);

View File

@ -2,96 +2,133 @@
* 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
* erhaelt der Anwender eine verstaendliche Erklaerung und eine Handlungsempfehlung. * erhält der Anwender eine verständliche Erklärung und eine
* 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 { StatusIndicator } from '../../components/StatusIndicator'; import { useCallback } from 'react';
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;
/** Zeigt den Zustand des Backends und seiner Komponenten. */ /** Ein Eintrag der Komponententabelle. */
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 (
<section className="health-panel" aria-busy="true"> <Card aria-busy="true">
<h2 className="health-panel__title">Systemzustand</h2> <CardHeader title="Systemzustand" />
<p className="health-panel__hint">Der Systemzustand wird ermittelt …</p> <LoadingState label="Der Systemzustand wird ermittelt …" />
</section> </Card>
); );
} }
if (loadState === 'failed' || systemHealth === null) { if (loadState === 'failed' || systemHealth === null) {
return ( return (
<section className="health-panel"> <Card>
<h2 className="health-panel__title">Systemzustand</h2> <CardHeader title="Systemzustand" />
<ErrorState
{/* role="alert" sorgt dafuer, dass Screenreader den Fehler ansagen. */} title="Der Systemzustand konnte nicht ermittelt werden."
<div className="health-panel__error" role="alert"> message={loadError ? describeApiError(loadError) : undefined}
<p className="health-panel__error-message"> requestId={loadError && loadError.requestId !== '' ? loadError.requestId : undefined}
{loadError?.message ?? 'Der Systemzustand konnte nicht ermittelt werden.'} onRetry={reload}
</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 componentEntries = Object.entries(systemHealth.components); const componentRows = buildRows();
return ( return (
<section className="health-panel"> <Card>
<h2 className="health-panel__title">Systemzustand</h2> <CardHeader
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>
}
/>
<div className="health-panel__overall"> {componentRows.length === 0 ? (
<StatusIndicator status={systemHealth.status} /> <CardContent>
<span className="health-panel__version"> <p className="text-sm text-fg-muted">Es sind keine Komponenten registriert.</p>
Version {systemHealth.version} · Umgebung {systemHealth.environment} </CardContent>
</span>
</div>
{componentEntries.length === 0 ? (
<p className="health-panel__hint">Es sind keine Komponenten registriert.</p>
) : ( ) : (
<table className="health-panel__table"> <DataTable
<caption className="health-panel__caption">Zustand der einzelnen Komponenten</caption> columns={componentColumns}
<thead> rows={componentRows}
<tr> getRowKey={(component) => component.name}
<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>
)}
</td>
<td className="health-panel__latency">{componentHealth.latency_ms.toFixed(1)} ms</td>
</tr>
))}
</tbody>
</table>
)} )}
</section> </Card>
); );
} }

View File

@ -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 ausdruecklich "laedt" oder "Fehler" * Ergebnis vorliegt, bleibt der Zustand ausdrücklich "lädt" 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';
/** Rueckgabewert des Hooks. */ /** Rückgabewert 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;
/** Laedt den Systemzustand erneut. */ /** Lädt den Systemzustand erneut. */
reload: () => void; reload: () => void;
} }
/** /**
* Laedt den Systemzustand von GET /api/v1/health. * Lädt 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 Aufraeumens. // Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens.
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 waere irrefuehrend. // als aktuellen Zustand auszugeben wäre irreführend.
setSystemHealth(null); setSystemHealth(null);
setLoadError( setLoadError(
caughtError instanceof ApiError caughtError instanceof ApiError

View File

@ -1,164 +0,0 @@
/**
* 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>
);
}

View File

@ -0,0 +1,520 @@
/**
* 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>
);
}

View File

@ -0,0 +1,161 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,611 @@
/**
* 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>
);
}

View File

@ -0,0 +1,269 @@
/**
* 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 } : {},
);
}

View File

@ -1,357 +0,0 @@
/**
* 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}`;
}

View File

@ -1,113 +0,0 @@
/**
* 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 } : {});
}

View File

@ -1,324 +0,0 @@
/**
* 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;
}

View File

@ -1,8 +1,8 @@
/** /**
* Tests des Backup-Assistenten. * Tests des Backup-Assistenten.
* *
* Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte * Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte
* aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine * aufhalten, dass Rückwärtsgehen 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]);
}); });
/** Fuehrt den Assistenten bis zu einem Schritt und fuellt das Noetige aus. */ /** Führt den Assistenten bis zu einem Schritt und füllt das Nötige 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'), 'Naechtliche Sicherung'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche 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 "taeglich 02:00" genuegt. // Zeitplan: der Standard "täglich 02:00" genügt.
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 ausserhalb von act(). // erst nach dem Test, und React meldet eine Aktualisierung außerhalb 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',
'Pruefung', 'Prüfung',
'Benachrichtigung', 'Benachrichtigung',
'Uebersicht', 'Übersicht',
'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 hiesse, ein leeres Formular als fehlerhaft zu markieren. // zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
it('haelt bei einem leeren Namen auf und erklaert warum', async () => { it('hält bei einem leeren Namen auf und erklärt 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('haelt ohne Quelle auf', async () => { it('hält 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 waere // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre
// jeder Blick zurueck ein Datenverlust. // jeder Blick zurück ein Datenverlust.
it('bewahrt die Eingaben beim Zurueckgehen', async () => { it('bewahrt die Eingaben beim Zurückgehen', 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'), 'Naechtliche Sicherung'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
await user.click(screen.getByRole('button', { name: 'Zurueck' })); await user.click(screen.getByRole('button', { name: 'Zurück' }));
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Naechtliche Sicherung'); expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Nächtliche 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: taeglich um 02:00 Uhr/)).toBeInTheDocument(); expect(screen.getByText(/Ergibt: täglich 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, laesst sie aber nicht waehlen', async () => { it('zeigt gesperrte Repositories, lässt sie aber nicht wählen', 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
// vorgetaeuschtes Funktionsversprechen (PROMPT.md §138). // vorgetäuschtes 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 verfuegbar')).toBeInTheDocument(); expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument();
expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument(); expect(screen.getByText(/nicht automatisch gelöscht/)).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: 'Naechtliche Sicherung' } as jobsApi.BackupJob; const createdJob = { id: 'abc', name: 'Nächtliche 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('Naechtliche Sicherung'); expect(sentRequest?.name).toBe('Nächtliche 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 Oberflaeche nicht kennen kann. // eigentlichen Grund, den die Oberfläche 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 laeuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten', message: 'der zeitplan läuft 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 zurueckgehen und den // Der Assistent bleibt stehen, damit der Anwender zurückgehen und den
// Zeitplan aendern kann. // Zeitplan ändern 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 zurueck ueber die Schrittleiste, nicht nach vorn', async () => { it('erlaubt den Sprung zurück über 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 wuerde eine // Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine
// Pruefung ueberspringen, die der Assistent gerade fuehren soll. // Prüfung überspringen, die der Assistent gerade führen 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/ }));

View File

@ -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 fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt, * Der Assistent führt von Name bis Anlegen. Die Prüfung geschieht je Schritt,
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten * damit ein Fehler dort auffällt, wo er entsteht - nicht erst nach dem letzten
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem * Schritt. Rückwärts 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 } from './jobsApi'; import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi';
import type { BackupJob, BackupRepository, SourceType } from './jobsApi'; import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
import { import {
buildCreateRequest, buildCreateRequest,
@ -23,7 +23,6 @@ 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 {
@ -59,7 +58,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 Schliessen des Assistenten noch Zustand setzt. // Antwort nach dem Schließen des Assistenten noch Zustand setzt.
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@ -76,17 +75,17 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return () => abortController.abort(); return () => abortController.abort();
}, []); }, []);
/** Aendert ein Feld des Entwurfs. */ /** Ändert 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 vollstaendig ist. */ /** Geht einen Schritt vor, sofern der aktuelle vollständig 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 hiesse, ein leeres Formular als fehlerhaft zu markieren. // an zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
setShowProblems(true); setShowProblems(true);
return; return;
@ -96,7 +95,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 zurueck. */ /** Geht einen Schritt zurück. */
function goToPreviousStep(): void { function goToPreviousStep(): void {
setShowProblems(false); setShowProblems(false);
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0)); setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
@ -132,7 +131,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="wizard__steps"> <ol className="flex flex-wrap items-center gap-1 border-b border-line px-5 py-3">
{WIZARD_STEPS.map((wizardStep, stepIndex) => ( {WIZARD_STEPS.map((wizardStep, stepIndex) => (
<li key={wizardStep}> <li key={wizardStep}>
<button <button
@ -142,48 +141,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="wizard__step-number">{stepIndex + 1}</span> <span className="tabular">{stepIndex + 1}</span>
<span className="wizard__step-title">{STEP_TITLES[wizardStep]}</span> <span className="">{STEP_TITLES[wizardStep]}</span>
</button> </button>
</li> </li>
))} ))}
</ol> </ol>
<div className="wizard__panel"> <div className="px-5 py-4">
<h2 className="wizard__heading"> <h2 className="text-base font-semibold text-fg">
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="wizard__problems" role="alert"> <ul className="mt-3 space-y-1 rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{stepValidation.problems.map((problemText) => ( {stepValidation.problems.map((problemText) => (
<li key={problemText}>{problemText}</li> <li key={problemText}>{problemText}</li>
))} ))}
</ul> </ul>
)} )}
<div className="wizard__actions"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-5 py-3">
<button type="button" className="wizard__button" onClick={onCancel}> <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}>
Abbrechen Abbrechen
</button> </button>
<div className="wizard__navigation"> <div className="flex gap-2">
{currentStepIndex > 0 && ( {currentStepIndex > 0 && (
<button type="button" className="wizard__button" onClick={goToPreviousStep}> <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}>
Zurueck Zurück
</button> </button>
)} )}
{currentStep !== 'create' ? ( {currentStep !== 'create' ? (
<button type="button" className="wizard__button wizard__button--primary" onClick={goToNextStep}> <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}>
Weiter Weiter
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
className="wizard__button wizard__button--primary" 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={() => void handleCreate()} onClick={() => void handleCreate()}
disabled={isSubmitting} disabled={isSubmitting}
> >
@ -196,7 +195,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</section> </section>
); );
/** Waehlt den Inhalt des aktuellen Schrittes. */ /** Wählt 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];
@ -228,15 +227,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 vorgetaeuschtes Funktionsversprechen * niemand auswertet, ist ein vorgetäuschtes Funktionsversprechen
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung * (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
* tatsaechlich geschieht. * tatsächlich geschieht.
*/ */
function renderUnavailableStep(explanationText: string): React.JSX.Element { function renderUnavailableStep(explanationText: string): React.JSX.Element {
return ( return (
<div className="wizard__unavailable"> <div className="rounded-md border border-line bg-sunken p-4">
<p className="wizard__unavailable-badge">Noch nicht verfuegbar</p> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfügbar</p>
<p className="wizard__unavailable-text">{explanationText}</p> <p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
</div> </div>
); );
} }
@ -244,23 +243,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="wizard__fields"> <div className="mt-4 space-y-4">
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Name des Auftrags</span> <span className="block text-sm font-medium text-fg">Name des Auftrags</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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="Naechtliche Sicherung Dateiserver" placeholder="Nächtliche Sicherung Dateiserver"
autoFocus autoFocus
/> />
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Beschreibung (optional)</span> <span className="block text-sm font-medium text-fg">Beschreibung (optional)</span>
<textarea <textarea
className="wizard__input wizard__input--area" 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"
value={jobDraft.description} value={jobDraft.description}
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
rows={3} rows={3}
@ -272,7 +271,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 2: Quellen. */ /** Schritt 2: Quellen. */
function renderSourceStep(): React.JSX.Element { function renderSourceStep(): React.JSX.Element {
/** Aendert eine einzelne Quelle. */ /** Ändert 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) =>
@ -282,15 +281,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
} }
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
{jobDraft.sources.map((draftSource, sourceIndex) => ( {jobDraft.sources.map((draftSource, sourceIndex) => (
<fieldset key={sourceIndex} className="wizard__source"> <fieldset key={sourceIndex} className="flex items-center justify-between gap-2 rounded-md border border-line px-3 py-2 text-sm">
<legend className="wizard__label">Quelle {sourceIndex + 1}</legend> <legend className="block text-sm font-medium text-fg">Quelle {sourceIndex + 1}</legend>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Art</span> <span className="block text-sm font-medium text-fg">Art</span>
<select <select
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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 })
@ -303,16 +302,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 verfuegbar)' : ''} {!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfügbar)' : ''}
</option> </option>
))} ))}
</select> </select>
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Pfad</span> <span className="block text-sm font-medium text-fg">Pfad</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text" type="text"
value={draftSource.sourceIdentifier} value={draftSource.sourceIdentifier}
onChange={(changeEvent) => onChange={(changeEvent) =>
@ -322,20 +321,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/> />
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Bezeichnung (optional)</span> <span className="block text-sm font-medium text-fg">Bezeichnung (optional)</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Auszuschliessen (kommasepariert, optional)</span> <span className="block text-sm font-medium text-fg">Auszuschließen (kommasepariert, optional)</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text" type="text"
value={draftSource.excludePatterns} value={draftSource.excludePatterns}
onChange={(changeEvent) => onChange={(changeEvent) =>
@ -348,7 +347,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{jobDraft.sources.length > 1 && ( {jobDraft.sources.length > 1 && (
<button <button
type="button" type="button"
className="wizard__button wizard__button--small" 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"
onClick={() => onClick={() =>
updateDraft({ updateDraft({
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex), sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
@ -363,7 +362,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<button <button
type="button" type="button"
className="wizard__button wizard__button--small" 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"
onClick={() => onClick={() =>
updateDraft({ updateDraft({
sources: [ sources: [
@ -382,31 +381,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="wizard__fields"> <div className="mt-4 space-y-4">
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Art des Zeitplans</span> <span className="block text-sm font-medium text-fg">Art des Zeitplans</span>
<select <select
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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 Abstaenden</option> <option value="interval">In festen Abständen</option>
<option value="hourly">Stuendlich</option> <option value="hourly">Stündlich</option>
<option value="daily">Taeglich</option> <option value="daily">Täglich</option>
<option value="weekly">Woechentlich</option> <option value="weekly">Wöchentlich</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="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Abstand in Stunden</span> <span className="block text-sm font-medium text-fg">Abstand in Stunden</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="number" type="number"
min={1} min={1}
value={jobDraft.scheduleIntervalHours} value={jobDraft.scheduleIntervalHours}
@ -419,12 +418,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="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label"> <span className="block text-sm font-medium text-fg">
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'} {jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
</span> </span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="time" type="time"
value={jobDraft.scheduleTime} value={jobDraft.scheduleTime}
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
@ -433,11 +432,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)} )}
{jobDraft.scheduleType === 'weekly' && ( {jobDraft.scheduleType === 'weekly' && (
<fieldset className="wizard__field"> <fieldset className="block space-y-1.5">
<legend className="wizard__label">Wochentage</legend> <legend className="block text-sm font-medium text-fg">Wochentage</legend>
<div className="wizard__checkbox-row"> <div className="flex flex-wrap gap-4">
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => ( {WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
<label key={weekdayName} className="wizard__checkbox"> <label key={weekdayName} className="flex items-center gap-2 text-sm text-fg">
<input <input
type="checkbox" type="checkbox"
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)} checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
@ -457,11 +456,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)} )}
{jobDraft.scheduleType === 'monthly' && ( {jobDraft.scheduleType === 'monthly' && (
<fieldset className="wizard__field"> <fieldset className="block space-y-1.5">
<legend className="wizard__label">Tage des Monats</legend> <legend className="block text-sm font-medium text-fg">Tage des Monats</legend>
<div className="wizard__checkbox-row"> <div className="flex flex-wrap gap-4">
{[1, 15, -1].map((monthDay) => ( {[1, 15, -1].map((monthDay) => (
<label key={monthDay} className="wizard__checkbox"> <label key={monthDay} className="flex items-center gap-2 text-sm text-fg">
<input <input
type="checkbox" type="checkbox"
checked={jobDraft.scheduleMonthDays.includes(monthDay)} checked={jobDraft.scheduleMonthDays.includes(monthDay)}
@ -477,46 +476,111 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label> </label>
))} ))}
</div> </div>
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in {/* Der Monatsletzte ist kein Luxus: Wer "am 31." wählt, bekommt in
vier Monaten des Jahres keine Sicherung. */} vier Monaten des Jahres keine Sicherung. */}
<p className="wizard__hint"> <p className="text-xs text-fg-muted">
Der letzte Tag des Monats faellt nie aus - anders als etwa der 31. Der letzte Tag des Monats fällt nie aus - anders als etwa der 31.
</p> </p>
</fieldset> </fieldset>
)} )}
{jobDraft.scheduleType === 'cron' && ( {jobDraft.scheduleType === 'cron' && (
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Cron-Ausdruck</span> <span className="block text-sm font-medium text-fg">Cron-Ausdruck</span>
<input <input
className="wizard__input wizard__input--mono" 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"
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="wizard__hint">Minute Stunde Tag Monat Wochentag</span> <span className="text-xs text-fg-muted">Minute Stunde Tag Monat Wochentag</span>
</label> </label>
)} )}
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && ( {jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Zeitzone</span> <span className="block text-sm font-medium text-fg">Zeitzone</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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="wizard__hint"> <span className="text-xs text-fg-muted">
Ohne Angabe rechnet der Server in UTC. Ohne Angabe rechnet der Server in UTC.
</span> </span>
</label> </label>
)} )}
<p className="wizard__preview">Ergibt: {describeDraftSchedule(jobDraft)}</p> <p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">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>
); );
} }
@ -524,22 +588,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="wizard__error">{repositoryError}</p>; return <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical">{repositoryError}</p>;
} }
if (repositories.length === 0) { if (repositories.length === 0) {
return ( return (
<p className="wizard__hint"> <p className="text-xs text-fg-muted">
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datenträger und
wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt. wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt.
</p> </p>
); );
} }
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
{repositories.map((repositoryEntry) => ( {repositories.map((repositoryEntry) => (
<label key={repositoryEntry.id} className="wizard__radio"> <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">
<input <input
type="radio" type="radio"
name="repository" name="repository"
@ -549,15 +613,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })} onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
/> />
<span className="wizard__radio-body"> <span className="flex min-w-0 flex-col">
<span className="wizard__radio-title">{repositoryEntry.name}</span> <span className="font-medium text-fg">{repositoryEntry.name}</span>
<span className="wizard__radio-detail">{repositoryEntry.location}</span> <span className="font-mono text-xs text-fg-muted">{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 waehlen kann. */} Repository, das er sieht und nicht wählen kann. */}
{!repositoryEntry.accepts_backups && ( {!repositoryEntry.accepts_backups && (
<span className="wizard__radio-warning"> <span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
</span> </span>
)} )}
@ -571,20 +635,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="wizard__fields"> <div className="mt-4 space-y-4">
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst {/* Verschlüsselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere ohne Schlüsselmaterial. Eine Schaltfläche zum Abschalten wäre
eine Einstellung, die es nicht gibt. */} eine Einstellung, die es nicht gibt. */}
<div className="wizard__notice"> <div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm">
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM <strong>Verschlüsselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und verschlüsselt, bevor sie das System verlassen. Der Schlüssel gehört zum Repository und
wird vom Dienst verwaltet. wird vom Dienst verwaltet.
</div> </div>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Dringlichkeit</span> <span className="block text-sm font-medium text-fg">Dringlichkeit</span>
<select <select
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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'] })
@ -595,21 +659,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="wizard__hint"> <span className="text-xs text-fg-muted">
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind. Entscheidet die Reihenfolge, wenn mehrere Aufträge gleichzeitig fällig sind.
</span> </span>
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Bandbreitengrenze (optional)</span> <span className="block text-sm font-medium text-fg">Bandbreitengrenze (optional)</span>
<input <input
className="wizard__input" className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
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="wizard__hint"> <span className="text-xs text-fg-muted">
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>
@ -617,14 +681,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
); );
} }
/** Schritt 9: Uebersicht. */ /** Schritt 9: Übersicht. */
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="wizard__summary"> <dl className="mt-4 space-y-3">
<dt>Name</dt> <dt>Name</dt>
<dd>{jobDraft.name}</dd> <dd>{jobDraft.name}</dd>
@ -637,14 +701,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dt>Quellen</dt> <dt>Quellen</dt>
<dd> <dd>
<ul className="wizard__summary-list"> <ul className="space-y-2">
{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="wizard__summary-detail"> <span className="text-sm text-fg">
{' '} {' '}
ohne {splitPatternList(draftSource.excludePatterns).join(', ')} ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
</span> </span>
@ -661,19 +725,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="wizard__summary-unavailable">unbegrenzt (Regeln noch nicht umgesetzt)</dd> <dd className="text-sm text-fg-muted">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
<dt>Sicherheit</dt> <dt>Sicherheit</dt>
<dd> <dd>
verschluesselt, Dringlichkeit {jobDraft.priority} verschlüsselt, Dringlichkeit {jobDraft.priority}
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`} {jobDraft.bandwidthLimit.trim() !== '' && `, höchstens ${jobDraft.bandwidthLimit}`}
</dd> </dd>
<dt>Pruefung</dt> <dt>Prüfung</dt>
<dd className="wizard__summary-unavailable">keine automatische Pruefung</dd> <dd className="text-sm text-fg-muted">keine automatische Prüfung</dd>
<dt>Benachrichtigung</dt> <dt>Benachrichtigung</dt>
<dd className="wizard__summary-unavailable">keine</dd> <dd className="text-sm text-fg-muted">keine</dd>
</dl> </dl>
); );
} }
@ -681,14 +745,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="wizard__fields"> <div className="mt-4 space-y-4">
<p> <p>
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine Der Auftrag wird angelegt und läuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat. vollständige Sicherung; jeder weitere liest nur, was sich geändert hat.
</p> </p>
{submitError !== null && ( {submitError !== null && (
<p className="wizard__error" role="alert"> <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{submitError} {submitError}
</p> </p>
)} )}
@ -697,20 +761,27 @@ 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 'wizard__step wizard__step--current'; return `${baseClasses} bg-accent-subtle font-medium text-accent`;
} }
if (stepIndex < currentStepIndex) { if (stepIndex < currentStepIndex) {
return 'wizard__step wizard__step--done'; return `${baseClasses} text-healthy`;
} }
return 'wizard__step'; return `${baseClasses} text-fg-subtle`;
} }
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */ /** Wandelt einen Fehler in eine verständliche 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;

View File

@ -0,0 +1,464 @@
/**
* 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>
);
}

View File

@ -0,0 +1,363 @@
/**
* 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>
</>
);
}

View File

@ -1,108 +0,0 @@
/**
* 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);
}

View File

@ -1,173 +0,0 @@
/**
* 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>
);
}

View File

@ -1,10 +1,10 @@
/** /**
* API-Anbindung der Sicherungsauftraege (SYNCOVA_API.md §9). * API-Anbindung der Sicherungsaufträge (SYNCOVA_API.md §9).
* *
* Das Modul kennt nur den Vertrag nach aussen. Die Gestalt der Anfrage folgt * Das Modul kennt nur den Vertrag nach außen. Die Gestalt der Anfrage folgt
* dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen * dem Backend und nicht der Oberfläche: Der Wizard führt seinen eigenen
* Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede * Entwurf und übersetzt ihn erst beim Anlegen. Andernfalls müsste jede
* Aenderung an der API sofort die Maske umbauen. * Änderung 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;
/** Einzuschliessende Muster. */ /** Einzuschließende Muster. */
include_patterns?: string[]; include_patterns?: string[];
/** Auszuschliessende Muster. */ /** Auszuschließende 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;
/** Erlaeuterung des Zwecks. */ /** Erläuterung des Zwecks. */
description?: string; description?: string;
/** Dringlichkeit. */ /** Dringlichkeit. */
priority?: JobPriority; priority?: JobPriority;
@ -76,21 +76,48 @@ export interface CreateJobRequest {
sources: SourceDescriptor[]; sources: SourceDescriptor[];
/** Ziel-Repository. */ /** Ziel-Repository. */
repository_id: string; repository_id: string;
/** Zulaessiger Datenverlust in Sekunden. */ /** Zulässiger Datenverlust in Sekunden. */
rpo_seconds?: number; rpo_seconds?: number;
/** Zulaessige Wiederherstellungsdauer in Sekunden. */ /** Zulässige 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 {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
id: string; id: string;
/** Bezeichnung. */ /** Bezeichnung. */
name: string; name: string;
/** Erlaeuterung. */ /** Erläuterung. */
description?: string; description?: string;
/** Zustand. */ /** Zustand. */
status: string; status: string;
@ -98,13 +125,13 @@ export interface BackupJob {
priority: JobPriority; priority: JobPriority;
/** Zeitplan. */ /** Zeitplan. */
schedule: ScheduleDescriptor; schedule: ScheduleDescriptor;
/** Erklaerung des Zeitplans in einem Satz. */ /** Erklärung 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;
/** Naechster Zeitpunkt in UTC. */ /** Nächster 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;
@ -112,11 +139,15 @@ 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 {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
id: string; id: string;
/** Sprechende Bezeichnung. */ /** Sprechende Bezeichnung. */
name: string; name: string;
@ -129,26 +160,26 @@ export interface BackupRepository {
/** /**
* Meldet, ob dieses Ziel Sicherungen annimmt. * Meldet, ob dieses Ziel Sicherungen annimmt.
* *
* Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche * Die Auskunft kommt vom Server. Die Oberfläche müsste sonst wissen, welche
* Zustaende schreibend sind - eine Regel, die dort nicht hingehoert. * Zustände schreibend sind - eine Regel, die dort nicht hingehört.
*/ */
accepts_backups: boolean; accepts_backups: boolean;
/** Meldet den gehaerteten Modus. */ /** Meldet den gehärteten Modus. */
hardened: boolean; hardened: boolean;
} }
/** /**
* Laedt die bekannten Sicherungsziele. * Lädt die bekannten Sicherungsziele.
* *
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei * Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
* exactOptionalPropertyTypes ist ein ausdrueckliches undefined etwas anderes * exactOptionalPropertyTypes ist ein ausdrückliches 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 } : {});
} }
/** Laedt die vorhandenen Sicherungsauftraege. */ /** Lädt die vorhandenen Sicherungsaufträge. */
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 } : {});
} }
@ -157,3 +188,115 @@ 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',
});
}

View File

@ -1,7 +1,7 @@
/** /**
* Tests des Wizard-Modells. * Tests des Wizard-Modells.
* *
* Geprueft wird die Logik ohne gerenderte Maske: welcher Schritt vollstaendig * Geprüft wird die Logik ohne gerenderte Maske: welcher Schritt vollständig
* 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 vollstaendig ausgefuellten Entwurf. */ /** Liefert einen vollständig ausgefüllten Entwurf. */
function buildCompleteDraft(): JobDraft { function buildCompleteDraft(): JobDraft {
return { return {
...createEmptyDraft(), ...createEmptyDraft(),
name: 'Naechtliche Sicherung', name: 'Nächtliche 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('Pruefung der Schritte', () => { describe('Prüfung der Schritte', () => {
it('nimmt einen vollstaendigen Entwurf an', () => { it('nimmt einen vollständigen Entwurf an', () => {
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull(); expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
}); });
@ -56,7 +56,7 @@ describe('Pruefung 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 gefaehrlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht. // die gefährlichste 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('Pruefung der Schritte', () => {
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true); expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true);
}); });
it('weist eine unmoegliche Uhrzeit ab', () => { it('weist eine unmögliche 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 fuenf Felder in einem Cron-Ausdruck', () => { it('verlangt fünf Felder in einem Cron-Ausdruck', () => {
const cronDraft: JobDraft = { const cronDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
scheduleType: 'cron', scheduleType: 'cron',
@ -128,9 +128,9 @@ describe('Pruefung der Schritte', () => {
expect(validateStep('schedule', cronDraft).isComplete).toBe(true); expect(validateStep('schedule', cronDraft).isComplete).toBe(true);
}); });
// Aufbewahrung, Pruefung und Benachrichtigung fragen nichts ab und duerfen // Aufbewahrung, Prüfung und Benachrichtigung fragen nichts ab und dürfen
// den Anwender deshalb auch nicht aufhalten. // den Anwender deshalb auch nicht aufhalten.
it('haelt bei den nicht umgesetzten Schritten nicht auf', () => { it('hält 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('Pruefung 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('haelt Bit und Byte auseinander', () => { it('hält 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 gebraeuchlichen Schreibweisen an', () => { it('nimmt die gebräuchlichen 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('uebertraegt Name, Quelle, Zeitplan und Ziel', () => { it('überträgt Name, Quelle, Zeitplan und Ziel', () => {
const jobRequest = buildCreateRequest(buildCompleteDraft()); const jobRequest = buildCreateRequest(buildCompleteDraft());
expect(jobRequest.name).toBe('Naechtliche Sicherung'); expect(jobRequest.name).toBe('Nächtliche 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 Zeitplaenen mit Uhrzeit', () => { it('sendet die Zeitzone bei Zeitplänen 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 zaehlt Abstaende, keine Uhrzeiten. Eine Zeitzone waere // Ein Intervallplan zählt Abstände, keine Uhrzeiten. Eine Zeitzone wäre
// dort bedeutungslos. // dort bedeutungslos.
it('laesst die Zeitzone bei einem Intervallplan weg', () => { it('lässt 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 ueberschriebe auf dem Server einen sinnvollen // Ein leeres Feld mitzusenden überschriebe auf dem Server einen sinnvollen
// Standard mit einem Leerwert. // Standard mit einem Leerwert.
it('laesst leere Felder weg', () => { it('lässt 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('uebertraegt die Bandbreitengrenze in Byte je Sekunde', () => { it('überträgt 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('uebergeht leere Quellzeilen', () => { it('übergeht 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 Zeitplaene verstaendlich', () => { it('beschreibt die Zeitpläne verständlich', () => {
const dailyDraft = buildCompleteDraft(); const dailyDraft = buildCompleteDraft();
expect(describeDraftSchedule(dailyDraft)).toBe('taeglich um 02:00 Uhr (Europe/Berlin)'); expect(describeDraftSchedule(dailyDraft)).toBe('täglich um 02:00 Uhr (Europe/Berlin)');
expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung'); expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung');

View File

@ -1,9 +1,9 @@
/** /**
* Tests des Liniendiagramms. * Tests des Liniendiagramms.
* *
* Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben * Sie prüfen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
* wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.** * wurde: **Eine Lücke wird nicht überbrückt und nicht auf null gezogen.**
* Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben. * Beides ergäbe 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 uebergebenen Punkten. */ /** Baut eine Reihe aus den übergebenen 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 Linienzuege aus dem Dokument. */ /** Liest alle gezeichneten Linienzüge 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 Luecke, statt sie zu ueberbruecken', () => { it('unterbricht die Linie an einer Lücke, statt sie zu überbrücken', () => {
// 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 Linienzuege — nicht einer, der die Luecke ueberspringt. // Zwei getrennte Linienzüge — nicht einer, der die Lücke überspringt.
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 waere zwar optisch gleich, liesse sich // Linienzug mit zwei M-Befehlen wäre zwar optisch gleich, ließe 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 Luecke nicht auf den Nullpunkt', () => { it('zieht eine Lücke 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
// Zeichenflaeche. Taucht er auf, wurde die Luecke als Null gezeichnet — und // Zeichenfläche. Taucht er auf, wurde die Lücke 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 ergaebe eine Linie der Laenge null und waere // Eine einzelne Messung ergäbe eine Linie der Länge null und wäre
// unsichtbar — die Kurve saehe aus wie „nichts gemessen". // unsichtbar — die Kurve sähe 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 saehe aus wie eine Kurve auf null. // Kein leeres Achsenkreuz: Das sähe 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 laesst kleine Schwankungen wie Einbrueche // Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche
// aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu // aussehen — der häufigste 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 laeuft von null bis zum groessten Wert der Reihe. // Die Achse läuft von null bis zum größten Wert der Reihe.
expect(screen.getByText('0')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('102')).toBeInTheDocument(); expect(screen.getByText('102')).toBeInTheDocument();
}); });

View File

@ -1,29 +1,29 @@
/** /**
* Liniendiagramm als SVG, ohne Diagrammbibliothek. * Liniendiagramm als SVG, ohne Diagrammbibliothek.
* *
* Warum keine? Die gaengigen Bibliotheken bringen mehr Code mit, als die ganze * Warum keine? Die gängigen Bibliotheken bringen mehr Code mit, als die ganze
* Oberflaeche heute hat — und die eine Eigenschaft, auf die es hier ankommt, * Oberfläche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
* beherrschen sie standardmaessig falsch: **Sie zeichnen Luecken als Nullen.** * beherrschen sie standardmäßig falsch: **Sie zeichnen Lücken 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 faellt, laesst eine Anlage aussehen, als waere ihre * dort auf den Nullpunkt fällt, lässt eine Anlage aussehen, als wäre 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 Zeichenflaeche. */ /** Abmessungen der Zeichenfläche. */
const CHART_VIEWBOX_WIDTH = 720; const CHART_VIEWBOX_WIDTH = 720;
/** Hoehe der Zeichenflaeche. */ /** Höhe der Zeichenfläche. */
const CHART_VIEWBOX_HEIGHT = 220; const CHART_VIEWBOX_HEIGHT = 220;
/** Innenabstand links fuer die Beschriftung der Werteachse. */ /** Innenabstand links für 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 fuer die Zeitachse. */ /** Innenabstand unten für 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 Flaeche mit // Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Fläche 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 Flaeche bedeutet „keine Daten", nicht In diesem Zeitraum wurde nichts gemessen. Die leere Fläche bedeutet „keine Daten", nicht
„Wert null". „Wert null".
</p> </p>
); );
} }
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiss das nach // Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiß das nach
// der Rueckgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen. // der Rückgabe 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 laesst // Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse lässt
// kleine Schwankungen wie Einbrueche aussehen — der haeufigste Weg, mit // kleine Schwankungen wie Einbrüche aussehen — der häufigste 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 {
/** Groesster Wert der Reihen. */ /** Größter Wert der Reihen. */
readonly maximumValue: number; readonly maximumValue: number;
/** Einheit der Werte. */ /** Einheit der Werte. */
readonly unit: SeriesUnit; readonly unit: SeriesUnit;
/** Breite der Zeichenflaeche. */ /** Breite der Zeichenfläche. */
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 ergaebe eine Linie der Laenge {/* Ein einzelner Messpunkt ohne Nachbarn ergäbe eine Linie der Länge
null und waere unsichtbar. Er wird deshalb als Punkt gezeichnet. */} null und wäre 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 zusammenhaengende Folge von Punkten mit Wert wird ein eigener Abschnitt. * Jede zusammenhängende Folge von Punkten mit Wert wird ein eigener Abschnitt.
* Eine Luecke unterbricht die Linie — sie wird nicht ueberbrueckt und nicht auf * Eine Lücke unterbricht die Linie — sie wird nicht überbrückt 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 groessten Wert aller Reihen. */ /** Sucht den größten 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 unmoeglich. Eine Reihe aus lauter // Ein Maximum von null macht die Division unmöglich. Eine Reihe aus lauter
// Nullen ist ein gueltiger Fall — etwa null Fehlschlaege. // Nullen ist ein gültiger Fall — etwa null Fehlschläge.
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 Groesseneinheiten. */ /** Faktor zwischen zwei Größeneinheiten. */
const BYTE_UNIT_STEP = 1024; const BYTE_UNIT_STEP = 1024;
/** Schreibt einen Achsenwert lesbar. */ /** Schreibt einen Achsenwert lesbar. */

View File

@ -1,20 +1,22 @@
/** /**
* Kennzahlen und Diagramme. * Kennzahlen und Diagramme.
* *
* Alle zwoelf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage * Alle zwölf 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 Uebersicht: Ein weggelassenes Diagramm sieht aus wie * Dieselbe Regel wie in der Übersicht: 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 { ErrorState, LoadingState } from '../../components/PageState'; import { describeApiError } from '@/api/useMutation';
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';
/** Auswaehlbare Zeitraeume mit ihrer Beschriftung. */ /** Auswählbare Zeiträume 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' },
@ -32,22 +34,18 @@ 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"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Kennzahlen</h1> title="Kennzahlen"
description={
{data !== null && ( data !== null
<span className="page__meta"> ? `${data.available_count} von ${data.charts.length} Diagrammen haben eine Datengrundlage.`
{data.available_count} von {data.charts.length} Diagrammen haben eine Datengrundlage : undefined
</span> }
)} actions={
</header> <SelectInput
aria-label="Zeitraum"
<div className="filter-bar"> className="w-52"
<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)}
> >
@ -56,17 +54,28 @@ export function MetricsPage(): React.JSX.Element {
{rangeOption.label} {rangeOption.label}
</option> </option>
))} ))}
</select> </SelectInput>
</label> }
</div> />
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />} {loadState === 'loading' ? (
{loadState === 'failed' && loadError !== null && ( <Card>
<ErrorState error={loadError} onRetry={reload} /> <LoadingState label="Die Kennzahlen werden geladen …" />
)} </Card>
) : null}
{loadState === 'loaded' && data !== null && ( {loadState === 'failed' && loadError !== null ? (
<div className="chart-list"> <Card>
<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}
@ -75,8 +84,8 @@ export function MetricsPage(): React.JSX.Element {
/> />
))} ))}
</div> </div>
)} ) : null}
</section> </>
); );
} }
@ -84,7 +93,7 @@ export function MetricsPage(): React.JSX.Element {
interface ChartCardProperties { interface ChartCardProperties {
/** Das darzustellende Diagramm. */ /** Das darzustellende Diagramm. */
readonly definition: ChartDefinition; readonly definition: ChartDefinition;
/** Der gewaehlte Zeitraum. */ /** Der gewählte Zeitraum. */
readonly timeRange: string; readonly timeRange: string;
} }
@ -96,8 +105,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 Oberflaeche saehe aus wie eine // antwortete mit 501, und ein Fehler in der Oberfläche sähe aus wie eine
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht. // Störung. 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',
@ -105,35 +114,41 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
if (!definition.available) { if (!definition.available) {
return ( return (
<article className="chart-card chart-card--unavailable"> <Card>
<h2 className="chart-card__title">{definition.title}</h2> <CardHeader title={definition.title} description="noch nicht verfügbar" />
<p className="chart-card__badge">noch nicht verfuegbar</p> <CardContent>
<p className="chart-card__description">{definition.unavailable_reason}</p> <p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</article> </CardContent>
</Card>
); );
} }
return ( return (
<article className="chart-card"> <Card>
<h2 className="chart-card__title">{definition.title}</h2> <CardHeader title={definition.title} description={definition.description} />
<p className="chart-card__description">{definition.description}</p> <CardContent>
{loadState === 'loading' ? <LoadingState label="Die Reihe wird geladen …" /> : null}
{loadState === 'loading' && <LoadingState what="Die Reihe" />} {loadState === 'failed' && loadError !== null ? (
{loadState === 'failed' && loadError !== null && ( <ErrorState
<ErrorState error={loadError} onRetry={reload} /> message={describeApiError(loadError)}
)} 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="chart-card__note">{data.note}</p> <p className="mt-2 text-xs text-fg-muted">{data.note}</p>
)} ) : null}
</> </>
)} ) : null}
</article> </CardContent>
</Card>
); );
} }

View File

@ -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;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
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;
/** Erklaert eine fehlende Datengrundlage. */ /** Erklärt 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;
/** Waehlbare Zeitraeume. */ /** Wählbare Zeiträume. */
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 gueltig, wenn has_value gesetzt ist. */ /** Wert; nur gültig, 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 vollstaendiges Diagramm. */ /** Ein vollständiges Diagramm. */
export interface Chart { export interface Chart {
/** Bezeichner. */ /** Bezeichner. */
readonly metric: string; readonly metric: string;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
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;
} }
/** Laedt den Katalog aller Diagramme. */ /** Lädt 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 } : {});
} }
/** Laedt ein Diagramm fuer den angegebenen Zeitraum. */ /** Lädt ein Diagramm für den angegebenen Zeitraum. */
export async function fetchChart( export async function fetchChart(
metricName: string, metricName: string,
timeRange: string, timeRange: string,

View File

@ -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 verlaesst die * Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlässt die
* Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort * Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort
* ueberlebt eine erfundene Null jede muendliche Erlaeuterung. * überlebt eine erfundene Null jede mündliche Erläuterung.
*/ */
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 Standardhuelle. */ /** Baut eine Antwort in der Standardhülle. */
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 Sicherungslaeufe eines Tages.', description: 'Alle Sicherungsläufe 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-Kapazitaet', title: 'Bericht Repository-Kapazität',
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 Sicherungslaeufe eines Tages.', description: 'Alle Sicherungsläufe 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: 'pruefer', generated_by: 'prüfer',
sections: [ sections: [
{ {
title: 'Ueberblick', title: 'Überblick',
metrics: [ metrics: [
{ label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true }, { label: 'Läufe 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 gewaehlten Zeitraum wurde kein Lauf abgeschlossen.', unknown_reason: 'Im gewählten Zeitraum wurde kein Lauf abgeschlossen.',
}, },
], ],
tables: [ tables: [
{ {
title: 'Einzelne Laeufe', title: 'Einzelne Läufe',
columns: ['Auftrag', 'Ergebnis'], columns: ['Auftrag', 'Ergebnis'],
rows: [], rows: [],
empty_notice: 'Im gewaehlten Zeitraum wurde kein Lauf begonnen.', empty_notice: 'Im gewählten Zeitraum wurde kein Lauf begonnen.',
}, },
], ],
}, },
], ],
notes: ['Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'], notes: ['Im gewählten 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-Kapazitaet')).toBeInTheDocument(); expect(screen.getByText('Bericht Repository-Kapazität')).toBeInTheDocument();
// Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er // Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er
// nicht auswertet, waere ein Versprechen, das er nicht einloest. // nicht auswertet, wäre ein Versprechen, das er nicht einlöst.
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 Begruendung. // Die ungemessene Quote steht als „nicht gemessen" da — mit Begründung.
expect(screen.getByText('nicht gemessen')).toBeInTheDocument(); expect(screen.getByText('nicht gemessen')).toBeInTheDocument();
expect( expect(
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.', { screen.getByText('Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'),
selector: '.reports__metric-reason',
}),
).toBeInTheDocument(); ).toBeInTheDocument();
// Die gemessene Null bleibt dagegen sichtbar: „null Laeufe" ist eine // Die gemessene Null bleibt dagegen sichtbar: „null Läufe" ist eine
// Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden. // Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden.
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling; // Die gemessene Null steht als Zahl da und trägt **keine** Begründung —
// 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 Laeufe')).toBeInTheDocument(); expect(screen.getByText('Einzelne Läufe')).toBeInTheDocument();
}); });
expect( expect(
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'), screen.getByText('Im gewählten Zeitraum wurde kein Lauf begonnen.'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });

View File

@ -1,9 +1,9 @@
/** /**
* Berichte. * Berichte.
* *
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand. * Die Seite, die in Phase 12 als „noch nicht verfügbar" im Menü stand.
* *
* Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske: * Zwei Dinge unterscheidet sie von einer gewöhnlichen 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,7 +16,18 @@
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 { ErrorState, LoadingState } from '../../components/PageState'; import { PageHeader } from '@/components/layout/PageHeader';
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';
@ -30,13 +41,13 @@ interface ReportCatalogEntry {
readonly type: string; readonly type: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklaert, welche Frage der Bericht beantwortet. */ /** Erklärt, 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;
/** Verfuegbare Ausgabeformate. */ /** Verfügbare Ausgabeformate. */
readonly formats: readonly ReportFormat[]; readonly formats: readonly ReportFormat[];
} }
@ -44,7 +55,7 @@ interface ReportCatalogEntry {
interface ReportMetric { interface ReportMetric {
/** Beschriftung. */ /** Beschriftung. */
readonly label: string; readonly label: string;
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */ /** Gemessener Wert; nur gültig, wenn is_known gilt. */
readonly value?: number; readonly value?: number;
/** Einheit. */ /** Einheit. */
readonly unit?: string; readonly unit?: string;
@ -52,13 +63,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;
/** Erklaert einen fehlenden Wert. */ /** Erklärt einen fehlenden Wert. */
readonly unknown_reason?: string; readonly unknown_reason?: string;
} }
/** Eine Tabelle eines Berichts. */ /** Eine Tabelle eines Berichts. */
interface ReportTable { interface ReportTable {
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Spaltenbeschriftungen. */ /** Spaltenbeschriftungen. */
readonly columns: readonly string[]; readonly columns: readonly string[];
@ -70,9 +81,9 @@ interface ReportTable {
/** Ein Abschnitt eines Berichts. */ /** Ein Abschnitt eines Berichts. */
interface ReportSection { interface ReportSection {
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description?: string; readonly description?: string;
/** Kennzahlen. */ /** Kennzahlen. */
readonly metrics?: readonly ReportMetric[]; readonly metrics?: readonly ReportMetric[];
@ -86,7 +97,7 @@ interface GeneratedReport {
readonly type: string; readonly type: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description: string; readonly description: string;
/** Beginn des Zeitraums. */ /** Beginn des Zeitraums. */
readonly period_from: string; readonly period_from: string;
@ -98,19 +109,19 @@ interface GeneratedReport {
readonly generated_by?: string; readonly generated_by?: string;
/** Abschnitte. */ /** Abschnitte. */
readonly sections: readonly ReportSection[]; readonly sections: readonly ReportSection[];
/** Benannte Luecken und Einschraenkungen. */ /** Benannte Lücken und Einschränkungen. */
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 laeuft. */ /** Format, das gerade erzeugt wird; null, wenn nichts läuft. */
readonly runningFormat: ReportFormat | null; readonly runningFormat: ReportFormat | null;
/** Fehler der letzten Ausgabe. */ /** Fehler der letzten Ausgabe. */
readonly exportError: ApiError | null; readonly exportError: ApiError | null;
} }
/** Laedt den Berichtskatalog. */ /** Lädt 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 });
} }
@ -182,44 +193,57 @@ export function ReportsPage(): React.JSX.Element {
); );
if (catalogResource.loadState === 'loading') { if (catalogResource.loadState === 'loading') {
return <LoadingState what="die Berichte" />; return (
<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 Verfuegung. // blieb — dann steht der Katalog trotzdem nicht zur Verfügung.
return catalogResource.loadError === null ? ( return (
<p className="page__state">Der Berichtskatalog steht nicht zur Verfügung.</p> <Card>
) : ( <ErrorState
<ErrorState error={catalogResource.loadError} onRetry={catalogResource.reload} /> title="Der Berichtskatalog steht nicht zur Verfügung."
message={
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
}
requestId={catalogResource.loadError?.requestId}
onRetry={catalogResource.reload}
/>
</Card>
); );
} }
return ( return (
<section className="page"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Berichte</h1> title="Berichte"
<p className="page__subtitle"> 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."
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="reports__catalog"> <div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{catalogResource.data.map((catalogEntry) => ( {catalogResource.data.map((catalogEntry) => (
<button <button
key={catalogEntry.type} key={catalogEntry.type}
type="button" type="button"
className={
catalogEntry.type === selectedType
? 'reports__entry reports__entry--selected'
: 'reports__entry'
}
onClick={() => selectReport(catalogEntry.type)} onClick={() => selectReport(catalogEntry.type)}
className={cn(
'rounded-lg border p-4 text-left transition-colors',
catalogEntry.type === selectedType
? 'border-accent bg-accent-subtle'
: 'border-line bg-card hover:bg-hover',
)}
> >
<span className="reports__entry-title">{catalogEntry.title}</span> <span className="block font-medium text-fg">{catalogEntry.title}</span>
<span className="reports__entry-description">{catalogEntry.description}</span> <span className="mt-1 block text-sm text-fg-muted">
<span className="reports__entry-period"> {catalogEntry.description}
</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'}`}
@ -228,19 +252,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 Ausgabemoeglichkeiten. */ /** Zeigt einen erzeugten Bericht mit seinen Ausgabemöglichkeiten. */
function ReportPanel(properties: { function ReportPanel(properties: {
readonly catalogEntry: ReportCatalogEntry; readonly catalogEntry: ReportCatalogEntry;
readonly previewReport: GeneratedReport | null; readonly previewReport: GeneratedReport | null;
@ -250,44 +274,47 @@ function ReportPanel(properties: {
const { catalogEntry, previewReport, exportState, onExport } = properties; const { catalogEntry, previewReport, exportState, onExport } = properties;
return ( return (
<article className="reports__panel"> <Card>
<header className="reports__panel-header"> <CardHeader
<h2 className="reports__panel-title">{catalogEntry.title}</h2> title={catalogEntry.title}
<div className="reports__actions"> action={
<button <div className="flex gap-2">
type="button" <Button
className="button button--secondary" size="sm"
disabled={exportState.runningFormat !== null} isLoading={exportState.runningFormat === 'csv'}
onClick={() => onExport('csv')} disabled={exportState.runningFormat !== null}
> onClick={() => onExport('csv')}
{exportState.runningFormat === 'csv' ? 'CSV wird erzeugt …' : 'CSV herunterladen'} >
</button> CSV herunterladen
<button </Button>
type="button" <Button
className="button button--secondary" size="sm"
disabled={exportState.runningFormat !== null} isLoading={exportState.runningFormat === 'pdf'}
onClick={() => onExport('pdf')} disabled={exportState.runningFormat !== null}
> onClick={() => onExport('pdf')}
{exportState.runningFormat === 'pdf' ? 'PDF wird erzeugt …' : 'PDF herunterladen'} >
</button> PDF herunterladen
</div> </Button>
</header> </div>
}
/>
<CardContent>
{exportState.exportError !== null ? (
<Callout tone="critical" role="alert" className="mb-4">
<p>{describeApiError(exportState.exportError)}</p>
<p className="mt-1 font-mono text-xs">
Vorgangsnummer: {exportState.exportError.requestId}
</p>
</Callout>
) : null}
{exportState.exportError !== null && ( {previewReport === null ? (
<p className="reports__error" role="alert"> <LoadingState label="Der Bericht wird erzeugt …" />
{exportState.exportError.message} ) : (
<span className="reports__request-id"> <ReportPreview report={previewReport} />
Vorgang {exportState.exportError.requestId} )}
</span> </CardContent>
</p> </Card>
)}
{previewReport === null ? (
<LoadingState what="den Bericht" />
) : (
<ReportPreview report={previewReport} />
)}
</article>
); );
} }
@ -300,44 +327,56 @@ 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="reports__preview"> <div className="space-y-5">
<p className="reports__meta"> <p className="text-xs text-fg-muted">
{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 !== '' && ` von ${report.generated_by}`} {report.generated_by !== undefined && report.generated_by !== ''
? ` von ${report.generated_by}`
: ''}
</p> </p>
{report.sections.map((reportSection) => ( {report.sections.map((reportSection) => (
<section key={reportSection.title} className="reports__section"> <section key={reportSection.title}>
<h3 className="reports__section-title">{reportSection.title}</h3> <h3 className="text-sm font-semibold text-fg">{reportSection.title}</h3>
{reportSection.description !== undefined && ( {reportSection.description !== undefined ? (
<p className="reports__section-description">{reportSection.description}</p> <p className="mt-0.5 text-sm text-fg-muted">{reportSection.description}</p>
)} ) : null}
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 && ( {reportSection.metrics !== undefined && reportSection.metrics.length > 0 ? (
<dl className="reports__metrics"> <dl className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{reportSection.metrics.map((reportMetric) => ( {reportSection.metrics.map((reportMetric) => (
<div key={reportMetric.label} className="reports__metric"> <div
<dt className="reports__metric-label">{reportMetric.label}</dt> key={reportMetric.label}
<dd className="rounded-md border border-line bg-sunken p-3"
className={ >
reportMetric.is_known <dt className="text-xs text-fg-subtle">{reportMetric.label}</dt>
? 'reports__metric-value' <dd className="mt-1">
: 'reports__metric-value reports__metric-value--unknown' {/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
} Eine erfundene Null überlebt in einem Prüfordner jede
> mündliche Erläuterung. */}
{formatMetricValue(reportMetric)} <span
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined && ( className={
<span className="reports__metric-reason">{reportMetric.unknown_reason}</span> reportMetric.is_known
)} ? 'text-lg font-semibold tabular text-fg'
: 'text-sm text-fg-muted'
}
>
{formatMetricValue(reportMetric)}
</span>
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined ? (
<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} />
@ -345,16 +384,18 @@ 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="reports__notes"> <section className="border-t border-line pt-4">
<h3 className="reports__section-title">Hinweise</h3> <h3 className="text-sm font-semibold text-fg">Hinweise</h3>
<ul className="reports__note-list"> <ul className="mt-2 space-y-1">
{report.notes.map((note) => ( {report.notes.map((note) => (
<li key={note}>{note}</li> <li key={note} className="text-sm text-fg-muted">
• {note}
</li>
))} ))}
</ul> </ul>
</section> </section>
)} ) : null}
</div> </div>
); );
} }
@ -368,9 +409,11 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
if (table.rows.length === 0) { if (table.rows.length === 0) {
return ( return (
<div className="reports__table"> <div className="mt-4">
<h4 className="reports__table-title">{table.title}</h4> <h4 className="text-sm font-medium text-fg">{table.title}</h4>
<p className="reports__empty">{table.empty_notice ?? 'Keine Einträge.'}</p> <p className="mt-1 text-sm text-fg-muted">
{table.empty_notice ?? 'Keine Einträge.'}
</p>
</div> </div>
); );
} }
@ -378,28 +421,38 @@ 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="reports__table"> <div className="mt-4">
<h4 className="reports__table-title">{table.title}</h4> <h4 className="text-sm font-medium text-fg">{table.title}</h4>
<div className="reports__table-scroll">
<table className="data-table"> {/* Breite Tabellen laufen in ihrem eigenen Rahmen, nicht auf der Seite. */}
<div className="mt-2 overflow-x-auto rounded-md border border-line">
<table className="w-full border-collapse text-sm">
<thead> <thead>
<tr> <tr className="border-b border-line">
{table.columns.map((columnLabel) => ( {table.columns.map((columnLabel) => (
<th key={columnLabel}>{columnLabel}</th> <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 Schluessel: Eine Berichtszeile traegt keine Die Position ist der Schlüssel: Eine Berichtszeile trägt keine
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein. Kennung, und zwei Zeilen können Zeichen für Zeichen gleich sein.
Unbedenklich, weil die Tabelle statisch ist — sie wird weder Unbedenklich, weil die Tabelle statisch ist — sie wird weder
umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut. umsortiert noch ergänzt, sondern mit dem Bericht neu aufgebaut.
*/} */}
<tbody> <tbody>
{visibleRows.map((tableRow, rowIndex) => ( {visibleRows.map((tableRow, rowIndex) => (
<tr key={rowIndex}> <tr key={rowIndex} className="border-b border-line/60 last:border-0">
{tableRow.map((cellValue, cellIndex) => ( {tableRow.map((cellValue, cellIndex) => (
<td key={cellIndex}>{cellValue}</td> <td key={cellIndex} className="px-3 py-2 align-top text-fg">
{cellValue}
</td>
))} ))}
</tr> </tr>
))} ))}
@ -407,7 +460,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="reports__truncation"> <p className="mt-1.5 text-xs text-fg-muted">
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>
@ -486,7 +539,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 Uebersicht). * liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Übersicht).
*/ */
function formatPercent(percentValue: number): string { function formatPercent(percentValue: number): string {
if (percentValue > 0 && percentValue < 0.1) { if (percentValue > 0 && percentValue < 0.1) {
@ -502,11 +555,11 @@ function formatTimestamp(isoTimestamp: string): string {
} }
/** /**
* Loest den Browser-Download einer Datei aus. * Löst den Browser-Download einer Datei aus.
* *
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser * Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das hält 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 ueber eine grosse Anlage sind das schnell einige Megabyte * einem Monatsbericht über eine große Anlage sind das schnell einige Megabyte
* je Klick. * je Klick.
*/ */
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void { function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {

View File

@ -0,0 +1,509 @@
/**
* 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>
);
}

View File

@ -0,0 +1,94 @@
/**
* 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();
});
});

View File

@ -0,0 +1,491 @@
/**
* 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>
</>
);
}

View File

@ -0,0 +1,342 @@
/**
* 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,
});
}

View File

@ -0,0 +1,189 @@
/**
* 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>
);
}

View File

@ -0,0 +1,220 @@
/**
* 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>
);
}

View File

@ -0,0 +1,531 @@
/**
* 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>
);
}

View File

@ -0,0 +1,325 @@
/**
* 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>
);
}

View File

@ -0,0 +1,82 @@
/**
* 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 } : {},
);
}

View File

@ -0,0 +1,182 @@
/**
* 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 },
);
}

View File

@ -1,48 +1,62 @@
/** /**
* Security Center. * Security Center.
* *
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" stand — mit dem * Die Seite, die in Phase 12 als „noch nicht verfügbar" 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 traegt * Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist * Schweregrad, Erklärung, 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 { ErrorState, LoadingState } from '../../components/PageState'; import { describeApiError } from '@/api/useMutation';
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 {
/** Pruefbereich. */ /** Prüfbereich. */
readonly area: string; readonly area: string;
/** Schweregrad. */ /** Schweregrad. */
readonly severity: FindingSeverity; readonly severity: FindingSeverity;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung, warum der Zustand ein Problem ist. */ /** Erklärung, 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 naechste Handlung. */ /** Die nächste Handlung. */
readonly recommendation: string; readonly recommendation: string;
} }
/** Ergebnis eines Pruefbereichs. */ /** Ergebnis eines Prüfbereichs. */
interface AreaResult { interface AreaResult {
/** Bezeichner. */ /** Bezeichner. */
readonly area: string; readonly area: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Meldet, ob der Bereich geprueft werden konnte. */ /** Meldet, ob der Bereich geprüft werden konnte. */
readonly available: boolean; readonly available: boolean;
/** Erklaert einen nicht pruefbaren Bereich. */ /** Erklärt einen nicht prüfbaren Bereich. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
/** Befunde des Bereichs. */ /** Befunde des Bereichs. */
readonly findings: readonly SecurityFinding[]; readonly findings: readonly SecurityFinding[];
@ -58,13 +72,13 @@ interface AreaResult {
interface SecurityAssessment { interface SecurityAssessment {
/** Gesamtbewertung in Prozent. */ /** Gesamtbewertung in Prozent. */
readonly score: number; readonly score: number;
/** Erreichbare Punkte der geprueften Bereiche. */ /** Erreichbare Punkte der geprüften 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 pruefbarer Bereiche. */ /** Zahl nicht prüfbarer 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;
@ -72,7 +86,7 @@ interface SecurityAssessment {
readonly high_finding_count: number; readonly high_finding_count: number;
/** Zusammenfassung. */ /** Zusammenfassung. */
readonly summary: string; readonly summary: string;
/** Nicht gepruefte Bereiche. */ /** Nicht geprüfte Bereiche. */
readonly unchecked_areas: readonly string[]; readonly unchecked_areas: readonly string[];
} }
@ -94,68 +108,111 @@ 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"> <PageHeader
<h1 className="page__title">Sicherheit</h1> title="Security Center"
</header> description="Was geprüft wurde — und was nicht."
/>
{loadState === 'loading' && <LoadingState what="Die Sicherheitslage" />} {loadState === 'loading' ? (
{loadState === 'failed' && loadError !== null && ( <Card>
<ErrorState error={loadError} onRetry={reload} /> <LoadingState label="Die Sicherheitslage wird ermittelt …" />
)} </Card>
) : null}
{loadState === 'loaded' && data !== 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. */ /**
interface ScoreHeaderProperties { * Zeigt die Gesamtbewertung.
/** Die Antwort des Security Center. */ *
readonly response: SecurityResponse; * Neben der Prozentzahl steht immer die erreichbare Höchstpunktzahl und die
} * Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in die
* Rechnung ein — ihn als bestanden zu werten wäre Schönfärberei, als
/** Zeigt die Gesamtbewertung. */ * durchgefallen eine Behauptung.
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 (
<div className={`score-card score-card--${gradeClassOf(assessment)}`}> <Card>
<div className="score-card__figure"> <CardContent className="flex flex-wrap items-start gap-6">
<span className="score-card__value">{assessment.score}</span> <div>
<span className="score-card__unit">%</span> <p
</div> className={cn(
'text-4xl font-semibold tabular',
<div className="score-card__text"> criticalCount > 0 ? 'text-critical' : 'text-fg',
<p className="score-card__grade">{assessment.grade}</p> )}
<p className="score-card__summary">{assessment.summary}</p> >
{assessment.score}
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die <span className="ml-1 text-lg text-fg-muted">%</span>
Handlungsanweisung, nicht nur eine Einschraenkung. */}
{assessment.unchecked_areas.length > 0 && (
<p className="score-card__unchecked">
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
</p> </p>
)} <p className="mt-1 text-sm font-medium text-fg">{assessment.grade}</p>
</div> </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>
</CardContent>
</Card>
); );
} }
/** Eigenschaften der Befundliste. */ /**
interface FindingListProperties { * Zeigt die Befunde nach Schweregrad geordnet.
/** 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(
@ -165,130 +222,113 @@ function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
if (orderedFindings.length === 0) { if (orderedFindings.length === 0) {
return ( return (
<p className="page__state page__state--empty"> <Card>
Keine Befunde in den geprueften Bereichen. <EmptyState
</p> title="Keine Befunde in den geprüften Bereichen"
description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben."
/>
</Card>
); );
} }
return ( return (
<ul className="finding-list"> <Card>
{orderedFindings.map((finding) => ( <CardHeader title={`Befunde (${orderedFindings.length})`} />
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}> <CardContent>
<div className="finding__head"> <ul className="space-y-3">
<span className={`badge badge--${severityClassOf(finding.severity)}`}> {orderedFindings.map((finding) => (
{finding.severity} <li
</span> key={`${finding.area}-${finding.title}`}
<h2 className="finding__title">{finding.title}</h2> className="rounded-md border border-line p-3"
</div> >
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={finding.severity} />
<h3 className="font-medium text-fg">{finding.title}</h3>
</div>
<p className="finding__explanation">{finding.explanation}</p> <p className="mt-1.5 text-sm text-fg-muted">{finding.explanation}</p>
<dl className="finding__details"> <dl className="mt-3 space-y-2 border-t border-line pt-2 text-sm">
<div className="finding__detail"> <div className="flex gap-2">
<dt>Betroffen</dt> <dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
<dd>{finding.affected_object}</dd> Betroffen
</div> </dt>
<dd className="min-w-0 break-all text-fg">{finding.affected_object}</dd>
</div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der {/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
Befund ueberhaupt angezeigt wird. */} der Befund überhaupt angezeigt wird. */}
<div className="finding__detail finding__detail--recommendation"> <div className="flex gap-2">
<dt>Naechster Schritt</dt> <dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
<dd>{finding.recommendation}</dd> Nächster Schritt
</div> </dt>
</dl> <dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
</li> </div>
))} </dl>
</ul> </li>
))}
</ul>
</CardContent>
</Card>
); );
} }
/** Eigenschaften der Bereichsuebersicht. */ /** Zeigt die Ergebnisse je Prüfbereich. */
interface AreaTableProperties { function AreaTable({
/** Die Sicherheitslage. */ assessment,
}: {
readonly assessment: SecurityAssessment; readonly assessment: SecurityAssessment;
} }): React.JSX.Element {
const areaColumns: ReadonlyArray<TableColumn<AreaResult>> = [
{
key: 'title',
header: 'Bereich',
render: (areaResult) => <span className="font-medium text-fg">{areaResult.title}</span>,
},
{
key: 'result',
header: 'Ergebnis',
render: (areaResult) =>
areaResult.available ? (
<span className="text-sm text-fg">{areaResult.summary}</span>
) : (
<div className="flex flex-col gap-1">
<StatusBadge status="neutral" label="nicht prüfbar" />
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
</div>
),
},
{
key: 'points',
header: 'Punkte',
numeric: true,
render: (areaResult) =>
areaResult.available ? (
`${areaResult.earned_points} / ${areaResult.weight}`
) : (
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
// positiv noch negativ.
<span className="text-xs text-fg-subtle">zählt nicht</span>
),
},
];
/** Zeigt die Ergebnisse je Pruefbereich. */
function AreaTable({ assessment }: AreaTableProperties): React.JSX.Element {
return ( return (
<section className="area-overview"> <Card>
<h2 className="area-overview__title">Geprueft wurde</h2> <CardHeader
title="Geprüft wurde"
<div className="table-wrapper"> description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
<table className="data-table"> />
<thead> <DataTable
<tr> columns={areaColumns}
<th scope="col">Bereich</th> rows={assessment.areas}
<th scope="col">Ergebnis</th> getRowKey={(areaResult) => areaResult.area}
<th scope="col">Punkte</th> />
</tr> </Card>
</thead>
<tbody>
{assessment.areas.map((areaResult) => (
<tr key={areaResult.area}>
<td>{areaResult.title}</td>
<td>
{areaResult.available ? (
areaResult.summary
) : (
<>
<span className="badge badge--neutral">nicht pruefbar</span>
<span className="data-table__secondary">{areaResult.unavailable_reason}</span>
</>
)}
</td>
<td className="data-table__number">
{areaResult.available ? (
`${areaResult.earned_points} / ${areaResult.weight}`
) : (
// Ein ungeprueft Bereich geht nicht in die Rechnung ein —
// weder positiv noch negativ.
<span className="data-table__unknown">zaehlt nicht</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
); );
} }
/** 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 {

162
apps/web/src/lib/utils.ts Normal file
View File

@ -0,0 +1,162 @@
/** 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`;
}

View File

@ -1,14 +1,14 @@
/** Einstiegspunkt der Syncova-Oberflaeche. */ /** Einstiegspunkt der Syncova-Oberfläche. */
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/tokens.css'; import './styles/theme.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 wuerde nur eine leere Seite hinterlassen. // Ein stiller Abbruch würde 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.');
} }

View File

@ -1,108 +0,0 @@
/**
* 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>
);
}

View File

@ -1,37 +1,50 @@
/** /**
* Seite fuer einen Bereich ohne Backend. * Seite für einen Bereich ohne Bedienoberfläche.
* *
* 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, laesst den Bediener ratlos zurueck — und eine, die eine * nur „in Arbeit" sagt, lässt den Bediener ratlos zurück — und eine, die eine
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen. * leere Tabelle zeigt, lässt ihn glauben, es gäbe nichts zu sehen.
*/ */
import type { PageDefinition } from './pages'; import { Construction } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Callout, Card, CardContent } from '@/components/ui';
/** Eigenschaften der Hinweisseite. */ export function UnavailablePage({
interface UnavailablePageProperties { title,
/** Die betroffene Seite. */ reason,
readonly pageDefinition: PageDefinition; alternative,
} }: {
readonly title: ReactNode;
/** Zeigt den Ausbaustand eines Bereichs. */ readonly reason: ReactNode;
export function UnavailablePage({ pageDefinition }: UnavailablePageProperties): React.JSX.Element { /** Wo dieselbe Auskunft heute zu bekommen ist. */
readonly alternative?: ReactNode | undefined;
}): React.JSX.Element {
return ( return (
<section className="page"> <>
<header className="page__header"> <PageHeader title={title} />
<h1 className="page__title">{pageDefinition.label}</h1> <Card>
<span className="page__badge">noch nicht verfuegbar</span> <CardContent className="py-10">
</header> <div className="mx-auto flex max-w-xl flex-col items-center text-center">
<Construction className="size-7 text-fg-subtle" aria-hidden />
<p className="mt-3 text-sm text-fg">{reason}</p>
<div className="notice notice--information"> {alternative ? (
<p className="notice__text">{pageDefinition.unavailableReason}</p> <Callout tone="info" className="mt-5 w-full text-left">
</div> {alternative}
</Callout>
) : null}
<p className="page__hint"> <p className="mt-5 text-xs text-fg-muted">
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten gefuellt. Eine Maske mit Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten
erfundenen Zahlen liesse sich im Betrieb nicht von einer echten unterscheiden. gefüllt. Eine Maske mit erfundenen Zahlen ließe sich im Betrieb
</p> nicht von einer echten unterscheiden.
</section> </p>
</div>
</CardContent>
</Card>
</>
); );
} }

View File

@ -1,17 +1,17 @@
/** /**
* Tests des Seitenverzeichnisses. * Tests des Seitenverzeichnisses.
* *
* Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem * Sie prüfen weniger die Technik als eine Zusage: Jede Seite aus dem
* Implementierungsplan erscheint, und jede unfertige erklaert sich. * Implementierungsplan erscheint, und jede unfertige erklärt 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 verfuegbaren Seite einen Grund', () => { it('nennt zu jeder nicht verfügbaren Seite einen Grund', () => {
// Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos // Ein Bereich, der nur „nicht verfügbar" sagt, lässt den Bediener ratlos
// zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten. // zurück. 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 verfuegbaren Seite einen Nichtverfuegbarkeitsgrund', () => { it('gibt keiner verfügbaren Seite einen Nichtverfügbarkeitsgrund', () => {
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 fuenfzehn Seiten. Fehlt eine davon im Verzeichnis, // Der Plan (§14) nennt fünfzehn 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 noetige Berechtigung', () => { it('verbirgt Seiten ohne die nötige 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