Compare commits

..

No commits in common. "main" and "v1.0.0-rc6" have entirely different histories.

104 changed files with 1635 additions and 4782 deletions

View File

@ -1,154 +1,5 @@
# Änderungen # Änderungen
## V1 — Release Candidate 9, 18. August 2026
### Sicherungsart je Auftrag
Bisher entschied die Anlage allein: Liegt ein Elternbackup vor, wird
inkrementell gesichert. Jetzt wählbar —
- **inkrementell** (Standard, bisheriges Verhalten),
- **immer voll**, oder
- inkrementell **mit festem Volltag**, etwa „immer freitags".
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekäme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt.
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert** — unveränderte
Blöcke werden dedupliziert. Was steigt, ist die Laufzeit. Das steht so in der
Maske, weil es die häufigste Verwechslung ist.
Migration 000014 mit drei CHECKs. Der dritte lehnt „immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll.
### Behoben
- **Das Aufnahme-Token eines Agenten zeigte „undefined".** Das Feld heißt
`token`, nicht `enrollment_token` — Letzteres ist der Name im *Anfrage*körper
der Registrierung. Der dritte Formfehler dieser Art; alle konsumierten
Endpunkte sind jetzt gegen den laufenden Dienst abgeglichen statt aus der
Struktur abgeleitet.
### Aufnahmedialog mit Anleitung
Vollständige Anleitung für **Linux und Windows**, umschaltbar, mit fertig
ausgefüllten Befehlen — Serveradresse und Token eingesetzt, jeder Schritt
einzeln kopierbar. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
jemand `<token>` wörtlich einsetzt.
Dazu die beiden Stolperstellen: `--state` erwartet eine **Datei**, und der
Agent braucht Schreibzugriff auf das Repository.
### update.sh rüstet die Wiederherstellungsfläche nach
Sie kam mit rc8 dazu; eine Anlage aus einer älteren Fassung hat sie nicht. Ohne
sie scheitert jede Wiederherstellung an `ProtectSystem=strict`. `update.sh`
legt sie jetzt an und trägt sie in `ReadWritePaths` ein — ein Schritt, den man
von Hand ausführen muss, wird übersehen und fällt erst im Ernstfall auf.
## V1 — Release Candidate 8, 18. August 2026
Wiederherstellung von Dateien und Ordnern mit Auswahl statt Textfeld — und die
Erklärung, warum vorher gar keine Wiederherstellung funktionierte.
### Warum keine Wiederherstellung ging
Nicht die Rechte des Zielverzeichnisses, sondern die Härtung des Dienstes: Er
läuft mit `ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
Sicherungsordner. Jedes Ziel außerhalb endete mit `mkdir: permission denied` —
und zwar **nach** der Vorabprüfung, an der unangenehmsten Stelle. `/tmp`
scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein eigenes `/tmp`, und
was dort landet, ist von außen unsichtbar.
`setup.sh` legt jetzt `/srv/syncova-restore` an und trägt es ein;
`--wiederherstellungsziel` ergänzt weitere. Der Ort liegt unter `/srv`, weil
`/var/lib` auf der Sperrliste des Zielschutzes steht — beide Regeln zugleich zu
erfüllen lässt genau `/srv` übrig.
### Auswahl statt Textfeld
- **Ordnerbaum für das Ziel.** Er meldet je Verzeichnis, ob der Dienst dort
schreiben darf — **gemessen** durch eine Probedatei, nicht aus den Rechtebits
abgeleitet. Gesperrte Orte werden gezeigt, nicht versteckt: Sonst bliebe
offen, warum ein Pfad fehlt.
- **Browser für den Backup-Inhalt.** Ordner **und** einzelne Dateien lassen
sich zurückholen. Der Baum entsteht aus den Pfaden, nicht aus
Verzeichniseinträgen — ein Manifest kann eine Datei enthalten, deren
Elternordner nicht als eigener Eintrag vorliegt.
Zwei neue Endpunkte, der eingefrorene Vertrag ist entsprechend erweitert:
`GET /filesystem/browse` und `GET /backups/{id}/contents`.
### Behoben
- **„can't access property toLocaleString, chunks_checked is undefined"** beim
Integritätslauf. Die Ergebnisse liegen unter `details`, und die Felder heißen
`missing_chunks`/`corrupted_chunks`. Betrifft alle vier Prüfendpunkte — sie
tragen dieselbe Hülle. Derselbe Fehler wie zuvor bei `/retention-policies`:
die Antwortform angenommen statt geprüft. Alle konsumierten Endpunkte sind
jetzt gegen den laufenden Dienst abgeglichen.
### Geist Mono liegt im Paket
Drei Schnitte, 128 KB, OFL-Lizenz dabei. Ausgeliefert vom eigenen Ursprung —
das verlangt die CSP, und ein Backup-Server, dessen Oberfläche von der
Erreichbarkeit eines CDN abhängt, wäre auch ohne CSP falsch.
### Bekannte Grenze
**Eine Auswahl je Lauf**, kein Mehrfachhaken. Eine Liste ausgewählter Pfade
kennt die API nicht; mehrere Läufe hintereinander ergäben mehrere Ausgänge, und
ein „teilweise fehlgeschlagen" ließe sich dann nicht mehr erklären.
## V1 — Release Candidate 7, 18. August 2026
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.
### Behoben
- **`/retention` zeigte einen schwarzen Bildschirm.** `GET /retention-policies`
liefert ein Objekt `{policies, predefined}` — als einziger von zehn geprüften
Listenendpunkten. Die Oberfläche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React hängte den ganzen Baum aus. Der Regressionstest
füttert jetzt die **echte** Antwortform; ein Test mit einem Array hätte den
Fehler nie gefunden, und genau das war passiert.
- **Jedes Neuladen führte zurück zur Anmeldung.** Die Tokens lagen nur im
Arbeitsspeicher.
- **Ein Fehler in einer Komponente schwärzte die ganze Konsole.** Jetzt sitzt
eine Fehlergrenze um den Seiteninhalt: Menü und Kopfzeile bleiben stehen, der
Fehlertext ist lesbar und kopierbar.
### Sitzung
Sie überlebt ein Neuladen und endet nach **30 Minuten** — gerechnet als frühere
von zwei Grenzen: einer harten Obergrenze ab Anmeldung, die keine Interaktion
verschiebt, und einer Untätigkeitsgrenze. Die verbleibende Zeit läuft neben
„Abmelden" und wird unter fünf Minuten auffällig.
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestätigt: Wer beim Vermerken einer Interaktion die Obergrenze mitverschiebt,
macht aus „30 Minuten" ein „unbegrenzt, solange die Maus wackelt".
### Aussehen
Farben, Radien, Schatten und Schrift aus dem Preset `b5vnF8SMi`: violett als
Handlungsfarbe, Radius 0, schattenlos, durchgehend Geist Mono. Die
**Statusfarben bleiben** — das Preset kennt keine, und ohne sie ließe sich ein
Teilfehler nicht von einem Erfolg unterscheiden.
Dazu **Umlaute auf allen Seiten** (vorher durchgehend ae/oe/ue/ss), **33
Erläuterungen von Absätzen auf einen Satz gekürzt** und neue Module: acht
Schnellzugriffe auf der Übersicht sowie sechs mitgelieferte
Aufbewahrungsvorlagen als Kacheln — die lieferte der Server schon immer mit,
die Oberfläche warf sie bisher weg.
### Unverändert offen
Windows-Dienst, systemd-Einheit des Agenten und der Proxmox-Bootmeilenstein sind
gebaut, aber nie auf echter Hardware gefahren. Geist Mono wird nicht
mitgeliefert; ohne die Schrift auf dem Gerät greift die System-Monospace.
## V1 — Release Candidate 6, 18. August 2026 ## V1 — Release Candidate 6, 18. August 2026
**Die Weboberfläche ist eine vollständige Verwaltungskonsole geworden.** **Die Weboberfläche ist eine vollständige Verwaltungskonsole geworden.**

View File

@ -1,422 +0,0 @@
package httpapi
import (
"errors"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/recovery"
"github.com/syncova/syncova/packages/repository"
)
// browseHandler bedient die beiden Blätterendpunkte.
//
// Sie sind für eine Wiederherstellung gebaut und lösen zwei Probleme, die sich
// mit einem Textfeld nicht lösen lassen:
//
// 1. **Wohin darf zurückgeschrieben werden?** Der Dienst läuft mit
// `ProtectSystem=strict`; außerhalb weniger Pfade ist das Dateisystem für
// ihn schreibgeschützt. Ein Betreiber tippt „/opt/test", bekommt
// „permission denied" und hat keine Möglichkeit zu erkennen, welcher Ort
// überhaupt in Frage kommt. `GET /filesystem/browse` beantwortet genau das
// — es meldet je Verzeichnis, ob der **Dienst** dort schreiben kann,
// geprüft durch einen tatsächlichen Schreibversuch.
//
// 2. **Was steckt in dem Backup?** Ohne Inhaltsverzeichnis lässt sich weder
// eine einzelne Datei noch ein Unterordner gezielt zurückholen. `GET
// /backups/{id}/contents` liefert das Manifest als Ebene eines Baums.
//
// Beide lesen nur.
type browseHandler struct {
restoreHandlerReference *restoreHandler
targetGuard *recovery.TargetGuard
}
// filesystemEntry ist ein Eintrag des Dateisystems.
type filesystemEntry struct {
// Name ist der letzte Pfadbestandteil.
Name string `json:"name"`
// Path ist der vollständige absolute Pfad.
Path string `json:"path"`
// IsDirectory unterscheidet Verzeichnis von Datei.
IsDirectory bool `json:"is_directory"`
// SizeBytes ist die Größe bei Dateien.
SizeBytes int64 `json:"size_bytes,omitempty"`
// IsWritable meldet, ob der Dienst hier anlegen darf.
//
// Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten:
// Unter `ProtectSystem=strict` sagen die Bits nichts über das aus, was der
// Namensraum zulässt.
IsWritable bool `json:"is_writable"`
// ForbiddenReason nennt den Grund, wenn der Zielschutz den Ort ausschließt.
ForbiddenReason string `json:"forbidden_reason,omitempty"`
}
// browseFilesystemResponse ist die Antwort auf das Blättern im Dateisystem.
type browseFilesystemResponse struct {
// Path ist das aufgelistete Verzeichnis.
Path string `json:"path"`
// ParentPath ist das übergeordnete Verzeichnis; leer bei der Wurzel.
ParentPath string `json:"parent_path,omitempty"`
// Entries sind die enthaltenen Verzeichnisse.
Entries []filesystemEntry `json:"entries"`
// SuggestedPaths sind Orte, an denen der Dienst nachweislich schreiben darf.
//
// Sie stehen in der Antwort, damit die Oberfläche einen brauchbaren
// Startpunkt anbieten kann, statt den Betreiber suchen zu lassen.
SuggestedPaths []string `json:"suggested_paths,omitempty"`
}
// backupContentEntry ist ein Eintrag im Inhaltsverzeichnis eines Backups.
type backupContentEntry struct {
Name string `json:"name"`
// Path ist der Pfad im Manifest — genau der Wert, den eine
// Wiederherstellung als `path_prefix` erwartet.
Path string `json:"path"`
IsDirectory bool `json:"is_directory"`
EntryType string `json:"entry_type"`
SizeBytes int64 `json:"size_bytes,omitempty"`
ModifiedAt string `json:"modified_at,omitempty"`
Mode string `json:"mode,omitempty"`
// ChildCount ist die Zahl der Einträge unterhalb eines Verzeichnisses.
ChildCount int `json:"child_count,omitempty"`
// TotalBytes ist die Datenmenge unterhalb eines Verzeichnisses.
TotalBytes int64 `json:"total_bytes,omitempty"`
}
// browseBackupResponse ist die Antwort auf das Blättern im Backup.
type browseBackupResponse struct {
BackupID string `json:"backup_id"`
Path string `json:"path"`
ParentPath string `json:"parent_path,omitempty"`
// Entries sind die Einträge auf dieser Ebene.
Entries []backupContentEntry `json:"entries"`
// TotalEntryCount ist die Zahl aller Einträge im Backup.
TotalEntryCount int `json:"total_entry_count"`
}
// handleBrowseFilesystem bedient GET /filesystem/browse.
func (handler *browseHandler) handleBrowseFilesystem(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
requestedPath := strings.TrimSpace(request.URL.Query().Get("path"))
if requestedPath == "" {
requestedPath = "/"
}
if !filepath.IsAbs(requestedPath) {
WriteError(responseWriter, request, requestLogger,
NewValidationError("Der Pfad muss absolut sein."))
return
}
// Symlinks werden aufgelöst, bevor gelesen wird: Sonst ließe sich über
// einen Verweis an jeder Prüfung vorbei in ein fremdes Verzeichnis sehen.
resolvedPath, resolveError := filepath.EvalSymlinks(filepath.Clean(requestedPath))
if resolveError != nil {
resolvedPath = filepath.Clean(requestedPath)
}
directoryEntries, readError := os.ReadDir(resolvedPath)
if readError != nil {
if errors.Is(readError, os.ErrNotExist) {
WriteError(responseWriter, request, requestLogger,
NewNotFoundError("Das Verzeichnis wurde nicht gefunden."))
return
}
WriteError(responseWriter, request, requestLogger,
NewValidationError("Das Verzeichnis lässt sich nicht lesen: "+readError.Error()))
return
}
entries := make([]filesystemEntry, 0, len(directoryEntries))
for _, directoryEntry := range directoryEntries {
// Nur Verzeichnisse: Ein Wiederherstellungsziel ist immer ein
// Verzeichnis, und die Dateien daneben wären nur Rauschen.
if !directoryEntry.IsDir() {
continue
}
// Versteckte Verzeichnisse bleiben draußen. Wer eines braucht, tippt
// den Pfad — die Liste soll den Normalfall zeigen.
if strings.HasPrefix(directoryEntry.Name(), ".") {
continue
}
childPath := filepath.Join(resolvedPath, directoryEntry.Name())
entry := filesystemEntry{
Name: directoryEntry.Name(),
Path: childPath,
IsDirectory: true,
IsWritable: directoryIsWritable(childPath),
}
if handler.targetGuard != nil {
if guardError := handler.targetGuard.Validate(childPath); guardError != nil {
entry.ForbiddenReason = guardError.Error()
// Ein gesperrter Ort ist nie ein zulässiges Ziel, auch wenn das
// Dateisystem ihn zuließe.
entry.IsWritable = false
}
}
entries = append(entries, entry)
}
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
return entries[firstIndex].Name < entries[secondIndex].Name
})
response := browseFilesystemResponse{
Path: resolvedPath,
Entries: entries,
SuggestedPaths: writableSuggestions(handler.targetGuard),
}
if resolvedPath != "/" {
response.ParentPath = filepath.Dir(resolvedPath)
}
WriteSuccess(responseWriter, request, http.StatusOK, response)
}
// handleBrowseBackupContents bedient GET /backups/{id}/contents.
func (handler *browseHandler) handleBrowseBackupContents(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewValidationError("Die Backup-Kennung ist keine gültige UUID."))
return
}
repositoryPath, backupIDInRepository, resolveError := handler.restoreHandlerReference.resolveBackup(
request.Context(), backupIdentifier)
if resolveError != nil {
WriteError(responseWriter, request, requestLogger, resolveError)
return
}
// Schreibgeschützt: Ein Inhaltsverzeichnis liest nur und soll neben einer
// laufenden Sicherung erstellt werden können.
openedRepository, openError := repository.Open(request.Context(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, handler.restoreHandlerReference.logger)
if openError != nil {
WriteError(responseWriter, request, requestLogger,
NewServiceUnavailableError("Das Repository des Backups ist derzeit nicht erreichbar."))
return
}
defer func() { _ = openedRepository.Close() }()
backupManifest, manifestError := openedRepository.ReadManifest(request.Context(), backupIDInRepository)
if manifestError != nil {
WriteError(responseWriter, request, requestLogger,
NewValidationError("Das Manifest des Backups lässt sich nicht lesen: "+manifestError.Error()))
return
}
currentPath := strings.Trim(strings.TrimSpace(request.URL.Query().Get("path")), "/")
response := browseBackupResponse{
BackupID: backupIdentifier.String(),
Path: currentPath,
Entries: collectLevel(backupManifest.Entries, currentPath),
TotalEntryCount: len(backupManifest.Entries),
}
if currentPath != "" {
parentPath := path.Dir(currentPath)
if parentPath == "." {
parentPath = ""
}
response.ParentPath = parentPath
}
WriteSuccess(responseWriter, request, http.StatusOK, response)
}
// collectLevel bildet aus einem flachen Manifest eine Verzeichnisebene.
//
// Das Manifest kennt keine Baumstruktur, sondern eine flache Liste von Pfaden.
// Der Baum entsteht hier — und zwar **ohne** sich auf Verzeichniseinträge zu
// verlassen: Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis
// nicht als eigener Eintrag vorliegt. Wer nur die Einträge vom Typ `directory`
// auflistet, verliert dann ganze Teilbäume.
func collectLevel(manifestEntries []repository.ManifestEntry, currentPath string) []backupContentEntry {
prefix := ""
if currentPath != "" {
prefix = currentPath + "/"
}
// Verzeichnisse werden über ihre Kinder erkannt und dabei gleich
// aufsummiert: Ein Betreiber will vor dem Zurückholen wissen, wie viel an
// einem Ordner hängt.
directories := make(map[string]*backupContentEntry)
files := make([]backupContentEntry, 0, 32)
for _, manifestEntry := range manifestEntries {
entryPath := strings.Trim(manifestEntry.Path, "/")
if prefix != "" && !strings.HasPrefix(entryPath, prefix) {
continue
}
remainder := strings.TrimPrefix(entryPath, prefix)
if remainder == "" {
continue
}
separatorIndex := strings.Index(remainder, "/")
if separatorIndex < 0 {
// Direktes Kind dieser Ebene.
if manifestEntry.EntryType == "directory" {
directoryPath := prefix + remainder
if _, exists := directories[remainder]; !exists {
directories[remainder] = &backupContentEntry{
Name: remainder,
Path: directoryPath,
IsDirectory: true,
EntryType: "directory",
Mode: manifestEntry.Mode,
}
}
continue
}
files = append(files, backupContentEntry{
Name: remainder,
Path: entryPath,
IsDirectory: false,
EntryType: manifestEntry.EntryType,
SizeBytes: manifestEntry.SizeBytes,
ModifiedAt: formatOptionalTime(manifestEntry),
Mode: manifestEntry.Mode,
})
continue
}
// Ein Nachfahre: Er belegt, dass es das Verzeichnis gibt, auch wenn
// kein eigener Eintrag dafür existiert.
directoryName := remainder[:separatorIndex]
existing, exists := directories[directoryName]
if !exists {
existing = &backupContentEntry{
Name: directoryName,
Path: prefix + directoryName,
IsDirectory: true,
EntryType: "directory",
}
directories[directoryName] = existing
}
existing.ChildCount++
existing.TotalBytes += manifestEntry.SizeBytes
}
entries := make([]backupContentEntry, 0, len(directories)+len(files))
for _, directoryEntry := range directories {
entries = append(entries, *directoryEntry)
}
entries = append(entries, files...)
// Verzeichnisse zuerst, dann alphabetisch — die Reihenfolge, die jeder
// Dateimanager verwendet.
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
if entries[firstIndex].IsDirectory != entries[secondIndex].IsDirectory {
return entries[firstIndex].IsDirectory
}
return entries[firstIndex].Name < entries[secondIndex].Name
})
return entries
}
// formatOptionalTime gibt einen Zeitstempel aus, sofern gesetzt.
func formatOptionalTime(manifestEntry repository.ManifestEntry) string {
if manifestEntry.ModifiedAt.IsZero() {
return ""
}
return manifestEntry.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z")
}
// directoryIsWritable prüft durch einen echten Schreibversuch.
//
// Die Rechtebits zu lesen genügt nicht: Unter `ProtectSystem=strict` ist das
// Dateisystem für den Dienst außerhalb weniger Pfade schreibgeschützt, und
// davon steht nichts im Modus. Genau diese Lücke hat dazu geführt, dass eine
// Wiederherstellung nach `/opt/test` mit „permission denied" endete, obwohl
// das Verzeichnis dem Anschein nach beschreibbar war.
func directoryIsWritable(directoryPath string) bool {
probeFile, createError := os.CreateTemp(directoryPath, ".syncova-schreibprobe-*")
if createError != nil {
return false
}
probeName := probeFile.Name()
_ = probeFile.Close()
_ = os.Remove(probeName)
return true
}
// writableSuggestions nennt Orte, an denen der Dienst nachweislich schreiben darf.
//
// Ohne diese Liste sucht ein Betreiber im Blindflug: Die meisten Verzeichnisse
// eines gehärteten Systems scheiden aus, und welche übrig bleiben, hängt an der
// systemd-Einheit — nicht an etwas, das man dem Dateisystem ansieht.
func writableSuggestions(targetGuard *recovery.TargetGuard) []string {
// Reihenfolge ist Absicht: Zuerst die Flaeche, die `setup.sh` anlegt und
// in ReadWritePaths eintraegt. `/var/lib` steht bewusst nicht dabei — es
// ist im Zielschutz gesperrt.
candidates := []string{
"/srv/syncova-restore",
"/srv",
"/var/tmp",
"/home",
}
suggestions := make([]string, 0, len(candidates))
for _, candidate := range candidates {
if targetGuard != nil {
if guardError := targetGuard.Validate(candidate); guardError != nil {
continue
}
}
if directoryIsWritable(candidate) {
suggestions = append(suggestions, candidate)
}
}
return suggestions
}

View File

@ -1,90 +0,0 @@
package httpapi
import (
"testing"
"time"
"github.com/syncova/syncova/packages/repository"
)
// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum
// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen.
//
// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als
// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer
// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze
// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar.
func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) {
manifestEntries := []repository.ManifestEntry{
// Kein Eintrag fuer "berichte" selbst.
{Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900},
{Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100},
{Path: "notiz.txt", EntryType: "file", SizeBytes: 6},
}
rootLevel := collectLevel(manifestEntries, "")
if len(rootLevel) != 2 {
testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel))
}
// Verzeichnisse stehen vorn.
if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" {
testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0])
}
// Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem
// Zurueckholen wissen, wie viel an einem Ordner haengt.
if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 {
testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte",
rootLevel[0].ChildCount, rootLevel[0].TotalBytes)
}
// Und eine Ebene tiefer erscheinen die Jahresordner.
deeperLevel := collectLevel(manifestEntries, "berichte")
if len(deeperLevel) != 2 {
testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel))
}
}
// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest.
//
// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine
// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat.
func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) {
manifestEntries := []repository.ManifestEntry{
{Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1},
{Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1},
}
level := collectLevel(manifestEntries, "dokumente")
if len(level) != 1 || level[0].Name != "a.txt" {
testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level)
}
}
// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei.
func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) {
modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
level := collectLevel([]repository.ManifestEntry{
{Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime},
}, "")
if len(level) != 1 {
testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level))
}
// Der Pfad ist genau der Wert, den eine Wiederherstellung als
// `path_prefix` erwartet — eine Abweichung faellt sonst erst beim
// Zurueckschreiben auf.
if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" {
testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0])
}
if level[0].ModifiedAt != "2026-08-18T10:00:00Z" {
testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt)
}
}

View File

@ -122,5 +122,3 @@ POST /api/v1/users users.write
POST /api/v1/users/{id}/mfa/disable users.write POST /api/v1/users/{id}/mfa/disable users.write
POST /api/v1/verification verification.write POST /api/v1/verification verification.write
POST /api/v1/verification/{id}/cancel verification.write POST /api/v1/verification/{id}/cancel verification.write
GET /api/v1/filesystem/browse restores.read
GET /api/v1/backups/{id}/contents restores.read

View File

@ -89,14 +89,6 @@ type jobRequest struct {
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz. // BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
// BackupMode ist "incremental" (Standard) oder "always_full".
BackupMode string `json:"backup_mode,omitempty"`
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
//
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
// unterscheiden.
FullBackupWeekday *int `json:"full_backup_weekday,omitempty"`
// MaximumConcurrency begrenzt gleichzeitige Läufe. // MaximumConcurrency begrenzt gleichzeitige Läufe.
MaximumConcurrency int `json:"max_concurrency,omitempty"` MaximumConcurrency int `json:"max_concurrency,omitempty"`
} }
@ -135,14 +127,6 @@ type jobResponse struct {
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz. // BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
// BackupMode ist "incremental" (Standard) oder "always_full".
BackupMode string `json:"backup_mode,omitempty"`
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
//
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
// unterscheiden.
FullBackupWeekday *int `json:"full_backup_weekday,omitempty"`
// MaximumConcurrency begrenzt gleichzeitige Läufe. // MaximumConcurrency begrenzt gleichzeitige Läufe.
MaximumConcurrency int `json:"max_concurrency"` MaximumConcurrency int `json:"max_concurrency"`
// NextRunAt ist der nächste Zeitpunkt in UTC. // NextRunAt ist der nächste Zeitpunkt in UTC.

View File

@ -57,8 +57,6 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job,
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second, RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second, RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond, BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
MaximumConcurrency: maximumConcurrency, MaximumConcurrency: maximumConcurrency,
RetryPolicy: scheduler.DefaultRetryPolicy(), RetryPolicy: scheduler.DefaultRetryPolicy(),
CreatedBy: &creatorID, CreatedBy: &creatorID,
@ -154,8 +152,6 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse {
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()), RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()), RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond, BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
BackupMode: string(sourceJob.BackupMode),
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
MaximumConcurrency: sourceJob.MaximumConcurrency, MaximumConcurrency: sourceJob.MaximumConcurrency,
NextRunAt: sourceJob.NextRunAt, NextRunAt: sourceJob.NextRunAt,
LastRunAt: sourceJob.LastRunAt, LastRunAt: sourceJob.LastRunAt,
@ -194,33 +190,3 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
return scheduleData return scheduleData
} }
// weekdayFromPayload uebersetzt einen Wochentag aus der Anfrage.
//
// Ein Wert ausserhalb von 0..6 wird verworfen statt gekappt: Ein
// stillschweigend auf Sonntag gesetzter Montag waere ein Fehler, den niemand
// bemerkt — die Vollsicherung liefe dann am falschen Tag.
func weekdayFromPayload(requestedWeekday *int) *time.Weekday {
if requestedWeekday == nil {
return nil
}
if *requestedWeekday < 0 || *requestedWeekday > 6 {
return nil
}
convertedWeekday := time.Weekday(*requestedWeekday)
return &convertedWeekday
}
// weekdayToPayload uebersetzt einen Wochentag fuer die Antwort.
func weekdayToPayload(storedWeekday *time.Weekday) *int {
if storedWeekday == nil {
return nil
}
convertedValue := int(*storedWeekday)
return &convertedValue
}

View File

@ -377,19 +377,6 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore)) requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
// Blättern in Dateisystem und Backup.
//
// Beide gehören zur Wiederherstellung und tragen deshalb deren Leserecht:
// Wer eine Wiederherstellung vorbereiten darf, muss sehen können, was im
// Backup steckt und wohin sich zurückschreiben lässt.
browseHandlerInstance := &browseHandler{
restoreHandlerReference: restoreHandlerInstance,
targetGuard: restoreHandlerInstance.targetGuard,
}
requestMultiplexer.Handle("GET "+apiBasePath+"/filesystem/browse", protected("restores.read", browseHandlerInstance.handleBrowseFilesystem))
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/contents", protected("restores.read", browseHandlerInstance.handleBrowseBackupContents))
} }
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14). // registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).

View File

@ -1,26 +1,19 @@
/** /**
* Wurzelkomponente der Syncova-Oberfläche. * Wurzelkomponente der Syncova-Oberflaeche.
* *
* Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt * Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt
* letztere auf die Bereiche aus PROMPT.md §28. * letztere auf die Bereiche aus PROMPT.md §28.
* *
* Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf * Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf
* (`jobs/<id>`). Das reicht für diese Konsole und kostet rund fünfzig Zeilen — * (`jobs/<id>`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen —
* eine Router-Bibliothek wäre hier mehr Abhängigkeit als Nutzen. * eine Router-Bibliothek waere hier mehr Abhaengigkeit als Nutzen.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useState } from 'react';
import { setAccessTokenProvider } from './api/client'; import { setAccessTokenProvider } from './api/client';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { ErrorBoundary } from './components/layout/ErrorBoundary'; import { ToastProvider } from './components/ui';
import { LoadingState, ToastProvider } from './components/ui'; import { getAccessToken, logout } from './features/auth/authApi';
import {
fetchCurrentUser,
getAccessToken,
hasResumableSession,
logout,
} from './features/auth/authApi';
import { useSession } from './features/auth/useSession';
import { LoginPage } from './features/auth/LoginPage'; 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 { DashboardPage } from './features/dashboard/DashboardPage';
@ -49,64 +42,16 @@ import { useCurrentPage } from './navigation/useCurrentPage';
import type { CurrentUser } from './types/auth'; import type { CurrentUser } from './types/auth';
import './styles/theme.css'; import './styles/theme.css';
// Der API-Client erhält seinen Tokenzugriff einmalig beim Laden des Moduls. // Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls.
setAccessTokenProvider(getAccessToken); setAccessTokenProvider(getAccessToken);
export function App(): React.JSX.Element { export function App(): React.JSX.Element {
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null); const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
const { currentPageId, navigateToPage } = useCurrentPage(); const { currentPageId, navigateToPage } = useCurrentPage();
// Das gerade geöffnete Objekt innerhalb einer Seite, etwa ein Auftrag. // Das gerade geoeffnete Objekt innerhalb einer Seite, etwa ein Auftrag.
const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null); const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null);
// Beim Start wird geprüft, ob eine Sitzung fortgesetzt werden kann. Solange
// das läuft, erscheint weder Anmeldemaske noch Inhalt — sonst blitzte nach
// jedem Neuladen kurz die Anmeldung auf.
const [isResuming, setIsResuming] = useState(() => hasResumableSession());
useEffect(() => {
if (!isResuming) {
return;
}
let isCancelled = false;
void fetchCurrentUser()
.then((resumedUser) => {
if (!isCancelled) {
setAuthenticatedUser(resumedUser);
}
})
.catch(() => {
// Der Server hat das Token abgelehnt — etwa nach einer Kontosperre.
// Dann gilt die Sitzung als beendet, unabhängig von der lokalen Uhr.
})
.finally(() => {
if (!isCancelled) {
setIsResuming(false);
}
});
return () => {
isCancelled = true;
};
}, [isResuming]);
const handleSessionExpired = useCallback(() => {
setAuthenticatedUser(null);
void logout();
}, []);
const sessionClock = useSession(authenticatedUser !== null, handleSessionExpired);
if (isResuming) {
return (
<div className="flex min-h-screen items-center justify-center bg-page">
<LoadingState label="Sitzung wird fortgesetzt …" />
</div>
);
}
if (authenticatedUser === null) { if (authenticatedUser === null) {
return ( return (
<ToastProvider> <ToastProvider>
@ -122,7 +67,7 @@ export function App(): React.JSX.Element {
const grantedPermissions = authenticatedUser.permissions ?? []; const grantedPermissions = authenticatedUser.permissions ?? [];
/** Wechselt die Seite und schließt ein offenes Objekt. */ /** Wechselt die Seite und schliesst ein offenes Objekt. */
function handleNavigate(pageIdentifier: string): void { function handleNavigate(pageIdentifier: string): void {
setOpenObjectIdentifier(null); setOpenObjectIdentifier(null);
navigateToPage(pageIdentifier); navigateToPage(pageIdentifier);
@ -135,41 +80,33 @@ export function App(): React.JSX.Element {
onNavigate={handleNavigate} onNavigate={handleNavigate}
grantedPermissions={grantedPermissions} grantedPermissions={grantedPermissions}
userName={authenticatedUser.username} userName={authenticatedUser.username}
sessionClock={sessionClock}
onLogout={() => void handleLogout()} onLogout={() => void handleLogout()}
> >
{/* Die Grenze sitzt um den Inhalt: Ein Fehler auf einer Seite lässt <CurrentPageContent
Menü und Kopfzeile stehen, statt die Konsole zu schwärzen. */} currentPageId={currentPageId}
<ErrorBoundary resetKey={`${currentPageId}|${openObjectIdentifier ?? ''}`}> grantedPermissions={grantedPermissions}
<CurrentPageContent currentUser={authenticatedUser}
currentPageId={currentPageId} openObjectIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions} onOpenObject={setOpenObjectIdentifier}
currentUser={authenticatedUser} />
openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
onNavigate={handleNavigate}
/>
</ErrorBoundary>
</AppShell> </AppShell>
</ToastProvider> </ToastProvider>
); );
} }
/** Zeigt den Inhalt der gewählten Seite. */ /** Zeigt den Inhalt der gewaehlten Seite. */
function CurrentPageContent({ function CurrentPageContent({
currentPageId, currentPageId,
grantedPermissions, grantedPermissions,
currentUser, currentUser,
openObjectIdentifier, openObjectIdentifier,
onOpenObject, onOpenObject,
onNavigate,
}: { }: {
readonly currentPageId: string; readonly currentPageId: string;
readonly grantedPermissions: readonly string[]; readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser; readonly currentUser: CurrentUser;
readonly openObjectIdentifier: string | null; readonly openObjectIdentifier: string | null;
readonly onOpenObject: (objectIdentifier: string | null) => void; readonly onOpenObject: (objectIdentifier: string | null) => void;
readonly onNavigate: (pageIdentifier: string) => void;
}): React.JSX.Element { }): React.JSX.Element {
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID); const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
@ -177,14 +114,14 @@ function CurrentPageContent({
return <DashboardPage />; return <DashboardPage />;
} }
// Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne // Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche // sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche
// Auskunft (PROMPT.md §42). // Auskunft (PROMPT.md §42).
if (!mayViewPage(pageDefinition, grantedPermissions)) { if (!mayViewPage(pageDefinition, grantedPermissions)) {
return ( return (
<UnavailablePage <UnavailablePage
title={pageDefinition.label} title={pageDefinition.label}
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." reason="Ihrer Rolle fehlt die Berechtigung fuer diesen Bereich. Die Pruefung erfolgt auf dem Server; diese Anzeige verhindert nur den Umweg ueber eine Fehlermeldung."
/> />
); );
} }
@ -193,7 +130,7 @@ function CurrentPageContent({
return ( return (
<UnavailablePage <UnavailablePage
title={pageDefinition.label} title={pageDefinition.label}
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfügbar.'} reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfuegbar.'}
/> />
); );
} }
@ -202,10 +139,7 @@ function CurrentPageContent({
case 'dashboard': case 'dashboard':
return ( return (
<> <>
<DashboardPage <DashboardPage />
grantedPermissions={grantedPermissions}
onNavigate={onNavigate}
/>
<div className="mt-4"> <div className="mt-4">
<SystemHealthPanel /> <SystemHealthPanel />
</div> </div>
@ -304,7 +238,7 @@ function CurrentPageContent({
return ( return (
<UnavailablePage <UnavailablePage
title={pageDefinition.label} title={pageDefinition.label}
reason="Dieser Bereich wird gerade gebaut. Die zugehörigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberfläche." reason="Dieser Bereich wird gerade gebaut. Die zugehoerigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberflaeche."
/> />
); );
} }

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 würde. */ /** Baut eine Antwort, wie sie das Backend liefern wuerde. */
function buildJsonResponse(responseBody: unknown, statusCode: number): Response { function buildJsonResponse(responseBody: unknown, statusCode: number): Response {
return new Response(JSON.stringify(responseBody), { return new Response(JSON.stringify(responseBody), {
status: statusCode, status: statusCode,
@ -14,7 +14,7 @@ afterEach(() => {
}); });
describe('requestApi', () => { describe('requestApi', () => {
it('gibt die Nutzlast aus der Standard-Hülle zurück', async () => { it('gibt die Nutzlast aus der Standard-Huelle zurueck', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue( vi.fn().mockResolvedValue(
@ -35,7 +35,7 @@ describe('requestApi', () => {
await requestApi('/health'); await requestApi('/health');
// Ohne Correlation ID ließe sich eine Operation nicht Ende-zu-Ende verfolgen. // Ohne Correlation ID liesse sich eine Operation nicht Ende-zu-Ende verfolgen.
const firstCall = fetchMock.mock.calls[0]; const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined(); expect(firstCall).toBeDefined();
@ -69,7 +69,7 @@ describe('requestApi', () => {
}); });
}); });
it('meldet eine nicht erreichbare API verständlich', async () => { it('meldet eine nicht erreichbare API verstaendlich', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen. // Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
@ -79,24 +79,24 @@ describe('requestApi', () => {
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => { it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200))); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200)));
// Eine Antwort außerhalb des Vertrags darf nicht stillschweigend // Eine Antwort ausserhalb des Vertrags darf nicht stillschweigend
// als leeres Ergebnis interpretiert werden (PROMPT.md §140). // als leeres Ergebnis interpretiert werden (PROMPT.md §140).
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
}); });
it('lehnt eine Fehlerantwort ohne Fehlerkörper ab', async () => { it('lehnt eine Fehlerantwort ohne Fehlerkoerper ab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500))); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500)));
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
}); });
it('reicht einen Abbruch unverändert durch', async () => { it('reicht einen Abbruch unveraendert durch', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')), vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
); );
// Ein Abbruch ist Folge des Aufräumens und kein Fehlerfall. // Ein Abbruch ist Folge des Aufraeumens und kein Fehlerfall.
await expect(requestApi('/health')).rejects.toSatisfy( await expect(requestApi('/health')).rejects.toSatisfy(
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError), (thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
); );

View File

@ -1,8 +1,8 @@
/** /**
* HTTP-Client für die Syncova-API. * HTTP-Client fuer die Syncova-API.
* *
* Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als * Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
* ApiError. Aufrufer müssen sich damit nicht mit HTTP-Details befassen und es * ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden * kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
* (PROMPT.md §140: keine stillen Fehler). * (PROMPT.md §140: keine stillen Fehler).
*/ */
@ -12,15 +12,15 @@ import type { ErrorResponse, SuccessResponse } from '../types/api';
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */ /** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
const API_BASE_PATH = '/api/v1'; const API_BASE_PATH = '/api/v1';
/** Header, über den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ /** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
const CORRELATION_ID_HEADER = 'X-Correlation-ID'; const CORRELATION_ID_HEADER = 'X-Correlation-ID';
/** /**
* Header, der eine Anfrage genau einmal wirken lässt (SYNCOVA_API.md §5). * Header, der eine Anfrage genau einmal wirken laesst (SYNCOVA_API.md §5).
* *
* Er gehört an alle anlegenden und zerstörenden Aufrufe. Ohne ihn erzeugt ein * Er gehoert an alle anlegenden und zerstoerenden Aufrufe. Ohne ihn erzeugt ein
* Doppelklick oder ein wiederholter Versuch nach einer Zeitüberschreitung zwei * Doppelklick oder ein wiederholter Versuch nach einer Zeitueberschreitung zwei
* Aufträge — und bei einer Wiederherstellung zwei gleichzeitige Läufe in * Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in
* dasselbe Ziel. * dasselbe Ziel.
*/ */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
@ -28,8 +28,8 @@ const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/** /**
* Fehler einer API-Anfrage. * Fehler einer API-Anfrage.
* *
* Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender * Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenüber dem Betreiber eindeutig benennen kann. * einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
*/ */
export class ApiError extends Error { export class ApiError extends Error {
/** Stabiler maschinenlesbarer Fehlercode. */ /** Stabiler maschinenlesbarer Fehlercode. */
@ -60,8 +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 erhält sie über * Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
* diese Funktion. Andernfalls entstünde ein Zirkelbezug zwischen dem Client und * diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
* dem Anmeldemodul, das seinerseits den Client verwendet. * dem Anmeldemodul, das seinerseits den Client verwendet.
*/ */
let accessTokenProvider: () => string | null = () => null; let accessTokenProvider: () => string | null = () => null;
@ -71,24 +71,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void
accessTokenProvider = tokenProvider; accessTokenProvider = tokenProvider;
} }
/** Fehlercode für eine nicht erreichbare API. */ /** Fehlercode fuer eine nicht erreichbare API. */
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE'; export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
/** Fehlercode für eine unverständliche Antwort. */ /** Fehlercode fuer eine unverstaendliche Antwort. */
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE'; export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
/** /**
* Erzeugt eine Correlation ID für einen Request. * Erzeugt eine Correlation ID fuer einen Request.
* *
* crypto.randomUUID ist in allen unterstützten Browsern verfügbar; der * crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
* Rückfall deckt ältere Testumgebungen ab. * Rueckfall deckt aeltere Testumgebungen ab.
*/ */
function createCorrelationId(): string { function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID(); return crypto.randomUUID();
} }
// Rückfall ohne kryptografische Garantie - die Correlation ID dient allein // Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein
// der Nachvollziehbarkeit, nicht der Sicherheit. // der Nachvollziehbarkeit, nicht der Sicherheit.
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`; return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
} }
@ -97,22 +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 Anfragekörper, der als JSON gesendet wird. */ /** Optionaler Anfragekoerper, der als JSON gesendet wird. */
body?: unknown; body?: unknown;
/** Signal zum Abbrechen der Anfrage. */ /** Signal zum Abbrechen der Anfrage. */
signal?: AbortSignal; signal?: AbortSignal;
/** /**
* Schlüssel für Idempotenz. * Schluessel fuer Idempotenz.
* *
* `true` erzeugt einen zufälligen Schlüssel; eine Zeichenkette wird * `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird
* unverändert verwendet, damit ein Wiederholungsversuch derselben Handlung * unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung
* denselben Schlüssel trägt. * denselben Schluessel traegt.
*/ */
idempotencyKey?: string | true; idempotencyKey?: string | true;
} }
/** /**
* Führt eine Anfrage gegen die Syncova-API aus. * Fuehrt eine Anfrage gegen die Syncova-API aus.
* *
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health". * @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
* @returns Die Nutzlast der Antwort. * @returns Die Nutzlast der Antwort.
@ -142,7 +142,7 @@ export async function requestApi<TPayload>(
} }
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen // Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
// unauthentifiziert - der Server entscheidet dann über den Zugriff. // unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
const accessToken = accessTokenProvider(); const accessToken = accessTokenProvider();
if (accessToken !== null) { if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`; requestHeaders['Authorization'] = `Bearer ${accessToken}`;
@ -154,7 +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 läuft über ein Cookie bzw. einen Token desselben Ursprungs. // Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs.
credentials: 'same-origin', credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}), ...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
}); });
@ -166,13 +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 prüfen.', message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
statusCode: 0, statusCode: 0,
requestId: correlationId, requestId: correlationId,
}); });
} }
// 204 trägt per Definition keinen Körper. // 204 traegt per Definition keinen Koerper.
if (httpResponse.status === 204) { if (httpResponse.status === 204) {
return undefined as TPayload; return undefined as TPayload;
} }
@ -183,19 +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 unverständlich.', message: 'Die Antwort des Servers war unverstaendlich.',
statusCode: httpResponse.status, statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
}); });
} }
// Maßgeblich ist die Antworthülle, nicht allein der HTTP-Status. // Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status.
// //
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den // Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
// Betriebszustand, die Hülle den Inhalt. GET /api/v1/health nutzt genau diese // Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei // Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
// aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden // aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
// Status außerhalb von 2xx als inhaltsleeren Fehler behandeln, ginge // Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht. // ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
const errorResponse = parsedBody as Partial<ErrorResponse>; const errorResponse = parsedBody as Partial<ErrorResponse>;
if (errorResponse.error) { if (errorResponse.error) {
@ -224,17 +224,17 @@ export async function requestApi<TPayload>(
} }
/** /**
* Lädt eine Datei von der API herunter. * Laedt eine Datei von der API herunter.
* *
* Sie steht neben requestApi und nicht darin: Eine Datei trägt **keine** * Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
* Antworthülle, sondern ist der Inhalt selbst. Würde man sie durch requestApi * Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine * schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
* unverständliche Antwort — obwohl alles in Ordnung ist. * unverstaendliche Antwort — obwohl alles in Ordnung ist.
* *
* Der Fehlerfall geht dagegen sehr wohl durch die Hülle: Scheitert die Anfrage, * Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, bevor die * antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als * Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
* „bericht.pdf" im Download-Ordner, und der Anwender sähe statt einer Meldung * „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung
* eine kaputte Datei. * eine kaputte Datei.
*/ */
export async function downloadApiFile( export async function downloadApiFile(
@ -272,7 +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 prüfen.', message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
statusCode: 0, statusCode: 0,
requestId: correlationId, requestId: correlationId,
}); });
@ -310,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 wählt einen — * Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
* ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner. * ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
*/ */
function parseFileNameFromDisposition(dispositionHeader: string | null): string { function parseFileNameFromDisposition(dispositionHeader: string | null): string {
if (dispositionHeader === null) { if (dispositionHeader === null) {

View File

@ -1,14 +1,14 @@
/** /**
* Allgemeiner Lade-Hook für API-Ressourcen. * Allgemeiner Lade-Hook fuer API-Ressourcen.
* *
* Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis * Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis
* vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein * vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe * leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse
* (PROMPT.md §139). * (PROMPT.md §139).
* *
* Der Hook ersetzt die Wiederholung derselben dreißig Zeilen in jeder Seite. * Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite.
* Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit * Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit
* eigener Fehlerbehandlung wäre ein zweiter Ort, an dem sie fehlen kann. * eigener Fehlerbehandlung waere ein zweiter Ort, an dem sie fehlen kann.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@ -25,17 +25,17 @@ export interface UseApiResourceResult<TPayload> {
readonly data: TPayload | null; readonly data: TPayload | null;
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */ /** Aufgetretener Fehler; null, wenn keiner vorliegt. */
readonly loadError: ApiError | null; readonly loadError: ApiError | null;
/** Lädt die Ressource erneut. */ /** Laedt die Ressource erneut. */
readonly reload: () => void; readonly reload: () => void;
} }
/** /**
* Lädt eine Ressource und hält ihren Zustand. * Laedt eine Ressource und haelt ihren Zustand.
* *
* @param loadResource Ladefunktion; sie erhält ein Abbruchsignal. * @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal.
* @param dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner * @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner
* Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge * Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge
* ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt bei jedem * ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem
* Rendern neu. * Rendern neu.
*/ */
export function useApiResource<TPayload>( export function useApiResource<TPayload>(
@ -45,11 +45,11 @@ export function useApiResource<TPayload>(
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen. // reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
const [reloadCounter, setReloadCounter] = useState(0); const [reloadCounter, setReloadCounter] = useState(0);
// Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus // Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus
// lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen: // laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
// Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein // Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein
// setState im Effektkörper löste dagegen eine zweite Renderrunde aus, // setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus,
// bevor überhaupt etwas geladen wurde. // bevor ueberhaupt etwas geladen wurde.
const [loadResult, setLoadResult] = useState<{ const [loadResult, setLoadResult] = useState<{
key: string; key: string;
data: TPayload | null; data: TPayload | null;
@ -71,7 +71,7 @@ export function useApiResource<TPayload>(
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null }); setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
} catch (caughtError) { } catch (caughtError) {
// Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens. // Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') { if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
return; return;
} }
@ -95,14 +95,14 @@ export function useApiResource<TPayload>(
void loadFromApi(); void loadFromApi();
return () => abortController.abort(); return () => abortController.abort();
// loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern // loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern
// neu gebildete Funktion löste sonst eine Endlosschleife aus. Der // neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der
// effectiveKey steuert das Neuladen ausdrücklich. // effectiveKey steuert das Neuladen ausdruecklich.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveKey]); }, [effectiveKey]);
// Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die // Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die // vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die
// Tabelle also nicht aufblitzen. // Tabelle also nicht aufblitzen.
if (loadResult === null || loadResult.key !== effectiveKey) { if (loadResult === null || loadResult.key !== effectiveKey) {
return { return {

View File

@ -1,24 +1,24 @@
/** /**
* Haken für schreibende Aufrufe. * Haken fuer schreibende Aufrufe.
* *
* Bisher gab es nur `useApiResource` zum Lesen — was daran lag, dass die * 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, * Oberflaeche kaum etwas schreiben konnte. Dieser Haken traegt die drei Dinge,
* die eine verändernde Handlung in einer Betriebskonsole braucht: * die eine veraendernde Handlung in einer Betriebskonsole braucht:
* *
* 1. **Doppelklickschutz.** Solange die Anfrage läuft, wird keine zweite * 1. **Doppelklickschutz.** Solange die Anfrage laeuft, wird keine zweite
* ausgelöst. Bei einem Sicherungslauf wäre die zweite ein 409, bei einer * ausgeloest. Bei einem Sicherungslauf waere die zweite ein 409, bei einer
* Löschung schlimmstenfalls mehr als beabsichtigt. * Loeschung schlimmstenfalls mehr als beabsichtigt.
* 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit * 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit
* der sich ein Vorfall später im Serverprotokoll wiederfinden lässt. * der sich ein Vorfall spaeter im Serverprotokoll wiederfinden laesst.
* 3. **Kein `setState` nach dem Aushängen.** Ein Dialog, der sich nach dem * 3. **Kein `setState` nach dem Aushaengen.** Ein Dialog, der sich nach dem
* Absenden schließt, ist weg, bevor die Antwort da ist. * Absenden schliesst, ist weg, bevor die Antwort da ist.
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from './client'; import { ApiError } from './client';
export interface MutationState { export interface MutationState {
/** Läuft die Anfrage gerade? */ /** Laeuft die Anfrage gerade? */
readonly isRunning: boolean; readonly isRunning: boolean;
/** Fehler des letzten Versuchs, sonst null. */ /** Fehler des letzten Versuchs, sonst null. */
readonly error: ApiError | null; readonly error: ApiError | null;
@ -27,17 +27,17 @@ export interface MutationState {
export interface MutationResult<TArguments extends unknown[], TResult> { export interface MutationResult<TArguments extends unknown[], TResult> {
readonly isRunning: boolean; readonly isRunning: boolean;
readonly error: ApiError | null; readonly error: ApiError | null;
/** Führt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */ /** Fuehrt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */
readonly run: (...callArguments: TArguments) => Promise<TResult | null>; readonly run: (...callArguments: TArguments) => Promise<TResult | null>;
/** Verwirft eine stehende Fehlermeldung. */ /** Verwirft eine stehende Fehlermeldung. */
readonly reset: () => void; readonly reset: () => void;
} }
/** /**
* Baut einen Aufrufer für eine verändernde API-Handlung. * Baut einen Aufrufer fuer eine veraendernde API-Handlung.
* *
* @param performRequest Die eigentliche Anfrage. * @param performRequest Die eigentliche Anfrage.
* @param callbacks Rückmeldungen bei Erfolg und Fehler. * @param callbacks Rueckmeldungen bei Erfolg und Fehler.
*/ */
export function useMutation<TArguments extends unknown[], TResult>( export function useMutation<TArguments extends unknown[], TResult>(
performRequest: (...callArguments: TArguments) => Promise<TResult>, performRequest: (...callArguments: TArguments) => Promise<TResult>,
@ -51,14 +51,14 @@ export function useMutation<TArguments extends unknown[], TResult>(
error: null, error: null,
}); });
// Die Rückmeldungen liegen in einer Referenz, damit `run` seine Identität // Die Rueckmeldungen liegen in einer Referenz, damit `run` seine Identitaet
// behält. Sonst löste jede Renderrunde einen neuen Effekt aus, der `run` // behaelt. Sonst loeste jede Renderrunde einen neuen Effekt aus, der `run`
// in seiner Abhängigkeitsliste hat. // in seiner Abhaengigkeitsliste hat.
const callbacksReference = useRef(callbacks); const callbacksReference = useRef(callbacks);
// Die Zuweisung gehört in einen Effekt, nicht in den Renderkörper: Während // Die Zuweisung gehoert in einen Effekt, nicht in den Renderkoerper: Waehrend
// des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React // des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React
// darf ein Rendern verwerfen oder zweimal ausführen. // darf ein Rendern verwerfen oder zweimal ausfuehren.
useEffect(() => { useEffect(() => {
callbacksReference.current = callbacks; callbacksReference.current = callbacks;
}); });
@ -84,8 +84,8 @@ export function useMutation<TArguments extends unknown[], TResult>(
setMutationState({ isRunning: false, error: null }); setMutationState({ isRunning: false, error: null });
} }
// Die Rückmeldung läuft auch dann, wenn die Komponente weg ist: Ein // Die Rueckmeldung laeuft auch dann, wenn die Komponente weg ist: Ein
// Dialog schließt sich nach dem Absenden, und die Liste dahinter soll // Dialog schliesst sich nach dem Absenden, und die Liste dahinter soll
// sich trotzdem aktualisieren. // sich trotzdem aktualisieren.
callbacksReference.current.onSuccess?.(result); callbacksReference.current.onSuccess?.(result);
@ -139,33 +139,33 @@ export function useMutation<TArguments extends unknown[], TResult>(
/** Bekannte Lagen samt Abhilfe. */ /** Bekannte Lagen samt Abhilfe. */
const errorCodeExplanations: Readonly<Record<string, string>> = { const errorCodeExplanations: Readonly<Record<string, string>> = {
// Diese Lage entsteht bei einem zweiten Anstoß und ist kein Fehler des // Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
// Anwenders — der Auftrag läuft bereits. // Anwenders — der Auftrag laeuft bereits.
JOB_ALREADY_RUNNING: JOB_ALREADY_RUNNING:
'Dieser Auftrag läuft bereits. Ein zweiter Lauf würde in dieselbe Kette schreiben.', 'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.',
RESTORE_ALREADY_RUNNING: RESTORE_ALREADY_RUNNING:
'In dieses Ziel läuft bereits eine Wiederherstellung. Zwei gleichzeitige würden sich gegenseitig überschreiben.', 'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.',
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprüft.', VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.',
TARGET_NOT_EMPTY: TARGET_NOT_EMPTY:
'Das Zielverzeichnis ist nicht leer. Überschreiben verlangt die ausdrückliche Bestätigung.', 'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.',
RESTORE_TARGET_FORBIDDEN: RESTORE_TARGET_FORBIDDEN:
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurückgeschrieben.', 'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.',
LAST_ADMINISTRATOR: LAST_ADMINISTRATOR:
'Das ist der letzte Administrator. Er lässt sich weder löschen noch deaktivieren — sonst wäre die Anlage nicht mehr verwaltbar.', 'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.',
BUILTIN_ROLE_IMMUTABLE: BUILTIN_ROLE_IMMUTABLE:
'Mitgelieferte Rollen sind unveränderlich. Eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.', 'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Aufträge oder Sicherungen.', REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
REPOSITORY_LOCKED: REPOSITORY_LOCKED:
'Das Repository ist gesperrt — vermutlich läuft eine Sicherung. Hält der Zustand an, hilft "syncova-repo break-lock".', 'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".',
REPOSITORY_FULL: REPOSITORY_FULL:
'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf würde die Lage verschärfen.', 'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.',
IMMUTABLE_UNTIL: IMMUTABLE_UNTIL:
'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und lässt sich bis zum Fristende nicht löschen.', 'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.',
LEGAL_HOLD: LEGAL_HOLD:
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.', 'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
SERVICE_UNAVAILABLE: SERVICE_UNAVAILABLE:
'Der Dienst ist derzeit nicht vollständig verfügbar. Das ist kein Problem Ihrer Sitzung.', 'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung für diese Handlung.', FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.',
}; };
/** /**
@ -173,14 +173,14 @@ const errorCodeExplanations: Readonly<Record<string, string>> = {
* *
* **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht, * **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht,
* welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als * welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als
* jede allgemeine Erklärung. Die Tabelle springt nur ein, wenn der Server * jede allgemeine Erklaerung. Die Tabelle springt nur ein, wenn der Server
* keine Meldung mitschickt. * keine Meldung mitschickt.
* *
* Das war zunächst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein * Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
* `SERVICE_UNAVAILABLE` mit der Meldung „Für diesen Bericht ist keine * `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine
* Sicherheitsprüfung eingerichtet." wurde durch den allgemeinen Satz „Der * Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der
* Dienst ist derzeit nicht vollständig verfügbar" ersetzt. Der Betreiber * Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber
* hätte den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses * haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
* einen Berichts. * einen Berichts.
*/ */
export function describeApiError(apiError: ApiError): string { export function describeApiError(apiError: ApiError): string {
@ -194,10 +194,10 @@ export function describeApiError(apiError: ApiError): string {
} }
/** /**
* Liefert die allgemeine Erklärung zu einem Fehlercode, sofern es eine gibt. * Liefert die allgemeine Erklaerung zu einem Fehlercode, sofern es eine gibt.
* *
* Gedacht als **Ergänzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage * Gedacht als **Ergaenzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage
* eine bekannte Abhilfe hat, lässt sie sich zusätzlich anzeigen. * eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen.
*/ */
export function explainApiErrorCode(errorCode: string): string | undefined { export function explainApiErrorCode(errorCode: string): string | undefined {
return errorCodeExplanations[errorCode]; return errorCodeExplanations[errorCode];

View File

@ -1,92 +0,0 @@
Copyright (c) 2023 Vercel, in collaboration with basement.studio
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,20 +1,19 @@
/** /**
* Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt. * Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt.
* *
* Die Seitenleiste lässt sich einklappen und merkt sich das. Der Grund ist * Die Seitenleiste laesst sich einklappen und merkt sich das. Der Grund ist
* nicht Ästhetik: Wer eine Störung bearbeitet, hat oft eine breite Tabelle mit * nicht Aesthetik: Wer eine Stoerung bearbeitet, hat oft eine breite Tabelle mit
* Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menü sind dann im Weg. * Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menue sind dann im Weg.
* *
* Auf schmalen Geräten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit * Auf schmalen Geraeten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit
* auf Tablets (PROMPT.md §68) — und ein Bereitschaftsdienst sieht nachts als * auf Tablets (PROMPT.md §68) — und ein Bereitschaftsdienst sieht nachts als
* Erstes auf ein Telefon. * Erstes auf ein Telefon.
*/ */
import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, Timer, X } from 'lucide-react'; import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, X } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import { formatSessionClock, type SessionClock } from '@/features/auth/useSession';
import { import {
ALL_PAGES, ALL_PAGES,
SECTION_LABELS, SECTION_LABELS,
@ -44,7 +43,6 @@ export function AppShell({
onNavigate, onNavigate,
grantedPermissions, grantedPermissions,
userName, userName,
sessionClock,
onLogout, onLogout,
children, children,
}: { }: {
@ -52,8 +50,6 @@ export function AppShell({
readonly onNavigate: (pageIdentifier: string) => void; readonly onNavigate: (pageIdentifier: string) => void;
readonly grantedPermissions: readonly string[]; readonly grantedPermissions: readonly string[];
readonly userName: string; readonly userName: string;
/** Verbleibende Sitzungszeit für die Anzeige. */
readonly sessionClock: SessionClock;
readonly onLogout: () => void; readonly onLogout: () => void;
readonly children: ReactNode; readonly children: ReactNode;
}) { }) {
@ -64,7 +60,7 @@ export function AppShell({
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort // Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort
// hängen — und weil ein Dialog im Portal sonst im falschen Thema erschiene. // haengen — und weil ein Dialog im Portal sonst im falschen Thema erschiene.
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', themePreference); document.documentElement.setAttribute('data-theme', themePreference);
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
@ -75,19 +71,19 @@ export function AppShell({
return ( return (
<div className="flex min-h-screen bg-page"> <div className="flex min-h-screen bg-page">
{/* Abdunklung hinter der Schublade auf schmalen Geräten. */} {/* Abdunklung hinter der Schublade auf schmalen Geraeten. */}
{isMobileMenuOpen ? ( {isMobileMenuOpen ? (
<button <button
type="button" type="button"
className="fixed inset-0 z-30 bg-black/50 lg:hidden" className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menü schließen" aria-label="Menue schliessen"
/> />
) : null} ) : null}
<aside <aside
className={cn( className={cn(
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-line bg-sidebar transition-[width,transform]', 'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-line bg-card transition-[width,transform]',
isSidebarCollapsed ? 'w-16' : 'w-60', isSidebarCollapsed ? 'w-16' : 'w-60',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0', isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
)} )}
@ -103,7 +99,7 @@ export function AppShell({
type="button" type="button"
className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden" className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menü schließen" aria-label="Menue schliessen"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
@ -132,7 +128,7 @@ export function AppShell({
isActive={page.id === currentPageId} isActive={page.id === currentPageId}
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
onSelect={() => { onSelect={() => {
// Die Schublade schließt sich beim Auswählen, nicht // Die Schublade schliesst sich beim Auswaehlen, nicht
// in einem Effekt auf den Seitenwechsel: Der Anlass // in einem Effekt auf den Seitenwechsel: Der Anlass
// ist die Handlung, nicht der neue Zustand. // ist die Handlung, nicht der neue Zustand.
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
@ -159,7 +155,7 @@ export function AppShell({
}) })
} }
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" 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'} title={isSidebarCollapsed ? 'Menue ausklappen' : 'Menue einklappen'}
> >
{isSidebarCollapsed ? ( {isSidebarCollapsed ? (
<PanelLeft className="size-4 shrink-0" /> <PanelLeft className="size-4 shrink-0" />
@ -184,7 +180,7 @@ export function AppShell({
type="button" type="button"
className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden" className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden"
onClick={() => setIsMobileMenuOpen(true)} onClick={() => setIsMobileMenuOpen(true)}
aria-label="Menü öffnen" aria-label="Menue oeffnen"
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
@ -212,23 +208,6 @@ export function AppShell({
{userName} {userName}
</span> </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}> <Button variant="ghost" size="sm" onClick={onLogout}>
Abmelden Abmelden
</Button> </Button>
@ -265,8 +244,8 @@ function NavigationItem({
isActive isActive
? 'bg-accent-subtle font-medium text-accent' ? 'bg-accent-subtle font-medium text-accent'
: 'text-fg-muted hover:bg-hover hover:text-fg', : 'text-fg-muted hover:bg-hover hover:text-fg',
// Ein noch nicht verfügbarer Bereich bleibt anklickbar — die Zielseite // Ein noch nicht verfuegbarer Bereich bleibt anklickbar — die Zielseite
// erklärt, was fehlt. Ihn zu sperren verschwiege den Ausbaustand. // erklaert, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
!page.available && 'opacity-60', !page.available && 'opacity-60',
)} )}
> >

View File

@ -1,112 +0,0 @@
/**
* Fehlergrenze um den Seiteninhalt.
*
* Ohne sie reißt ein einziger Fehler in einer Komponente den gesamten Baum ab —
* React hängt alles aus, und übrig bleibt eine leere Seite. Im dunklen Thema ist
* das ein schwarzer Bildschirm ohne jeden Hinweis, und ein Betreiber hat keine
* Möglichkeit zu melden, was schiefging.
*
* Die Grenze sitzt **um den Inhalt**, nicht um die ganze Anwendung: Seitenleiste
* und Kopfzeile bleiben stehen, und man kann auf eine andere Seite wechseln,
* statt neu laden zu müssen.
*
* Klassenkomponente, weil React für Fehlergrenzen keine Entsprechung als Haken
* anbietet.
*/
import { AlertTriangle } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui';
interface ErrorBoundaryProps {
/** Wechselt der Wert, wird der Fehler verworfen — etwa beim Seitenwechsel. */
readonly resetKey: string;
readonly children: ReactNode;
}
interface ErrorBoundaryState {
readonly caughtError: Error | null;
readonly componentStack: string;
readonly resetKey: string;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
public constructor(properties: ErrorBoundaryProps) {
super(properties);
this.state = { caughtError: null, componentStack: '', resetKey: properties.resetKey };
}
public static getDerivedStateFromError(caughtError: Error): Partial<ErrorBoundaryState> {
return { caughtError };
}
/**
* Verwirft den Fehler beim Seitenwechsel.
*
* Abgeleitet statt in einem Effekt gesetzt: Sonst zeigte die neue Seite noch
* einen Wimpernschlag lang den Fehler der alten.
*/
public static getDerivedStateFromProps(
nextProperties: ErrorBoundaryProps,
currentState: ErrorBoundaryState,
): Partial<ErrorBoundaryState> | null {
if (nextProperties.resetKey !== currentState.resetKey) {
return { caughtError: null, componentStack: '', resetKey: nextProperties.resetKey };
}
return null;
}
public override componentDidCatch(caughtError: Error, errorInfo: ErrorInfo): void {
this.setState({ componentStack: errorInfo.componentStack ?? '' });
// Die Meldung gehört in die Entwicklerkonsole, damit sie sich kopieren
// lässt. Sie hier zu verschlucken wäre der stille Fehler, den die
// Entwicklungsregeln ausdrücklich verbieten.
console.error('Unbehandelter Fehler in der Oberfläche:', caughtError, errorInfo);
}
public override render(): ReactNode {
const { caughtError, componentStack } = this.state;
if (caughtError === null) {
return this.props.children;
}
return (
<div className="rounded-lg border border-critical/30 bg-card p-6" role="alert">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg">
Diese Seite konnte nicht angezeigt werden
</h2>
<p className="mt-1 text-sm text-fg-muted">
Die übrige Konsole arbeitet weiter. Wechseln Sie den Bereich oder
laden Sie neu.
</p>
<pre className="mt-4 max-h-40 overflow-auto rounded-md bg-sunken p-3 text-xs text-fg">
{caughtError.message || caughtError.name}
{componentStack ? `\n${componentStack.trim().split('\n').slice(0, 6).join('\n')}` : ''}
</pre>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => this.setState({ caughtError: null, componentStack: '' })}
>
Erneut versuchen
</Button>
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
Seite neu laden
</Button>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -1,4 +1,4 @@
/** Kopfbereich einer Seite: Titel, Erläuterung, Handlungen. */ /** Kopfbereich einer Seite: Titel, Erlaeuterung, Handlungen. */
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -10,7 +10,7 @@ export function PageHeader({
description, description,
actions, actions,
onBack, onBack,
backLabel = 'Zurück', backLabel = 'Zurueck',
className, className,
}: { }: {
readonly title: ReactNode; readonly title: ReactNode;

View File

@ -1,10 +1,10 @@
/** /**
* Schaltfläche. * Schaltflaeche.
* *
* Die Ausprägung "destructive" ist bewusst eigenständig und nicht nur eine * Die Auspraegung "destructive" ist bewusst eigenstaendig und nicht nur eine
* rote Farbe: Löschende Handlungen sollen sich auch dann unterscheiden, wenn * rote Farbe: Loeschende Handlungen sollen sich auch dann unterscheiden, wenn
* jemand die Oberfläche in Graustufen betrachtet oder farbfehlsichtig ist. * jemand die Oberflaeche in Graustufen betrachtet oder farbfehlsichtig ist.
* Deshalb trägt sie zusätzlich einen kräftigen Rand. * Deshalb traegt sie zusaetzlich einen kraeftigen Rand.
*/ */
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
@ -14,9 +14,9 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
// Gemeinsame Grundlage aller Ausprägungen. // Gemeinsame Grundlage aller Auspraegungen.
[ [
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md',
'text-sm font-medium transition-colors', 'text-sm font-medium transition-colors',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
@ -50,7 +50,7 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
/** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */ /** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */
readonly asChild?: boolean | undefined; readonly asChild?: boolean | undefined;
/** Zeigt einen Ladeanzeiger und sperrt die Schaltfläche. */ /** Zeigt einen Ladeanzeiger und sperrt die Schaltflaeche. */
readonly isLoading?: boolean | undefined; readonly isLoading?: boolean | undefined;
} }
@ -64,9 +64,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
<RenderedComponent <RenderedComponent
ref={forwardedReference} ref={forwardedReference}
className={cn(buttonVariants({ variant, size }), className)} className={cn(buttonVariants({ variant, size }), className)}
// Während eine Handlung läuft, darf sie nicht ein zweites Mal ausgelöst // Waehrend eine Handlung laeuft, darf sie nicht ein zweites Mal ausgeloest
// werden. Bei einem Sicherungslauf wäre der zweite Klick ein 409, bei // werden. Bei einem Sicherungslauf waere der zweite Klick ein 409, bei
// einer Löschung schlimmstenfalls mehr als beabsichtigt. // einer Loeschung schlimmstenfalls mehr als beabsichtigt.
disabled={disabled || isLoading} disabled={disabled || isLoading}
{...restProps} {...restProps}
> >

View File

@ -1,27 +1,12 @@
/** Karte — die Grundfläche aller Inhalte. */ /** Karte — die Grundflaeche aller Inhalte. */
import { Slot } from '@radix-ui/react-slot';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function Card({ export function Card({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
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 ( return (
<RenderedComponent <div
className={cn('border border-line bg-card', className)} className={cn('rounded-lg border border-line bg-card', className)}
{...restProps} {...restProps}
/> />
); );
@ -41,7 +26,7 @@ export function CardHeader({
return ( return (
<div <div
className={cn( className={cn(
'flex items-start justify-between gap-4 border-b border-line px-5 py-3.5', 'flex items-start justify-between gap-4 border-b border-line px-5 py-4',
className, className,
)} )}
{...restProps} {...restProps}

View File

@ -1,14 +1,14 @@
/** /**
* Tabelle. * Tabelle.
* *
* Eine Betriebskonsole besteht zu großen Teilen aus Tabellen, deshalb steckt * Eine Betriebskonsole besteht zu grossen Teilen aus Tabellen, deshalb steckt
* die Mühe hier und nicht in Effekten: * die Muehe hier und nicht in Effekten:
* *
* - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter * - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter
* trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte; * trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte;
* die erste verlangt eine Schaltfläche, die zweite nicht. * die erste verlangt eine Schaltflaeche, die zweite nicht.
* - **Während des Nachladens bleibt die alte Tabelle stehen** und wird nur * - **Waehrend des Nachladens bleibt die alte Tabelle stehen** und wird nur
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen lässt die Seite bei * abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen laesst die Seite bei
* jedem Filterwechsel aufblitzen. * jedem Filterwechsel aufblitzen.
* - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite. * - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite.
*/ */
@ -20,7 +20,7 @@ export interface TableColumn<RowType> {
readonly key: string; readonly key: string;
readonly header: ReactNode; readonly header: ReactNode;
readonly render: (row: RowType) => ReactNode; readonly render: (row: RowType) => ReactNode;
/** Rechtsbündig — für Zahlen. */ /** Rechtsbuendig — fuer Zahlen. */
readonly numeric?: boolean | undefined; readonly numeric?: boolean | undefined;
readonly className?: string | undefined; readonly className?: string | undefined;
} }
@ -31,7 +31,7 @@ export function DataTable<RowType>({
getRowKey, getRowKey,
onRowClick, onRowClick,
isLoading = false, isLoading = false,
emptyTitle = 'Keine Einträge', emptyTitle = 'Keine Eintraege',
emptyDescription, emptyDescription,
emptyAction, emptyAction,
className, className,
@ -62,13 +62,13 @@ export function DataTable<RowType>({
<div className={cn('w-full overflow-x-auto', className)}> <div className={cn('w-full overflow-x-auto', className)}>
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
<tr className="border-b border-line bg-sunken/50"> <tr className="border-b border-line">
{columns.map((column) => ( {columns.map((column) => (
<th <th
key={column.key} key={column.key}
scope="col" scope="col"
className={cn( className={cn(
'whitespace-nowrap px-4 py-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle', 'whitespace-nowrap px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-fg-subtle',
column.numeric ? 'text-right' : 'text-left', column.numeric ? 'text-right' : 'text-left',
column.className, column.className,
)} )}

View File

@ -3,12 +3,12 @@
* *
* Zwei Bauarten, und der Unterschied ist fachlich: * Zwei Bauarten, und der Unterschied ist fachlich:
* *
* - `Dialog` für Eingaben (anlegen, bearbeiten). Er lässt sich mit Escape und * - `Dialog` fuer Eingaben (anlegen, bearbeiten). Er laesst sich mit Escape und
* einem Klick daneben schließen. * einem Klick daneben schliessen.
* - `ConfirmDialog` für Handlungen, die etwas verändern. Er verlangt eine * - `ConfirmDialog` fuer Handlungen, die etwas veraendern. Er verlangt eine
* ausdrückliche Bestätigung. Bei zerstörenden Handlungen kann er zusätzlich * ausdrueckliche Bestaetigung. Bei zerstoerenden Handlungen kann er zusaetzlich
* ein wörtlich einzutippendes Wort verlangen — dasselbe Mittel, das * ein woertlich einzutippendes Wort verlangen — dasselbe Mittel, das
* `uninstall.sh` und die Wiederherstellung mit Überschreiben verwenden. Ein * `uninstall.sh` und die Wiederherstellung mit Ueberschreiben verwenden. Ein
* versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus, * versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus,
* und ein Klick aus Gewohnheit auch nicht. * und ein Klick aus Gewohnheit auch nicht.
*/ */
@ -46,7 +46,7 @@ export function DialogContent({
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2', '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', 'max-h-[calc(100vh-4rem)] overflow-y-auto',
'border border-line-strong bg-card', 'rounded-lg border border-line bg-card shadow-xl',
sizeClasses[size], sizeClasses[size],
className, className,
)} )}
@ -54,7 +54,7 @@ export function DialogContent({
{children} {children}
<DialogPrimitive.Close <DialogPrimitive.Close
className="absolute right-4 top-4 rounded-md p-1 text-fg-muted transition-colors hover:bg-hover hover:text-fg" 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" aria-label="Schliessen"
> >
<X className="size-4" /> <X className="size-4" />
</DialogPrimitive.Close> </DialogPrimitive.Close>
@ -103,19 +103,19 @@ export function DialogFooter({ children }: { readonly children: ReactNode }) {
} }
/** /**
* Bestätigungsdialog für verändernde Handlungen. * Bestaetigungsdialog fuer veraendernde Handlungen.
* *
* `confirmationWord` ist die zweite Hürde: Wird es gesetzt, muss der Betreiber * `confirmationWord` ist die zweite Huerde: Wird es gesetzt, muss der Betreiber
* es wörtlich eintippen. Das gehört ausschließlich an Handlungen, die Daten * es woertlich eintippen. Das gehoert ausschliesslich an Handlungen, die Daten
* unwiederbringlich entfernen oder überschreiben — ein Ritual ohne Anlass * unwiederbringlich entfernen oder ueberschreiben — ein Ritual ohne Anlass
* gewöhnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt. * gewoehnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
*/ */
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
onOpenChange, onOpenChange,
title, title,
description, description,
confirmLabel = 'Bestätigen', confirmLabel = 'Bestaetigen',
cancelLabel = 'Abbrechen', cancelLabel = 'Abbrechen',
confirmationWord, confirmationWord,
confirmationHint, confirmationHint,
@ -130,7 +130,7 @@ export function ConfirmDialog({
readonly description?: ReactNode | undefined; readonly description?: ReactNode | undefined;
readonly confirmLabel?: string | undefined; readonly confirmLabel?: string | undefined;
readonly cancelLabel?: string | undefined; readonly cancelLabel?: string | undefined;
/** Wird gesetzt, muss der Betreiber dieses Wort wörtlich eintippen. */ /** Wird gesetzt, muss der Betreiber dieses Wort woertlich eintippen. */
readonly confirmationWord?: string | undefined; readonly confirmationWord?: string | undefined;
readonly confirmationHint?: ReactNode | undefined; readonly confirmationHint?: ReactNode | undefined;
readonly isDestructive?: boolean | undefined; readonly isDestructive?: boolean | undefined;
@ -141,10 +141,10 @@ export function ConfirmDialog({
return ( return (
<DialogRoot open={open} onOpenChange={onOpenChange}> <DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogContent size="sm"> <DialogContent size="sm">
{/* Der Inhalt hängt im Portal und wird beim Schließen ausgehängt. {/* Der Inhalt haengt im Portal und wird beim Schliessen ausgehaengt.
Damit verschwindet die eingetippte Bestätigung von selbst — ein Damit verschwindet die eingetippte Bestaetigung von selbst — ein
Zurücksetzen im Effekt braucht es nicht, und die Hürde steht beim Zuruecksetzen im Effekt braucht es nicht, und die Huerde steht beim
nächsten Öffnen wieder. */} naechsten Oeffnen wieder. */}
<ConfirmDialogBody <ConfirmDialogBody
title={title} title={title}
description={description} description={description}
@ -164,7 +164,7 @@ export function ConfirmDialog({
); );
} }
/** Inhalt des Bestätigungsdialogs; hält die eingetippte Bestätigung. */ /** Inhalt des Bestaetigungsdialogs; haelt die eingetippte Bestaetigung. */
function ConfirmDialogBody({ function ConfirmDialogBody({
title, title,
description, description,
@ -208,7 +208,7 @@ function ConfirmDialogBody({
> >
{confirmationHint ?? ( {confirmationHint ?? (
<> <>
Tippen Sie zur Bestätigung{' '} Tippen Sie zur Bestaetigung{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{confirmationWord} {confirmationWord}
</code> </code>

View File

@ -4,8 +4,8 @@
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort * `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist * auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit * nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
* Begründung, nie als "0" — eine Null liest sich wie "geprüft und in * Begruendung, nie als "0" — eine Null liest sich wie "geprueft und in
* Ordnung", und genau das wäre falsch. * Ordnung", und genau das waere falsch.
*/ */
import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react'; import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react';
@ -84,9 +84,9 @@ export function EmptyState({
} }
/** /**
* Hinweis für eine Kennzahl ohne Datengrundlage. * Hinweis fuer eine Kennzahl ohne Datengrundlage.
* *
* Der Grund gehört dazu. "—" allein sieht nach einem Fehler der Oberfläche * Der Grund gehoert dazu. "—" allein sieht nach einem Fehler der Oberflaeche
* aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine * aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine
* Handlungsanweisung. * Handlungsanweisung.
*/ */
@ -99,7 +99,7 @@ export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
); );
} }
/** Auffälliger Hinweiskasten. */ /** Auffaelliger Hinweiskasten. */
export function Callout({ export function Callout({
tone = 'info', tone = 'info',
title, title,
@ -114,9 +114,9 @@ export function Callout({
/** /**
* ARIA-Rolle. * ARIA-Rolle.
* *
* Standard ist `note` — die meisten Hinweise erklären etwas und stehen schon * Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon
* beim Öffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung** * beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
* erscheint, gehört dagegen auf `alert`: Ein Screenreader sagt ihn dann an, * erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
* statt ihn im Seiteninhalt untergehen zu lassen. * statt ihn im Seiteninhalt untergehen zu lassen.
*/ */
readonly role?: 'note' | 'alert' | 'status' | undefined; readonly role?: 'note' | 'alert' | 'status' | undefined;
@ -145,7 +145,7 @@ export function Callout({
); );
} }
/** Beschriftetes Wertepaar für Detailseiten. */ /** Beschriftetes Wertepaar fuer Detailseiten. */
export function DetailItem({ export function DetailItem({
label, label,
children, children,

View File

@ -1,10 +1,10 @@
/** /**
* Formularelemente. * Formularelemente.
* *
* Jedes Feld trägt eine Beschriftung, die mit ihm verbunden ist (`htmlFor`), * Jedes Feld traegt eine Beschriftung, die mit ihm verbunden ist (`htmlFor`),
* und optional einen Hinweistext. Der Hinweis ist keine Zierde: In einer * und optional einen Hinweistext. Der Hinweis ist keine Zierde: In einer
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" über * Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" ueber
* Datenverlust, und ein Betreiber soll nicht raten müssen, was die Anlage mit * Datenverlust, und ein Betreiber soll nicht raten muessen, was die Anlage mit
* seiner Eingabe tut. * seiner Eingabe tut.
*/ */
@ -12,7 +12,7 @@ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type Selec
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const fieldClasses = [ const fieldClasses = [
'w-full rounded-lg border border-line-strong bg-card px-3 py-2 text-sm text-fg transition-colors', 'w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg',
'placeholder:text-fg-subtle', 'placeholder:text-fg-subtle',
'disabled:cursor-not-allowed disabled:opacity-60', 'disabled:cursor-not-allowed disabled:opacity-60',
].join(' '); ].join(' ');
@ -150,7 +150,7 @@ export const SelectInput = forwardRef<
); );
}); });
/** Kontrollkästchen mit Beschriftung und Erläuterung. */ /** Kontrollkaestchen mit Beschriftung und Erlaeuterung. */
export function CheckboxField({ export function CheckboxField({
checked, checked,
onCheckedChange, onCheckedChange,
@ -177,7 +177,7 @@ export function CheckboxField({
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)} onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)}
className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent)]" className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[hsl(var(--accent))]"
/> />
<div className="min-w-0"> <div className="min-w-0">
<label <label

View File

@ -1,48 +1,48 @@
/** /**
* Prüft die Zuordnung der Fachbegriffe auf die fünf Bedeutungen. * Prueft die Zuordnung der Fachbegriffe auf die fuenf Bedeutungen.
* *
* Diese Tabelle ist der eine Ort, an dem ein falscher Ton die Aussage der * 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 * ganzen Anlage umkehren kann: Ein gruenes Abzeichen an einem Teilfehler oder
* an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und * an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und
* niemand sieht es, weil die Oberfläche einwandfrei aussieht. * niemand sieht es, weil die Oberflaeche einwandfrei aussieht.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveStatus } from './StatusBadge'; import { resolveStatus } from './StatusBadge';
describe('Statuszuordnung', () => { describe('Statuszuordnung', () => {
it('färbt einen Teilfehler niemals grün', () => { it('faerbt einen Teilfehler niemals gruen', () => {
// Entwicklungsregel 1: Teilfehler heißen PARTIAL FAILURE, nie SUCCESS. // Entwicklungsregel 1: Teilfehler heissen PARTIAL FAILURE, nie SUCCESS.
expect(resolveStatus('partial_failure').tone).not.toBe('healthy'); expect(resolveStatus('partial_failure').tone).not.toBe('healthy');
expect(resolveStatus('partial_failure').tone).toBe('warning'); expect(resolveStatus('partial_failure').tone).toBe('warning');
}); });
it('färbt ein gesichertes, aber ungepruftes Backup nicht grün', () => { it('faerbt ein gesichertes, aber ungepruftes Backup nicht gruen', () => {
// `successful` heißt "der Lauf ist durchgelaufen" — nicht // `successful` heisst "der Lauf ist durchgelaufen" — nicht
// "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf // "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf
// `recoverable`. // `recoverable`.
expect(resolveStatus('successful').tone).toBe('neutral'); expect(resolveStatus('successful').tone).toBe('neutral');
expect(resolveStatus('recoverable').tone).toBe('healthy'); expect(resolveStatus('recoverable').tone).toBe('healthy');
}); });
it('behandelt ein beschädigtes Backup als kritisch, nicht als Warnung', () => { it('behandelt ein beschaedigtes Backup als kritisch, nicht als Warnung', () => {
// Ein Backup mit einem beschädigten Block ist nicht zu 70 Prozent // Ein Backup mit einem beschaedigten Block ist nicht zu 70 Prozent
// wiederherstellbar, sondern gar nicht. // wiederherstellbar, sondern gar nicht.
expect(resolveStatus('corrupted').tone).toBe('critical'); expect(resolveStatus('corrupted').tone).toBe('critical');
}); });
it('färbt einen nur softwareseitig durchgesetzten Löschschutz als Warnung', () => { it('faerbt einen nur softwareseitig durchgesetzten Loeschschutz als Warnung', () => {
// `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups // `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups
// löschen. Das als "in Ordnung" zu zeigen wäre die gefährlichste // loeschen. Das als "in Ordnung" zu zeigen waere die gefaehrlichste
// Beschönigung der Anlage. // Beschoenigung der Anlage.
expect(resolveStatus('advisory').tone).toBe('warning'); expect(resolveStatus('advisory').tone).toBe('warning');
expect(resolveStatus('filesystem').tone).toBe('healthy'); expect(resolveStatus('filesystem').tone).toBe('healthy');
}); });
it('zeigt einen unbekannten Zustand neutral und niemals grün', () => { it('zeigt einen unbekannten Zustand neutral und niemals gruen', () => {
// Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht // Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht
// als "in Ordnung" durchgehen. Grün wäre der bequeme Standard und der // als "in Ordnung" durchgehen. Gruen waere der bequeme Standard und der
// gefährlichste. // gefaehrlichste.
const unknownStatus = resolveStatus('ein_neuer_serverzustand'); const unknownStatus = resolveStatus('ein_neuer_serverzustand');
expect(unknownStatus.tone).toBe('neutral'); expect(unknownStatus.tone).toBe('neutral');
@ -57,7 +57,7 @@ describe('Statuszuordnung', () => {
it('kennt jede Einstufung, die der Server vergeben kann', () => { it('kennt jede Einstufung, die der Server vergeben kann', () => {
// Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort // Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort
// eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der // eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der
// Oberfläche als englischer Rohwert. // Oberflaeche als englischer Rohwert.
for (const classification of [ for (const classification of [
'failed', 'failed',
'corrupted', 'corrupted',
@ -67,7 +67,7 @@ describe('Statuszuordnung', () => {
]) { ]) {
expect( expect(
resolveStatus(classification).label, resolveStatus(classification).label,
`Einstufung ${classification} ist nicht übersetzt`, `Einstufung ${classification} ist nicht uebersetzt`,
).not.toBe(classification); ).not.toBe(classification);
} }
}); });

View File

@ -1,29 +1,29 @@
/** /**
* Statusanzeige — die wichtigste Komponente der Konsole. * Statusanzeige — die wichtigste Komponente der Konsole.
* *
* Sie bildet die Fachbegriffe der API auf die fünf Bedeutungen aus PROMPT.md * Sie bildet die Fachbegriffe der API auf die fuenf Bedeutungen aus PROMPT.md
* §106 ab. Dass diese Abbildung **an einer einzigen Stelle** steht, ist der * §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 * eigentliche Punkt: Waere sie ueber die Seiten verteilt, wuerde frueher oder
* später irgendwo "partial_failure" grün erscheinen — und ein Betreiber hält * spaeter irgendwo "partial_failure" gruen erscheinen — und ein Betreiber haelt
* einen Teilfehler dann für einen Erfolg. * einen Teilfehler dann fuer einen Erfolg.
* *
* Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den * Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den
* Entscheidungen der Phasen: * Entscheidungen der Phasen:
* *
* - "partial_failure" ist **niemals** grün (Entwicklungsregel 1). * - "partial_failure" ist **niemals** gruen (Entwicklungsregel 1).
* - "unverified" ist neutral, nicht grün: Ohne Wiederherstellungstest ist die * - "unverified" ist neutral, nicht gruen: Ohne Wiederherstellungstest ist die
* Wiederherstellbarkeit unbekannt, und Unbekanntes zählt nie als gut * Wiederherstellbarkeit unbekannt, und Unbekanntes zaehlt nie als gut
* (Phase 10). * (Phase 10).
* - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschädigtes Backup * - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschaedigtes Backup
* ist nicht zu 70 % wiederherstellbar, sondern gar nicht. * ist nicht zu 70 % wiederherstellbar, sondern gar nicht.
* - "advisory" beim Löschschutz ist eine Warnung: Der Schutz ist eine * - "advisory" beim Loeschschutz ist eine Warnung: Der Schutz ist eine
* Software-Regel, kein Schutz des Dateisystems (Phase 11). * Software-Regel, kein Schutz des Dateisystems (Phase 11).
*/ */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Die fünf Bedeutungen. Mehr gibt es nicht. */ /** Die fuenf Bedeutungen. Mehr gibt es nicht. */
export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral'; export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral';
interface StatusDefinition { interface StatusDefinition {
@ -34,21 +34,21 @@ interface StatusDefinition {
/** /**
* Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung. * Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung.
* *
* Ein unbekannter Schlüssel wird bewusst **neutral** dargestellt und mit dem * Ein unbekannter Schluessel wird bewusst **neutral** dargestellt und mit dem
* Rohwert beschriftet — nicht grün. Ein neuer Serverzustand, den diese Tabelle * Rohwert beschriftet — nicht gruen. Ein neuer Serverzustand, den diese Tabelle
* noch nicht kennt, darf nicht als "in Ordnung" durchgehen. * noch nicht kennt, darf nicht als "in Ordnung" durchgehen.
*/ */
const statusDefinitions: Readonly<Record<string, StatusDefinition>> = { const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Läufe --- // --- Laeufe ---
succeeded: { tone: 'healthy', label: 'Erfolgreich' }, succeeded: { tone: 'healthy', label: 'Erfolgreich' },
running: { tone: 'info', label: 'Läuft' }, running: { tone: 'info', label: 'Laeuft' },
queued: { tone: 'info', label: 'Eingereiht' }, queued: { tone: 'info', label: 'Eingereiht' },
pending: { tone: 'neutral', label: 'Ausstehend' }, pending: { tone: 'neutral', label: 'Ausstehend' },
partial_failure: { tone: 'warning', label: 'Teilfehler' }, partial_failure: { tone: 'warning', label: 'Teilfehler' },
failed: { tone: 'critical', label: 'Fehlgeschlagen' }, failed: { tone: 'critical', label: 'Fehlgeschlagen' },
cancelled: { tone: 'neutral', label: 'Abgebrochen' }, cancelled: { tone: 'neutral', label: 'Abgebrochen' },
// --- Aufträge --- // --- Auftraege ---
enabled: { tone: 'healthy', label: 'Aktiv' }, enabled: { tone: 'healthy', label: 'Aktiv' },
paused: { tone: 'warning', label: 'Angehalten' }, paused: { tone: 'warning', label: 'Angehalten' },
disabled: { tone: 'neutral', label: 'Deaktiviert' }, disabled: { tone: 'neutral', label: 'Deaktiviert' },
@ -58,17 +58,17 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// Das Vokabular lautet: failed, successful, verified, recoverable, corrupted. // Das Vokabular lautet: failed, successful, verified, recoverable, corrupted.
// //
// `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht // `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht
// grün: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass // gruen: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
// sich daraus etwas wiederherstellen lässt. Ein grünes Abzeichen läse sich // sich daraus etwas wiederherstellen laesst. Ein gruenes Abzeichen laese sich
// hier als "geprüft und in Ordnung", und genau diese Verwechslung soll die // hier als "geprueft und in Ordnung", und genau diese Verwechslung soll die
// ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf // ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf
// `recoverable`; alles davor ist ein Indiz. // `recoverable`; alles davor ist ein Indiz.
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' }, recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
verified: { tone: 'info', label: 'Geprüft' }, verified: { tone: 'info', label: 'Geprueft' },
successful: { tone: 'neutral', label: 'Gesichert, ungeprüft' }, successful: { tone: 'neutral', label: 'Gesichert, ungeprueft' },
corrupted: { tone: 'critical', label: 'Beschädigt' }, corrupted: { tone: 'critical', label: 'Beschaedigt' },
// --- Prüfergebnisse --- // --- Pruefergebnisse ---
clean: { tone: 'healthy', label: 'Ohne Befund' }, clean: { tone: 'healthy', label: 'Ohne Befund' },
findings: { tone: 'critical', label: 'Mit Befund' }, findings: { tone: 'critical', label: 'Mit Befund' },
@ -78,7 +78,7 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
unavailable: { tone: 'critical', label: 'Nicht erreichbar' }, unavailable: { tone: 'critical', label: 'Nicht erreichbar' },
maintenance: { tone: 'warning', label: 'Wartung' }, maintenance: { tone: 'warning', label: 'Wartung' },
// --- Durchsetzungsstufe des Löschschutzes (Phase 11) --- // --- Durchsetzungsstufe des Loeschschutzes (Phase 11) ---
filesystem: { tone: 'healthy', label: 'Dateisystem' }, filesystem: { tone: 'healthy', label: 'Dateisystem' },
storage: { tone: 'healthy', label: 'Speichersystem' }, storage: { tone: 'healthy', label: 'Speichersystem' },
advisory: { tone: 'warning', label: 'Nur Software' }, advisory: { tone: 'warning', label: 'Nur Software' },
@ -86,15 +86,15 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Gesundheit --- // --- Gesundheit ---
healthy: { tone: 'healthy', label: 'Fehlerfrei' }, healthy: { tone: 'healthy', label: 'Fehlerfrei' },
degraded: { tone: 'warning', label: 'Eingeschränkt' }, degraded: { tone: 'warning', label: 'Eingeschraenkt' },
critical: { tone: 'critical', label: 'Kritisch' }, critical: { tone: 'critical', label: 'Kritisch' },
unknown: { tone: 'neutral', label: 'Unbekannt' }, unknown: { tone: 'neutral', label: 'Unbekannt' },
// --- Meldungen --- // --- Meldungen ---
open: { tone: 'critical', label: 'Offen' }, open: { tone: 'critical', label: 'Offen' },
acknowledged: { tone: 'warning', label: 'Bestätigt' }, acknowledged: { tone: 'warning', label: 'Bestaetigt' },
resolved: { tone: 'healthy', label: 'Erledigt' }, resolved: { tone: 'healthy', label: 'Erledigt' },
suppressed: { tone: 'neutral', label: 'Unterdrückt' }, suppressed: { tone: 'neutral', label: 'Unterdrueckt' },
// --- Schweregrade --- // --- Schweregrade ---
info: { tone: 'info', label: 'Information' }, info: { tone: 'info', label: 'Information' },
@ -110,8 +110,8 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
revoked: { tone: 'neutral', label: 'Gesperrt' }, revoked: { tone: 'neutral', label: 'Gesperrt' },
// --- Ransomware-Einstufung (Phase 16) --- // --- Ransomware-Einstufung (Phase 16) ---
elevated: { tone: 'warning', label: 'Auffällig' }, elevated: { tone: 'warning', label: 'Auffaellig' },
// "unknown" oben deckt den Fall unter fünf Vergleichsläufen mit ab. // "unknown" oben deckt den Fall unter fuenf Vergleichslaeufen mit ab.
}; };
const toneClasses: Readonly<Record<StatusTone, string>> = { const toneClasses: Readonly<Record<StatusTone, string>> = {
@ -133,8 +133,8 @@ export function resolveStatus(rawStatus: string | null | undefined): StatusDefin
return ( return (
statusDefinitions[normalizedStatus] ?? { statusDefinitions[normalizedStatus] ?? {
// Unbekannt bleibt neutral. Grün wäre hier der bequeme und gefährliche // Unbekannt bleibt neutral. Gruen waere hier der bequeme und gefaehrliche
// Standard: Ein neuer Fehlerzustand des Servers sähe aus wie Erfolg. // Standard: Ein neuer Fehlerzustand des Servers saehe aus wie Erfolg.
tone: 'neutral', tone: 'neutral',
label: rawStatus, label: rawStatus,
} }
@ -148,7 +148,7 @@ export function StatusBadge({
showDot = true, showDot = true,
}: { }: {
readonly status: string | null | undefined; readonly status: string | null | undefined;
/** Überschreibt die Beschriftung, der Ton bleibt. */ /** Ueberschreibt die Beschriftung, der Ton bleibt. */
readonly label?: ReactNode | undefined; readonly label?: ReactNode | undefined;
readonly className?: string | undefined; readonly className?: string | undefined;
readonly showDot?: boolean | undefined; readonly showDot?: boolean | undefined;
@ -158,7 +158,7 @@ export function StatusBadge({
return ( return (
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium', 'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium',
toneClasses[resolved.tone], toneClasses[resolved.tone],
className, className,
)} )}
@ -171,7 +171,7 @@ export function StatusBadge({
); );
} }
/** Nur der farbige Punkt — für dichte Tabellen. */ /** Nur der farbige Punkt — fuer dichte Tabellen. */
export function StatusDot({ export function StatusDot({
status, status,
className, className,

View File

@ -3,12 +3,12 @@
* *
* Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die * Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die
* `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht * `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht
* funktioniert" — und damit ist weder ihm noch einer späteren Fehlersuche * funktioniert" — und damit ist weder ihm noch einer spaeteren Fehlersuche
* geholfen. Die Kennung lässt sich mit einem Klick kopieren, weil sie sonst * geholfen. Die Kennung laesst sich mit einem Klick kopieren, weil sie sonst
* abgetippt wird und dabei ein Zeichen verlorengeht. * abgetippt wird und dabei ein Zeichen verlorengeht.
* *
* Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer * Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer
* gerade wegsieht, während eine Löschung fehlschlägt, hätte sonst keine * gerade wegsieht, waehrend eine Loeschung fehlschlaegt, haette sonst keine
* Chance, es zu bemerken. * Chance, es zu bemerken.
*/ */
@ -44,7 +44,7 @@ export function useToast(): ToastContextValue {
const contextValue = useContext(ToastContext); const contextValue = useContext(ToastContext);
if (!contextValue) { if (!contextValue) {
throw new Error('useToast benötigt einen ToastProvider im Elternbaum.'); throw new Error('useToast benoetigt einen ToastProvider im Elternbaum.');
} }
return contextValue; return contextValue;
@ -68,8 +68,8 @@ export function ToastProvider({ children }: { readonly children: ReactNode }) {
{ ...message, id: messageId }, { ...message, id: messageId },
]); ]);
// Fehler bleiben stehen, bis sie jemand schließt. Alles andere // Fehler bleiben stehen, bis sie jemand schliesst. Alles andere
// verschwindet nach fünf Sekunden. // verschwindet nach fuenf Sekunden.
if (message.tone !== 'error') { if (message.tone !== 'error') {
window.setTimeout(() => removeMessage(messageId), 5000); window.setTimeout(() => removeMessage(messageId), 5000);
} }
@ -140,7 +140,7 @@ function ToastCard({
return ( return (
<div <div
className={cn( className={cn(
'pointer-events-auto border border-l-4 border-line-strong bg-raised p-3', 'pointer-events-auto rounded-lg border border-l-4 border-line bg-raised p-3 shadow-lg',
toneClasses[message.tone], toneClasses[message.tone],
)} )}
> >
@ -166,7 +166,7 @@ function ToastCard({
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
className="shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg" className="shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg"
aria-label="Meldung schließen" aria-label="Meldung schliessen"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>

View File

@ -2,13 +2,13 @@
* Ereignisprotokoll. * Ereignisprotokoll.
* *
* Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per * 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 * Anwendungslogik. Es laesst sich weder aus der Oberflaeche noch ueber die API
* ändern oder löschen — ein Protokoll, das sich bereinigen ließe, wäre * aendern oder loeschen — ein Protokoll, das sich bereinigen liesse, waere
* keines. Deshalb gibt es hier keine Handlungen, nur eine Suche. * keines. Deshalb gibt es hier keine Handlungen, nur eine Suche.
* *
* Die Adresse in der Spalte „Herkunft" ist die der tatsächlichen Verbindung. * Die Adresse in der Spalte „Herkunft" ist die der tatsaechlichen Verbindung.
* Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind fälschbar, und * Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind faelschbar, und
* im Audit stünde sonst eine beliebige Adresse. Hinter einem Reverse Proxy — * im Audit stuende sonst eine beliebige Adresse. Hinter einem Reverse Proxy —
* also im Normalfall dieser Anlage — steht dort dessen Adresse. * also im Normalfall dieser Anlage — steht dort dessen Adresse.
*/ */
@ -43,7 +43,7 @@ interface AuditEvent {
readonly correlation_id?: string; readonly correlation_id?: string;
} }
/** Lädt die letzten Ereignisse. */ /** Laedt die letzten Ereignisse. */
async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> { async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> {
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', { return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', {
signal: abortSignal, signal: abortSignal,
@ -60,8 +60,8 @@ export function EventsPage() {
/** /**
* Filterung im Browser. * Filterung im Browser.
* *
* Die Liste umfasst höchstens hundert Einträge; eine serverseitige Suche * Die Liste umfasst hoechstens hundert Eintraege; eine serverseitige Suche
* wäre hier mehr Vertrag als Nutzen. Bei größeren Beständen gehört sie * waere hier mehr Vertrag als Nutzen. Bei groesseren Bestaenden gehoert sie
* auf den Server — das ist eine bewusste Grenze, keine Auslassung. * auf den Server — das ist eine bewusste Grenze, keine Auslassung.
*/ */
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
@ -180,7 +180,9 @@ export function EventsPage() {
/> />
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Append-only: weder hier noch über die API änderbar. Das Protokoll ist <strong>append-only per Datenbank-Trigger</strong>. Es
laesst sich weder hier noch ueber die API aendern oder loeschen — auch
nicht von einem Administrator.
</Callout> </Callout>
<Card> <Card>
@ -211,7 +213,7 @@ export function EventsPage() {
emptyDescription={ emptyDescription={
searchTerm searchTerm
? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.' ? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.'
: 'Sobald jemand eine sicherheitsrelevante Handlung ausführt, erscheint sie hier.' : 'Sobald jemand eine sicherheitsrelevante Handlung ausfuehrt, erscheint sie hier.'
} }
/> />
</Card> </Card>

View File

@ -2,16 +2,16 @@
* Rollen und Berechtigungen. * Rollen und Berechtigungen.
* *
* Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste * Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste
* mit 34 Einträgen dazu führt, dass jemand „alles" ankreuzt. Zwei Paare sind * mit 34 Eintraegen dazu fuehrt, dass jemand „alles" ankreuzt. Zwei Paare sind
* bewusst getrennt und werden hier ausdrücklich erklärt: * bewusst getrennt und werden hier ausdruecklich erklaert:
* *
* - `restores.execute` enthält **nicht** `restores.overwrite`. Wiederherstellen * - `restores.execute` enthaelt **nicht** `restores.overwrite`. Wiederherstellen
* ist etwas anderes als vorhandene Daten überschreiben. * ist etwas anderes als vorhandene Daten ueberschreiben.
* - `backups.delete` enthält **nicht** `immutability.manage`. Wer aufräumen * - `backups.delete` enthaelt **nicht** `immutability.manage`. Wer aufraeumen
* darf, darf keinen Löschschutz aufheben — das ist der Schritt, der einem * darf, darf keinen Loeschschutz aufheben — das ist der Schritt, der einem
* Angreifer den Weg öffnet. * Angreifer den Weg oeffnet.
* *
* Mitgelieferte Rollen sind unveränderlich und werden als solche gezeigt, * Mitgelieferte Rollen sind unveraenderlich und werden als solche gezeigt,
* statt eine Maske anzubieten, die der Server ablehnt. * statt eine Maske anzubieten, die der Server ablehnt.
*/ */
@ -46,16 +46,16 @@ import {
type Role, type Role,
} from './administrationApi'; } from './administrationApi';
/** Erklärungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */ /** Erklaerungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */
const PERMISSION_NOTES: Readonly<Record<string, string>> = { const PERMISSION_NOTES: Readonly<Record<string, string>> = {
'restores.overwrite': 'restores.overwrite':
'Nicht in restores.execute enthalten. Erlaubt das Überschreiben vorhandener Daten am Ziel.', 'Nicht in restores.execute enthalten. Erlaubt das Ueberschreiben vorhandener Daten am Ziel.',
'immutability.manage': 'immutability.manage':
'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Löschschutz und Legal Hold.', 'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Loeschschutz und Legal Hold.',
'verification.restore_test': 'verification.restore_test':
'Liest ein Backup vollständig und schreibt es versuchsweise zurück — die einzige Prüfung, die ein Nachweis ist.', 'Liest ein Backup vollstaendig und schreibt es versuchsweise zurueck — die einzige Pruefung, die ein Nachweis ist.',
'settings.write': 'settings.write':
'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfährt.', 'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfaehrt.',
}; };
export function RolesPage({ export function RolesPage({
@ -92,17 +92,17 @@ export function RolesPage({
rolesResource.reload(); rolesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Rolle ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Die Rolle liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteRole, { const deleteMutation = useMutation(deleteRole, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Rolle gelöscht'); toast.showSuccess('Rolle geloescht');
setRolePendingDeletion(null); setRolePendingDeletion(null);
rolesResource.reload(); rolesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const roles = rolesResource.data ?? []; const roles = rolesResource.data ?? [];
@ -110,7 +110,7 @@ export function RolesPage({
/** /**
* Berechtigungen nach Bereich gruppiert. * Berechtigungen nach Bereich gruppiert.
* *
* Der Rückfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein * Der Rueckfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein
* `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die * `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die
* Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als * Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als
* Muster trotzdem falsch. * Muster trotzdem falsch.
@ -119,8 +119,8 @@ export function RolesPage({
const grouped = new Map<string, Permission[]>(); const grouped = new Map<string, Permission[]>();
for (const permission of permissionsResource.data ?? []) { for (const permission of permissionsResource.data ?? []) {
// Ohne ausdrückliche Kategorie dient der Teil vor dem Punkt als Bereich — // Ohne ausdrueckliche Kategorie dient der Teil vor dem Punkt als Bereich —
// "backups.read" gehört zu "backups". // "backups.read" gehoert zu "backups".
const categoryName = const categoryName =
permission.category ?? permission.name.split('.')[0] ?? 'sonstige'; permission.category ?? permission.name.split('.')[0] ?? 'sonstige';
const existing = grouped.get(categoryName) ?? []; const existing = grouped.get(categoryName) ?? [];
@ -166,7 +166,7 @@ export function RolesPage({
header: 'Art', header: 'Art',
render: (role) => ( render: (role) => (
<span className="text-xs text-fg-muted"> <span className="text-xs text-fg-muted">
{role.is_builtin ? 'Mitgeliefert (unveränderlich)' : 'Selbst angelegt'} {role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'}
</span> </span>
), ),
}, },
@ -179,8 +179,8 @@ export function RolesPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Löschen" title="Loeschen"
aria-label={`Rolle ${role.name} löschen`} aria-label={`Rolle ${role.name} loeschen`}
onClick={(clickEvent) => { onClick={(clickEvent) => {
clickEvent.stopPropagation(); clickEvent.stopPropagation();
setRolePendingDeletion(role); setRolePendingDeletion(role);
@ -211,7 +211,7 @@ export function RolesPage({
<> <>
<PageHeader <PageHeader
title="Rollen" title="Rollen"
description="Wer was darf. Geprüft wird immer auf dem Server." description="Wer was darf. Geprueft wird immer auf dem Server."
actions={ actions={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}> <Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -243,13 +243,16 @@ export function RolesPage({
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}> <Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}>
Schließen Schliessen
</Button> </Button>
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
{selectedRole.is_builtin ? ( {selectedRole.is_builtin ? (
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Mitgelieferte Rollen sind unveränderlich. Mitgelieferte Rollen sind <strong>unveraenderlich</strong>. Eine
Aenderung verschoebe die Bedeutung bestehender Zuweisungen —
jemand behielte eine Rolle, die etwas anderes bedeutet als bei
ihrer Vergabe.
</Callout> </Callout>
) : null} ) : null}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -286,7 +289,7 @@ export function RolesPage({
label="Beschreibung" label="Beschreibung"
value={draftDescription} value={draftDescription}
onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)} onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)}
placeholder="Darf Läufe anstoßen und Meldungen bestätigen" placeholder="Darf Laeufe anstossen und Meldungen bestaetigen"
/> />
<div className="space-y-4 border-t border-line pt-4"> <div className="space-y-4 border-t border-line pt-4">
@ -345,9 +348,9 @@ export function RolesPage({
<ConfirmDialog <ConfirmDialog
open={rolePendingDeletion !== null} open={rolePendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)} onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)}
title="Rolle löschen" title="Rolle loeschen"
description={rolePendingDeletion?.name} description={rolePendingDeletion?.name}
confirmLabel="Löschen" confirmLabel="Loeschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -357,7 +360,8 @@ export function RolesPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Konten mit dieser Rolle verlieren die Rechte sofort. Konten mit dieser Rolle verlieren die zugehoerigen Rechte sofort. Ist
sie noch zugewiesen, lehnt der Server das Loeschen ab.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -84,7 +84,7 @@ export function SettingsPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Weg ließ sich nicht anlegen', 'Der Weg liess sich nicht anlegen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -104,7 +104,7 @@ export function SettingsPage({
onSuccess: (enrollment) => setMfaEnrollment(enrollment), onSuccess: (enrollment) => setMfaEnrollment(enrollment),
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Einrichtung ließ sich nicht beginnen', 'Die Einrichtung liess sich nicht beginnen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -114,7 +114,7 @@ export function SettingsPage({
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Zweiter Faktor eingerichtet', 'Zweiter Faktor eingerichtet',
'Ab der nächsten Anmeldung wird er verlangt.', 'Ab der naechsten Anmeldung wird er verlangt.',
); );
setIsMfaConfirmed(true); setIsMfaConfirmed(true);
setMfaEnrollment(null); setMfaEnrollment(null);
@ -123,7 +123,7 @@ export function SettingsPage({
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Code wurde nicht anerkannt', 'Der Code wurde nicht anerkannt',
`${describeApiError(apiError)} — prüfen Sie die Uhrzeit des Geräts; TOTP hängt an der Zeit.`, `${describeApiError(apiError)} — pruefen Sie die Uhrzeit des Geraets; TOTP haengt an der Zeit.`,
apiError.requestId, apiError.requestId,
), ),
}); });
@ -182,7 +182,7 @@ export function SettingsPage({
<Card className="mb-4"> <Card className="mb-4">
<CardHeader <CardHeader
title="Zweiter Faktor" title="Zweiter Faktor"
description="Für das eigene Konto." description="Fuer das eigene Konto."
action={ action={
mfaIsActive ? ( mfaIsActive ? (
<StatusBadge status="healthy" label="Eingerichtet" /> <StatusBadge status="healthy" label="Eingerichtet" />
@ -202,9 +202,9 @@ export function SettingsPage({
<div className="space-y-4"> <div className="space-y-4">
<Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt"> <Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt">
Tragen Sie es in Ihre Authentifizierungs-App ein und Tragen Sie es in Ihre Authentifizierungs-App ein und
bestätigen Sie mit dem angezeigten Code. Erst dann wird der bestaetigen Sie mit dem angezeigten Code. Erst dann wird der
zweite Faktor scharf — sonst hätten Sie ein Geheimnis zweite Faktor scharf — sonst haetten Sie ein Geheimnis
hinterlegt, dessen App Sie nie geprüft haben. hinterlegt, dessen App Sie nie geprueft haben.
</Callout> </Callout>
<div className="rounded-md border border-line bg-sunken p-3"> <div className="rounded-md border border-line bg-sunken p-3">
@ -231,16 +231,16 @@ export function SettingsPage({
disabled={mfaCode.trim().length !== 6} disabled={mfaCode.trim().length !== 6}
onClick={() => void confirmMfaMutation.run(mfaCode.trim())} onClick={() => void confirmMfaMutation.run(mfaCode.trim())}
> >
Bestätigen Bestaetigen
</Button> </Button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-fg-muted"> <p className="text-sm text-fg-muted">
Ihr Konto ist nur durch sein Passwort geschützt. Bei einem Konto Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto
mit Löschrecht ist das der Punkt, an dem eine Übernahme zum mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum
Datenverlust führt. Datenverlust fuehrt.
</p> </p>
<Button <Button
variant="primary" variant="primary"
@ -283,7 +283,7 @@ export function SettingsPage({
getRowKey={(channel) => channel.id} getRowKey={(channel) => channel.id}
isLoading={channelsResource.loadState === 'loading'} isLoading={channelsResource.loadState === 'loading'}
emptyTitle="Kein Benachrichtigungsweg" emptyTitle="Kein Benachrichtigungsweg"
emptyDescription="Ohne Weg erfährt niemand von einem Ausfall." emptyDescription="Ohne Weg erfaehrt niemand von einem Ausfall. Die Meldungen entstehen trotzdem und stehen unter „Meldungen“."
emptyAction={ emptyAction={
mayWriteSettings ? ( mayWriteSettings ? (
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}> <Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
@ -329,7 +329,7 @@ export function SettingsPage({
} }
error={ error={
draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure
? 'Nur https. Ein Webhook trägt oft ein Token im Pfad.' ? 'Nur https. Ein Webhook traegt oft ein Token im Pfad.'
: undefined : undefined
} }
/> />
@ -347,12 +347,19 @@ export function SettingsPage({
</SelectInput> </SelectInput>
<Callout tone="info"> <Callout tone="info">
Eine zu niedrige Schwelle ist gefährlicher als eine zu hohe. Eine zu niedrige Schwelle ist gefaehrlicher als eine zu hohe: Wer
taeglich zwanzig Meldungen bekommt, schaltet sie nach einer Woche
ab — und dann kommt auch die kritische nicht mehr an.
</Callout> </Callout>
{draftType === 'webhook' ? ( {draftType === 'webhook' ? (
<Callout tone="warning"> <Callout tone="warning">
Interne Adressen werden abgelehnt — geprüft beim Anlegen und vor jedem Aufruf. Interne Adressen werden abgelehnt — der Metadatendienst einer
Cloud (<code className="font-mono text-xs">169.254.169.254</code>)
und die eigene Datenbank sind sonst mit einem Eintrag erreichbar.
Geprueft wird beim Anlegen <strong>und</strong> vor jedem
Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere
Adresse zeigen.
</Callout> </Callout>
) : null} ) : null}
</DialogBody> </DialogBody>
@ -397,7 +404,9 @@ export function SettingsPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Danach wird über diesen Weg nichts mehr zugestellt. Ueber diesen Weg wird danach nichts mehr zugestellt. Ist es der
einzige, erfaehrt niemand mehr von einem Ausfall — die Meldungen
entstehen weiterhin und stehen unter „Meldungen“.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,13 +1,13 @@
/** /**
* Benutzer. * Benutzer.
* *
* Der Schutz des letzten Administrators liegt beim Server — die Oberfläche * Der Schutz des letzten Administrators liegt beim Server — die Oberflaeche
* kann ihn nicht durchsetzen, weil sie nicht weiß, wer sonst noch Administrator * kann ihn nicht durchsetzen, weil sie nicht weiss, wer sonst noch Administrator
* ist. Was sie tut: den Serverfehler in einen verständlichen Satz übersetzen, * ist. Was sie tut: den Serverfehler in einen verstaendlichen Satz uebersetzen,
* statt „LAST_ADMINISTRATOR" anzuzeigen. * statt „LAST_ADMINISTRATOR" anzuzeigen.
* *
* Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein * Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein
* Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffällt. * Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffaellt.
*/ */
import { Plus, ShieldOff, Trash2, UserCog } from 'lucide-react'; import { Plus, ShieldOff, Trash2, UserCog } from 'lucide-react';
@ -74,7 +74,7 @@ export function UsersPage({
onSuccess: (user) => { onSuccess: (user) => {
toast.showSuccess( toast.showSuccess(
'Konto angelegt', 'Konto angelegt',
`${user.username} — richten Sie als Nächstes den zweiten Faktor ein.`, `${user.username} — richten Sie als Naechstes den zweiten Faktor ein.`,
); );
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setDraftUsername(''); setDraftUsername('');
@ -84,34 +84,34 @@ export function UsersPage({
usersResource.reload(); usersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Konto ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Das Konto liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const updateMutation = useMutation(updateUser, { const updateMutation = useMutation(updateUser, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Konto geändert'); toast.showSuccess('Konto geaendert');
setUserBeingEdited(null); setUserBeingEdited(null);
usersResource.reload(); usersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Änderung schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Die Aenderung schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteUser, { const deleteMutation = useMutation(deleteUser, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Konto gelöscht'); toast.showSuccess('Konto geloescht');
setUserPendingDeletion(null); setUserPendingDeletion(null);
usersResource.reload(); usersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const mfaResetMutation = useMutation(disableUserMfa, { const mfaResetMutation = useMutation(disableUserMfa, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Zweiter Faktor zurückgesetzt', 'Zweiter Faktor zurueckgesetzt',
'Das Konto ist jetzt nur noch durch sein Passwort geschützt.', 'Das Konto ist jetzt nur noch durch sein Passwort geschuetzt.',
); );
setUserPendingMfaReset(null); setUserPendingMfaReset(null);
usersResource.reload(); usersResource.reload();
@ -197,8 +197,8 @@ export function UsersPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Zweiten Faktor zurücksetzen" title="Zweiten Faktor zuruecksetzen"
aria-label={`Zweiten Faktor von ${user.username} zurücksetzen`} aria-label={`Zweiten Faktor von ${user.username} zuruecksetzen`}
onClick={() => setUserPendingMfaReset(user)} onClick={() => setUserPendingMfaReset(user)}
> >
<ShieldOff /> <ShieldOff />
@ -207,8 +207,8 @@ export function UsersPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Löschen" title="Loeschen"
aria-label={`${user.username} löschen`} aria-label={`${user.username} loeschen`}
onClick={() => setUserPendingDeletion(user)} onClick={() => setUserPendingDeletion(user)}
> >
<Trash2 /> <Trash2 />
@ -253,8 +253,8 @@ export function UsersPage({
{usersWithoutMfa > 0 ? ( {usersWithoutMfa > 0 ? (
<Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt"> <Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt">
{usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort {usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort
geschützt. Bei Konten mit Löschrecht ist das der Punkt, an dem ein geschuetzt. Bei Konten mit Loeschrecht ist das der Punkt, an dem ein
übernommenes Konto zum Datenverlust führt. uebernommenes Konto zum Datenverlust fuehrt.
</Callout> </Callout>
) : null} ) : null}
@ -293,7 +293,7 @@ export function UsersPage({
autoComplete="new-password" autoComplete="new-password"
value={draftPassword} value={draftPassword}
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)} onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlässt diese Maske nur im Anfragekörper." hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlaesst diese Maske nur im Anfragekoerper."
/> />
<div className="space-y-2"> <div className="space-y-2">
@ -382,7 +382,9 @@ export function UsersPage({
</div> </div>
<Callout tone="info"> <Callout tone="info">
Dem letzten Administrator lassen sich die Rechte nicht entziehen. Dem <strong>letzten Administrator</strong> lassen sich die
Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst
waere die Anlage nicht mehr verwaltbar.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -413,9 +415,9 @@ export function UsersPage({
<ConfirmDialog <ConfirmDialog
open={userPendingDeletion !== null} open={userPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)} onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)}
title="Konto löschen" title="Konto loeschen"
description={userPendingDeletion?.username} description={userPendingDeletion?.username}
confirmLabel="Löschen" confirmLabel="Loeschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
confirmationWord={userPendingDeletion?.username} confirmationWord={userPendingDeletion?.username}
@ -426,16 +428,18 @@ export function UsersPage({
}} }}
> >
<Callout tone="info"> <Callout tone="info">
Die Einträge im Ereignisprotokoll bleiben bestehen. Die Eintraege dieses Kontos im Ereignisprotokoll bleiben bestehen —
ein Protokoll, das sich durch Loeschen eines Kontos bereinigen liesse,
waere keines.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={userPendingMfaReset !== null} open={userPendingMfaReset !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)} onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)}
title="Zweiten Faktor zurücksetzen" title="Zweiten Faktor zuruecksetzen"
description={userPendingMfaReset?.username} description={userPendingMfaReset?.username}
confirmLabel="Zurücksetzen" confirmLabel="Zuruecksetzen"
isDestructive isDestructive
isLoading={mfaResetMutation.isRunning} isLoading={mfaResetMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -445,7 +449,10 @@ export function UsersPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Danach genügt das Passwort allein. Prüfen Sie die Anfrage auf anderem Weg. Danach genuegt <strong>das Passwort allein</strong>. Der Vorgang wird
protokolliert. Vergewissern Sie sich auf anderem Weg, dass die
Anfrage tatsaechlich von dieser Person stammt — das ist der uebliche
Ansatzpunkt fuer eine Kontouebernahme.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,12 +1,12 @@
/** /**
* API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA. * API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA.
* *
* Diese vier gehören zusammen, weil sie dieselbe Frage betreffen: **Wer darf * Diese vier gehoeren zusammen, weil sie dieselbe Frage betreffen: **Wer darf
* was, und wer erfährt davon?** Zwei Schutzmechanismen prägen die Masken: * was, und wer erfaehrt davon?** Zwei Schutzmechanismen praegen die Masken:
* *
* - **Der letzte Administrator ist geschützt** gegen Löschung, Deaktivierung * - **Der letzte Administrator ist geschuetzt** gegen Loeschung, Deaktivierung
* und Rollenentzug. Sonst wäre die Anlage nicht mehr verwaltbar. * und Rollenentzug. Sonst waere die Anlage nicht mehr verwaltbar.
* - **Mitgelieferte Rollen sind unveränderlich.** Eine Änderung verschöbe die * - **Mitgelieferte Rollen sind unveraenderlich.** Eine Aenderung verschoebe die
* Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas * Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas
* anderes bedeutet als bei ihrer Vergabe. * anderes bedeutet als bei ihrer Vergabe.
*/ */
@ -28,11 +28,11 @@ export interface User {
mfa_enabled: boolean; mfa_enabled: boolean;
last_login_at?: string; last_login_at?: string;
created_at?: string; created_at?: string;
/** Zähler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */ /** Zaehler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */
failed_login_count?: number; failed_login_count?: number;
} }
/** Lädt die Benutzer. */ /** Laedt die Benutzer. */
export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> { export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {}); return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {});
} }
@ -40,8 +40,8 @@ export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
/** /**
* Legt ein Benutzerkonto an. * Legt ein Benutzerkonto an.
* *
* Das Passwort geht ausschließlich über den Anfragekörper — niemals als * Das Passwort geht ausschliesslich ueber den Anfragekoerper — niemals als
* Abfrageparameter, sonst stünde es in jedem Zugriffsprotokoll. * Abfrageparameter, sonst stuende es in jedem Zugriffsprotokoll.
*/ */
export async function createUser(userInput: { export async function createUser(userInput: {
username: string; username: string;
@ -56,7 +56,7 @@ export async function createUser(userInput: {
}); });
} }
/** Ändert ein Benutzerkonto. */ /** Aendert ein Benutzerkonto. */
export async function updateUser( export async function updateUser(
userIdentifier: string, userIdentifier: string,
changes: { changes: {
@ -72,7 +72,7 @@ export async function updateUser(
}); });
} }
/** Löscht ein Benutzerkonto. */ /** Loescht ein Benutzerkonto. */
export async function deleteUser(userIdentifier: string): Promise<void> { export async function deleteUser(userIdentifier: string): Promise<void> {
return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, { return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, {
method: 'DELETE', method: 'DELETE',
@ -81,10 +81,10 @@ export async function deleteUser(userIdentifier: string): Promise<void> {
} }
/** /**
* Setzt den zweiten Faktor eines Kontos zurück. * Setzt den zweiten Faktor eines Kontos zurueck.
* *
* Der Weg für den Fall, dass jemand sein Gerät verloren hat. Es ist eine * Der Weg fuer den Fall, dass jemand sein Geraet verloren hat. Es ist eine
* sicherheitsrelevante Handlung und wird auditiert: Danach genügt das * sicherheitsrelevante Handlung und wird auditiert: Danach genuegt das
* Passwort allein. * Passwort allein.
*/ */
export async function disableUserMfa(userIdentifier: string): Promise<User> { export async function disableUserMfa(userIdentifier: string): Promise<User> {
@ -104,7 +104,7 @@ export interface Role {
name: string; name: string;
description?: string; description?: string;
permissions: string[]; permissions: string[];
/** Mitgelieferte Rollen sind unveränderlich. */ /** Mitgelieferte Rollen sind unveraenderlich. */
is_builtin?: boolean; is_builtin?: boolean;
user_count?: number; user_count?: number;
} }
@ -117,12 +117,12 @@ export interface Permission {
category?: string; category?: string;
} }
/** Lädt die Rollen. */ /** Laedt die Rollen. */
export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> { export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> {
return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {}); return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {});
} }
/** Lädt die vergebbaren Berechtigungen. */ /** Laedt die vergebbaren Berechtigungen. */
export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> { export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> {
return requestApi<Permission[]>('/permissions', abortSignal ? { signal: abortSignal } : {}); return requestApi<Permission[]>('/permissions', abortSignal ? { signal: abortSignal } : {});
} }
@ -140,7 +140,7 @@ export async function createRole(roleInput: {
}); });
} }
/** Ändert eine Rolle. */ /** Aendert eine Rolle. */
export async function updateRole( export async function updateRole(
roleIdentifier: string, roleIdentifier: string,
changes: { description?: string; permissions?: string[] }, changes: { description?: string; permissions?: string[] },
@ -151,7 +151,7 @@ export async function updateRole(
}); });
} }
/** Löscht eine Rolle. */ /** Loescht eine Rolle. */
export async function deleteRole(roleIdentifier: string): Promise<void> { export async function deleteRole(roleIdentifier: string): Promise<void> {
return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, { return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
method: 'DELETE', method: 'DELETE',
@ -163,7 +163,7 @@ export async function deleteRole(roleIdentifier: string): Promise<void> {
// Benachrichtigungswege // Benachrichtigungswege
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Ein Zustellweg für Meldungen. */ /** Ein Zustellweg fuer Meldungen. */
export interface NotificationChannel { export interface NotificationChannel {
id: string; id: string;
name: string; name: string;
@ -179,15 +179,15 @@ export interface NotificationChannel {
/** /**
* Konfiguration ohne Geheimnisse. * Konfiguration ohne Geheimnisse.
* *
* Bei einem Webhook steht die Adresse hier, und die trägt oft ein Token im * Bei einem Webhook steht die Adresse hier, und die traegt oft ein Token im
* Pfad — der Server liefert sie deshalb geschwärzt oder gar nicht aus. * Pfad — der Server liefert sie deshalb geschwaerzt oder gar nicht aus.
*/ */
configuration?: Record<string, unknown>; configuration?: Record<string, unknown>;
enabled?: boolean; enabled?: boolean;
created_at?: string; created_at?: string;
} }
/** Lädt die Benachrichtigungswege. */ /** Laedt die Benachrichtigungswege. */
export async function listNotificationChannels( export async function listNotificationChannels(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<NotificationChannel[]> { ): Promise<NotificationChannel[]> {
@ -212,7 +212,7 @@ export async function createNotificationChannel(channelInput: {
}); });
} }
/** Löscht einen Benachrichtigungsweg. */ /** Loescht einen Benachrichtigungsweg. */
export async function deleteNotificationChannel(channelIdentifier: string): Promise<void> { export async function deleteNotificationChannel(channelIdentifier: string): Promise<void> {
return requestApi<void>( return requestApi<void>(
`/notification-channels/${encodeURIComponent(channelIdentifier)}`, `/notification-channels/${encodeURIComponent(channelIdentifier)}`,
@ -228,7 +228,7 @@ export async function deleteNotificationChannel(channelIdentifier: string): Prom
export interface MfaEnrollment { export interface MfaEnrollment {
/** Das gemeinsame Geheimnis in Base32 — einmalig. */ /** Das gemeinsame Geheimnis in Base32 — einmalig. */
secret: string; secret: string;
/** URI für die Authentifizierungs-App. */ /** URI fuer die Authentifizierungs-App. */
otpauth_url?: string; otpauth_url?: string;
} }
@ -238,11 +238,11 @@ export async function enrollMfa(): Promise<MfaEnrollment> {
} }
/** /**
* Schließt die Einrichtung mit einem Code ab. * Schliesst die Einrichtung mit einem Code ab.
* *
* Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn hätte jemand * Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn haette jemand
* ein Geheimnis hinterlegt, dessen App er nie geprüft hat — und sich beim * ein Geheimnis hinterlegt, dessen App er nie geprueft hat — und sich beim
* nächsten Anmelden ausgesperrt. * naechsten Anmelden ausgesperrt.
*/ */
export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> { export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> {
return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', { return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', {

View File

@ -2,13 +2,13 @@
* Meldungen. * Meldungen.
* *
* Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung: * Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiß, * **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss,
* was überhaupt geprüft wird.** Ohne die Liste bliebe offen, ob „keine * was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprüft". * Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft".
* *
* „Zur Kenntnis nehmen" schließt eine Meldung nicht. Sie bleibt offen und in * „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in
* der Liste, bis ihre Ursache verschwindet — sonst verschwände der Zustand aus * der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus
* der Übersicht, obwohl er weiterbesteht. * der Uebersicht, obwohl er weiterbesteht.
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
@ -92,7 +92,7 @@ export function AlertsPage(): React.JSX.Element {
title="Meldungen" title="Meldungen"
description={ description={
overviewResource.data !== null overviewResource.data !== null
? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprüft.` ? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprueft.`
: undefined : undefined
} }
/> />
@ -123,7 +123,7 @@ export function AlertsPage(): React.JSX.Element {
{loadState === 'loaded' && data !== null && data.length === 0 ? ( {loadState === 'loaded' && data !== null && data.length === 0 ? (
<EmptyState <EmptyState
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'} title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprüft wird — erst damit ist eine leere Liste eine gute Nachricht." description="Die Liste „Worauf geachtet wird“ unten sagt, was geprueft wird — erst damit ist eine leere Liste eine gute Nachricht."
/> />
) : null} ) : null}
@ -166,8 +166,8 @@ function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.J
<p <p
className={cn( className={cn(
'text-2xl font-semibold tabular', 'text-2xl font-semibold tabular',
// Farbe nur, wenn der Wert eine Aussage trägt: Null kritische // Farbe nur, wenn der Wert eine Aussage traegt: Null kritische
// Meldungen sind neutral, nicht grün — grün hieße "geprüft // Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft
// und in Ordnung", und das sagt diese Zahl allein nicht. // und in Ordnung", und das sagt diese Zahl allein nicht.
figure.tone && figure.value > 0 figure.tone && figure.value > 0
? figure.tone === 'critical' ? figure.tone === 'critical'
@ -247,7 +247,7 @@ function AlertRow({
</Button> </Button>
) : null} ) : null}
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}> <Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
Schließen Schliessen
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -259,9 +259,9 @@ function AlertRow({
/** /**
* Zeigt, worauf geachtet wird. * Zeigt, worauf geachtet wird.
* *
* Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt * Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt
* den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten * den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten
* Regel ausdrücklich, dass sie nicht greift, und warum. * Regel ausdruecklich, dass sie nicht greift, und warum.
*/ */
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element { function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
return ( return (
@ -285,7 +285,7 @@ function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX
{rule.available ? ( {rule.available ? (
<StatusBadge status={rule.severity} /> <StatusBadge status={rule.severity} />
) : ( ) : (
<StatusBadge status="neutral" label="wird nicht geprüft" /> <StatusBadge status="neutral" label="wird nicht geprueft" />
)} )}
</div> </div>
<p className="mt-1 text-sm text-fg-muted"> <p className="mt-1 text-sm text-fg-muted">

View File

@ -1,5 +1,5 @@
/** /**
* Zugriff auf Meldungen und Benachrichtigungskanäle. * Zugriff auf Meldungen und Benachrichtigungskanaele.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -12,17 +12,17 @@ export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
/** Eine Meldung. */ /** Eine Meldung. */
export interface Alert { export interface Alert {
/** Öffentlicher Bezeichner. */ /** Oeffentlicher Bezeichner. */
readonly id: string; readonly id: string;
/** Auslösende Regel. */ /** Ausloesende Regel. */
readonly rule_name: string; readonly rule_name: string;
/** Schweregrad. */ /** Schweregrad. */
readonly severity: AlertSeverity; readonly severity: AlertSeverity;
/** Bearbeitungszustand. */ /** Bearbeitungszustand. */
readonly status: AlertStatus; readonly status: AlertStatus;
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Befund und nächste Handlung. */ /** Befund und naechste Handlung. */
readonly message: string; readonly message: string;
/** Art des betroffenen Gegenstands. */ /** Art des betroffenen Gegenstands. */
readonly entity_type?: string; readonly entity_type?: string;
@ -41,11 +41,11 @@ export interface Alert {
readonly last_seen_at: string; readonly last_seen_at: string;
/** Zeitpunkt der Kenntnisnahme in UTC. */ /** Zeitpunkt der Kenntnisnahme in UTC. */
readonly acknowledged_at?: string; readonly acknowledged_at?: string;
/** Bemerkung des Bestätigenden. */ /** Bemerkung des Bestaetigenden. */
readonly acknowledgement_note?: string; readonly acknowledgement_note?: string;
/** Zeitpunkt der Auflösung in UTC. */ /** Zeitpunkt der Aufloesung in UTC. */
readonly resolved_at?: string; readonly resolved_at?: string;
/** Begründung der Auflösung. */ /** Begruendung der Aufloesung. */
readonly resolution_note?: string; readonly resolution_note?: string;
} }
@ -55,13 +55,13 @@ export interface AlertRule {
readonly name: string; readonly name: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklärung. */ /** Erklaerung. */
readonly description: string; readonly description: string;
/** Schweregrad ausgelöster Meldungen. */ /** Schweregrad ausgeloester Meldungen. */
readonly severity: AlertSeverity; readonly severity: AlertSeverity;
/** Meldet, ob die Regel auslösen kann. */ /** Meldet, ob die Regel ausloesen kann. */
readonly available: boolean; readonly available: boolean;
/** Erklärt eine Regel ohne Datengrundlage. */ /** Erklaert eine Regel ohne Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
} }
@ -79,17 +79,17 @@ export interface AlertSummary {
readonly resolved_last_day: number; readonly resolved_last_day: number;
} }
/** Antwort der Meldungsübersicht. */ /** Antwort der Meldungsuebersicht. */
export interface AlertOverview { export interface AlertOverview {
/** Die Meldungslage. */ /** Die Meldungslage. */
readonly summary: AlertSummary; readonly summary: AlertSummary;
/** Alle Regeln. */ /** Alle Regeln. */
readonly rules: readonly AlertRule[]; readonly rules: readonly AlertRule[];
/** Zahl der auslösbaren Regeln. */ /** Zahl der ausloesbaren Regeln. */
readonly available_rule_count: number; readonly available_rule_count: number;
} }
/** Lädt die Meldungen. */ /** Laedt die Meldungen. */
export async function fetchAlerts( export async function fetchAlerts(
onlyActive: boolean, onlyActive: boolean,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -106,7 +106,7 @@ export async function fetchAlerts(
); );
} }
/** Lädt die Meldungslage samt Regelwerk. */ /** Laedt die Meldungslage samt Regelwerk. */
export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> { export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> {
return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {}); return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {});
} }
@ -119,7 +119,7 @@ export async function acknowledgeAlert(alertIdentifier: string, note: string): P
}); });
} }
/** Schließt eine Meldung von Hand. */ /** Schliesst eine Meldung von Hand. */
export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> { export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> {
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, { await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
method: 'POST', method: 'POST',

View File

@ -12,7 +12,7 @@ function buildResponse(responseBody: unknown, statusCode = 200): Response {
}); });
} }
/** Ein vollständiger Benutzer, wie ihn die API zurückgibt. */ /** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */
const testUser = { const testUser = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
username: 'admin', username: 'admin',
@ -22,7 +22,7 @@ const testUser = {
permissions: ['users.read'], permissions: ['users.read'],
}; };
/** Ein Tokenpaar, wie es die API zurückgibt. */ /** Ein Tokenpaar, wie es die API zurueckgibt. */
const testTokens = { const testTokens = {
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
@ -85,7 +85,7 @@ describe('LoginPage', () => {
expect(handleAuthenticated).not.toHaveBeenCalled(); expect(handleAuthenticated).not.toHaveBeenCalled();
}); });
it('schließt die Anmeldung nach gültigem Code ab', async () => { it('schliesst die Anmeldung nach gueltigem Code ab', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValueOnce( .mockResolvedValueOnce(
@ -112,14 +112,14 @@ describe('LoginPage', () => {
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument()); await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
await userEvent.type(screen.getByLabelText('Code'), '123456'); await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' })); await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
await waitFor(() => { await waitFor(() => {
expect(handleAuthenticated).toHaveBeenCalled(); expect(handleAuthenticated).toHaveBeenCalled();
}); });
}); });
it('zeigt die Fehlermeldung des Servers verständlich an', async () => { it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue( vi.fn().mockResolvedValue(
@ -166,14 +166,14 @@ describe('LoginPage', () => {
await userEvent.type(passwordField, 'falsch'); await userEvent.type(passwordField, 'falsch');
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' })); await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
// Ein stehengebliebenes Passwort wäre auf einem gemeinsam genutzten // Ein stehengebliebenes Passwort waere auf einem gemeinsam genutzten
// Bildschirm sichtbar. // Bildschirm sichtbar.
await waitFor(() => { await waitFor(() => {
expect(passwordField.value).toBe(''); expect(passwordField.value).toBe('');
}); });
}); });
it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurück', async () => { it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurueck', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValueOnce( .mockResolvedValueOnce(
@ -205,9 +205,9 @@ describe('LoginPage', () => {
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument()); await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
await userEvent.type(screen.getByLabelText('Code'), '123456'); await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' })); await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
// Ohne Rückkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt. // Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt.
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument(); expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
}); });

View File

@ -29,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 verständliche Meldung (PROMPT.md §124). */ /** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
function describeError(caughtError: unknown): string { function describeError(caughtError: unknown): string {
if (caughtError instanceof ApiError) { if (caughtError instanceof ApiError) {
return caughtError.message; return caughtError.message;
@ -59,7 +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 unvollständig. Bitte erneut versuchen.'); setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
} catch (caughtError) { } catch (caughtError) {
setErrorMessage(describeError(caughtError)); setErrorMessage(describeError(caughtError));
// Das Passwort wird nach einem Fehlversuch geleert. // Das Passwort wird nach einem Fehlversuch geleert.
@ -88,7 +88,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
return; return;
} }
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.'); setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
} catch (caughtError) { } catch (caughtError) {
setErrorMessage(describeError(caughtError)); setErrorMessage(describeError(caughtError));
setMfaCode(''); setMfaCode('');
@ -106,7 +106,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
return ( return (
<div className="flex min-h-screen items-center justify-center bg-page p-4"> <div className="flex min-h-screen items-center justify-center bg-page p-4">
<main className="w-full max-w-sm border border-line-strong bg-card p-6"> <main className="w-full max-w-sm rounded-lg border border-line bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold text-fg">Syncova</h1> <h1 className="text-lg font-semibold text-fg">Syncova</h1>
{loginStep === 'credentials' ? ( {loginStep === 'credentials' ? (
@ -147,7 +147,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
)} )}
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}> <button className="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 läuft …' : 'Anmelden'} {isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
</button> </button>
</form> </form>
) : ( ) : (
@ -155,7 +155,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
<p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p> <p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
<p className="mt-1.5 text-xs text-fg-muted"> <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 möglich. Wiederherstellungscode moeglich.
</p> </p>
<label className="block text-sm font-medium text-fg" htmlFor="mfa-code"> <label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
@ -167,7 +167,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
type="text" type="text"
value={mfaCode} value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)} onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
// one-time-code lässt Mobilgeräte den Code aus der SMS/App vorschlagen. // one-time-code laesst Mobilgeraete den Code aus der SMS/App vorschlagen.
autoComplete="one-time-code" autoComplete="one-time-code"
inputMode="text" inputMode="text"
autoFocus autoFocus
@ -181,7 +181,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
)} )}
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}> <button className="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 ? 'Prüfung läuft …' : 'Bestätigen'} {isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
</button> </button>
<button <button

View File

@ -1,16 +1,13 @@
/** /**
* Anmeldefunktionen gegen die Syncova-API. * Anmeldefunktionen gegen die Syncova-API.
* *
* Die Tokens liegen im Arbeitsspeicher **und** im Sitzungsspeicher des Tabs. * Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in
* Warum das eine Abkehr von der ursprünglichen Entscheidung ist und was sie * localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und
* trägt, steht in `sessionStore.ts` — kurz: Ohne Ablage warf jedes Neuladen den * ueberstuenden das Schliessen des Browsers (PROMPT.md §45).
* Betreiber auf die Anmeldemaske, und begrenzt wird die Sitzung ohnehin durch
* eine harte Obergrenze und eine Untätigkeitsgrenze.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth'; import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
import { clearStoredSession, readStoredSession, writeStoredSession } from './sessionStore';
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */ /** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
let currentAccessToken: string | null = null; let currentAccessToken: string | null = null;
@ -18,53 +15,27 @@ let currentAccessToken: string | null = null;
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */ /** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
let currentRefreshToken: string | null = null; let currentRefreshToken: string | null = null;
/** /** Liefert das aktuelle Zugriffstoken. */
* Liefert das aktuelle Zugriffstoken.
*
* Fehlt es im Arbeitsspeicher — der Fall nach einem Neuladen —, wird es aus dem
* Sitzungsspeicher geholt. Der liefert nur zurück, was beide Zeitgrenzen noch
* einhält.
*/
export function getAccessToken(): string | null { export function getAccessToken(): string | null {
if (currentAccessToken !== null) {
return currentAccessToken;
}
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, enthält das Ergebnis eine * Ist ein zweiter Faktor eingerichtet, enthaelt das Ergebnis eine
* Herausforderung statt der Tokens. * Herausforderung statt der Tokens.
*/ */
export async function login(username: string, password: string): Promise<LoginResult> { export async function login(username: string, password: string): Promise<LoginResult> {
@ -80,7 +51,7 @@ export async function login(username: string, password: string): Promise<LoginRe
return loginResult; return loginResult;
} }
/** Schließt eine Anmeldung mit dem zweiten Faktor ab. */ /** Schliesst eine Anmeldung mit dem zweiten Faktor ab. */
export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> { export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> {
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', { const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
method: 'POST', method: 'POST',
@ -105,7 +76,7 @@ export async function logout(): Promise<void> {
} }
} }
/** Lädt den aktuell angemeldeten Benutzer. */ /** Laedt den aktuell angemeldeten Benutzer. */
export async function fetchCurrentUser(): Promise<CurrentUser> { export async function fetchCurrentUser(): Promise<CurrentUser> {
return requestApi<CurrentUser>('/me'); return requestApi<CurrentUser>('/me');
} }

View File

@ -1,102 +0,0 @@
/**
* Prüft die beiden Grenzen der Sitzung.
*
* Beide sind eine Zusage an den Betreiber, und beide lassen sich mit einer
* falschen Zeile still aushebeln: Wer beim Vermerken einer Interaktion die
* harte Obergrenze mitverschiebt, macht aus „30 Minuten" ein „unbegrenzt,
* solange die Maus wackelt".
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SESSION_IDLE_MS,
SESSION_MAXIMUM_MS,
clearStoredSession,
readStoredSession,
remainingSessionMs,
touchStoredSession,
writeStoredSession,
} from './sessionStore';
const tokenPair = {
access_token: 'zugriff',
refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis
access_expires_at: '',
refresh_expires_at: '',
};
describe('Sitzungsablage', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T10:00:00Z'));
clearStoredSession();
});
afterEach(() => {
vi.useRealTimers();
});
it('überlebt ein Neuladen innerhalb der Frist', () => {
writeStoredSession(tokenPair);
// Ein Neuladen bedeutet: neuer Arbeitsspeicher, gleiche Ablage.
const resumed = readStoredSession();
expect(resumed).not.toBeNull();
expect(resumed?.accessToken).toBe('zugriff');
});
it('endet nach der harten Obergrenze, auch bei ständiger Interaktion', () => {
writeStoredSession(tokenPair);
// Alle fünf Minuten eine Interaktion — sieben Mal, also 35 Minuten.
for (let step = 0; step < 7; step += 1) {
vi.advanceTimersByTime(5 * 60 * 1000);
touchStoredSession();
}
// Die Untätigkeitsgrenze wäre nie erreicht worden. Die Obergrenze schon.
expect(readStoredSession()).toBeNull();
});
it('endet bei Untätigkeit vor der Obergrenze', () => {
writeStoredSession(tokenPair);
vi.advanceTimersByTime(SESSION_IDLE_MS + 1000);
expect(readStoredSession()).toBeNull();
});
it('verschiebt die Obergrenze durch eine Interaktion nicht', () => {
const created = writeStoredSession(tokenPair);
vi.advanceTimersByTime(60 * 1000);
const touched = touchStoredSession();
expect(touched?.absoluteExpiryMs).toBe(created.absoluteExpiryMs);
expect(touched?.lastActivityMs).toBeGreaterThan(created.lastActivityMs);
});
it('nennt die frühere der beiden Grenzen als Restzeit', () => {
const created = writeStoredSession(tokenPair);
// Direkt nach der Anmeldung fallen beide Grenzen zusammen.
expect(remainingSessionMs(created)).toBe(SESSION_MAXIMUM_MS);
vi.advanceTimersByTime(20 * 60 * 1000);
const stillValid = readStoredSession();
expect(stillValid).not.toBeNull();
// Zehn Minuten bis zur Obergrenze, dreißig bis zur Untätigkeitsgrenze —
// maßgeblich sind die zehn.
expect(remainingSessionMs(stillValid!)).toBe(10 * 60 * 1000);
});
it('verwirft einen unlesbaren Eintrag, statt ihn zu raten', () => {
window.sessionStorage.setItem('syncova.session', '{kein gültiges json');
expect(readStoredSession()).toBeNull();
expect(window.sessionStorage.getItem('syncova.session')).toBeNull();
});
});

View File

@ -1,181 +0,0 @@
/**
* Ablage der Sitzung.
*
* **Diese Datei kehrt eine frühere Entscheidung um, und zwar bewusst.**
*
* Bisher lagen die Tokens ausschließlich im Arbeitsspeicher. Das war die
* sicherste Variante und praktisch unbrauchbar: Jedes Neuladen — F5, ein
* versehentlicher Klick auf „Aktualisieren", ein Absturz des Tabs — warf den
* Betreiber auf die Anmeldemaske zurück. Mitten in einer Störung ist das kein
* Sicherheitsgewinn, sondern ein Hindernis.
*
* Gewählt ist `sessionStorage`, nicht `localStorage`:
*
* | | überlebt F5 | überlebt Tab-Schließen | überlebt Neustart |
* | --- | --- | --- | --- |
* | Arbeitsspeicher | nein | nein | nein |
* | **sessionStorage** | **ja** | **nein** | **nein** |
* | localStorage | ja | ja | ja |
*
* Was den Rest trägt, ist nicht der Speicherort, sondern die Begrenzung:
*
* 1. **Harte Obergrenze** von 30 Minuten ab Anmeldung — unabhängig von jeder
* Aktivität.
* 2. **Untätigkeitsgrenze**: Wer nichts tut, wird abgemeldet.
* 3. **Sofortiger Widerruf serverseitig.** Die Tokens sind opak, kein JWT —
* genau deshalb wurden sie so gewählt. Ein gesperrtes Konto ist mit der
* nächsten Anfrage draußen, nicht erst nach Ablauf.
*
* Bleibt das Restrisiko: Ein Skript, das über XSS auf der Seite läuft, kann
* `sessionStorage` lesen. Dagegen steht die Content-Security-Policy der
* Auslieferung — und die Feststellung, dass ein Angreifer mit Skriptausführung
* ohnehin jede Anfrage im Namen des Angemeldeten stellen könnte, auch bei
* Tokens im Arbeitsspeicher.
*/
import type { TokenPair } from '../../types/auth';
/** Schlüssel der Ablage. */
const SESSION_STORAGE_KEY = 'syncova.session';
/** Harte Obergrenze einer Sitzung ab Anmeldung. */
export const SESSION_MAXIMUM_MS = 30 * 60 * 1000;
/** Grenze der Untätigkeit. */
export const SESSION_IDLE_MS = 30 * 60 * 1000;
/** Der abgelegte Zustand einer Sitzung. */
export interface StoredSession {
readonly accessToken: string;
readonly refreshToken: string;
/** Zeitpunkt, zu dem die Sitzung endet — unabhängig von Aktivität. */
readonly absoluteExpiryMs: number;
/** Zeitpunkt der letzten Interaktion. */
readonly lastActivityMs: number;
}
/** Liest die abgelegte Sitzung; `null`, wenn keine gültige vorliegt. */
export function readStoredSession(): StoredSession | null {
let rawValue: string | null = null;
try {
rawValue = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
} catch {
// Ein Browser mit gesperrtem Speicher ist kein Fehlerfall — die Sitzung
// gilt dann eben nur für diese Seitenansicht.
return null;
}
if (rawValue === null) {
return null;
}
let parsedSession: Partial<StoredSession>;
try {
parsedSession = JSON.parse(rawValue) as Partial<StoredSession>;
} catch {
// Ein unlesbarer Eintrag wird verworfen, nicht geraten.
clearStoredSession();
return null;
}
if (
typeof parsedSession.accessToken !== 'string' ||
typeof parsedSession.refreshToken !== 'string' ||
typeof parsedSession.absoluteExpiryMs !== 'number' ||
typeof parsedSession.lastActivityMs !== 'number'
) {
clearStoredSession();
return null;
}
const currentTime = Date.now();
// Beide Grenzen werden schon beim Lesen geprüft. Eine abgelaufene Sitzung
// darf gar nicht erst in den Arbeitsspeicher zurückkehren.
if (
currentTime >= parsedSession.absoluteExpiryMs ||
currentTime - parsedSession.lastActivityMs >= SESSION_IDLE_MS
) {
clearStoredSession();
return null;
}
return parsedSession as StoredSession;
}
/** Legt eine neue Sitzung ab und setzt beide Uhren. */
export function writeStoredSession(tokenPair: TokenPair): StoredSession {
const currentTime = Date.now();
const storedSession: StoredSession = {
accessToken: tokenPair.access_token,
refreshToken: tokenPair.refresh_token,
absoluteExpiryMs: currentTime + SESSION_MAXIMUM_MS,
lastActivityMs: currentTime,
};
persist(storedSession);
return storedSession;
}
/**
* Vermerkt eine Interaktion.
*
* Die harte Obergrenze bleibt unberührt — sonst ließe sich eine Sitzung durch
* Mausbewegung endlos verlängern, und „30 Minuten" wäre keine Zusage.
*/
export function touchStoredSession(): StoredSession | null {
const storedSession = readStoredSession();
if (storedSession === null) {
return null;
}
const refreshedSession: StoredSession = {
...storedSession,
lastActivityMs: Date.now(),
};
persist(refreshedSession);
return refreshedSession;
}
/** Verwirft die abgelegte Sitzung. */
export function clearStoredSession(): void {
try {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
} catch {
// Nichts zu tun: Es gibt keinen Speicher, aus dem etwas zu entfernen wäre.
}
}
/** Schreibt den Zustand in den Sitzungsspeicher. */
function persist(storedSession: StoredSession): void {
try {
window.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession));
} catch {
// Ohne Speicher läuft die Sitzung weiter, überlebt aber kein Neuladen.
}
}
/**
* Liefert die verbleibende Zeit in Millisekunden.
*
* Maßgeblich ist die **frühere** der beiden Grenzen: Eine Sitzung endet, sobald
* eine von beiden erreicht ist.
*/
export function remainingSessionMs(storedSession: StoredSession): number {
const currentTime = Date.now();
const untilAbsolute = storedSession.absoluteExpiryMs - currentTime;
const untilIdle = storedSession.lastActivityMs + SESSION_IDLE_MS - currentTime;
return Math.max(0, Math.min(untilAbsolute, untilIdle));
}

View File

@ -1,172 +0,0 @@
/**
* Sitzungsuhr und Untätigkeitserkennung.
*
* Der Haken liefert die verbleibende Zeit sekundengenau für die Anzeige und
* meldet das Ende, sobald eine der beiden Grenzen erreicht ist.
*
* Umgesetzt mit `useSyncExternalStore`, und das ist keine Geschmacksfrage: Die
* verbleibende Zeit hängt an zwei Dingen außerhalb von React — der Uhr und dem
* Sitzungsspeicher. Sie beim Rendern auszurechnen wäre ein unreiner Aufruf, sie
* in einem Effekt in den Zustand zu schreiben eine zweite Renderrunde je
* Sekunde. `useSyncExternalStore` ist genau für diesen Fall gebaut.
*
* Die Interaktionen werden **gedrosselt** vermerkt: Jede Mausbewegung in den
* Sitzungsspeicher zu schreiben wären hunderte Schreibvorgänge je Minute, und
* `sessionStorage` schreibt synchron. Einmal alle zehn Sekunden genügt — die
* Grenze liegt bei dreißig Minuten.
*/
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
import {
readStoredSession,
remainingSessionMs,
touchStoredSession,
} from './sessionStore';
/** Abstand, in dem eine Interaktion frühestens erneut vermerkt wird. */
const ACTIVITY_THROTTLE_MS = 10_000;
/** Ereignisse, die als Interaktion gelten. */
const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
export interface SessionClock {
/** Verbleibende Sekunden bis zum Ende der Sitzung. */
readonly remainingSeconds: number;
/** Fällt die Sitzung in Kürze aus? */
readonly isExpiringSoon: boolean;
}
/** Angemeldete Beobachter der Sitzungsuhr. */
const clockSubscribers = new Set<() => void>();
/** Zeitgeber, der einmal je Sekunde alle Beobachter weckt. */
let clockIntervalHandle: number | null = null;
/** Meldet einen Beobachter an und startet den Zeitgeber beim ersten. */
function subscribeToClock(notify: () => void): () => void {
clockSubscribers.add(notify);
if (clockIntervalHandle === null) {
clockIntervalHandle = window.setInterval(() => {
for (const subscriber of clockSubscribers) {
subscriber();
}
}, 1000);
}
return () => {
clockSubscribers.delete(notify);
// Ohne Beobachter läuft kein Zeitgeber weiter — sonst tickte er nach dem
// Abmelden bis zum Schließen des Tabs.
if (clockSubscribers.size === 0 && clockIntervalHandle !== null) {
window.clearInterval(clockIntervalHandle);
clockIntervalHandle = null;
}
};
}
/**
* Liefert die verbleibenden Sekunden.
*
* Der Wert ist ganzzahlig, damit `useSyncExternalStore` ihn zwischen zwei
* Sekunden als unverändert erkennt und kein Rendern auslöst.
*/
function readRemainingSeconds(): number {
const storedSession = readStoredSession();
if (storedSession === null) {
return 0;
}
return Math.ceil(remainingSessionMs(storedSession) / 1000);
}
/** Ohne Anmeldung läuft keine Uhr. */
function readZero(): number {
return 0;
}
/**
* Beobachtet die Sitzung und ruft `onExpired` genau einmal auf.
*
* @param isActive Nur bei bestehender Anmeldung läuft die Uhr.
* @param onExpired Wird beim Ablauf aufgerufen.
*/
export function useSession(isActive: boolean, onExpired: () => void): SessionClock {
const remainingSeconds = useSyncExternalStore(
isActive ? subscribeToClock : noopSubscribe,
isActive ? readRemainingSeconds : readZero,
readZero,
);
const onExpiredReference = useRef(onExpired);
const lastActivityWriteReference = useRef(0);
const hasExpiredReference = useRef(false);
useEffect(() => {
onExpiredReference.current = onExpired;
});
/** Vermerkt eine Interaktion, höchstens alle zehn Sekunden. */
const noteActivity = useCallback(() => {
const activityTime = Date.now();
if (activityTime - lastActivityWriteReference.current < ACTIVITY_THROTTLE_MS) {
return;
}
lastActivityWriteReference.current = activityTime;
touchStoredSession();
}, []);
useEffect(() => {
if (!isActive) {
hasExpiredReference.current = false;
return;
}
for (const eventName of ACTIVITY_EVENTS) {
// `passive` verhindert, dass die Erkennung das Scrollen ausbremst.
window.addEventListener(eventName, noteActivity, { passive: true });
}
return () => {
for (const eventName of ACTIVITY_EVENTS) {
window.removeEventListener(eventName, noteActivity);
}
};
}, [isActive, noteActivity]);
// Das Ende wird gemeldet, sobald der abgeleitete Wert null erreicht — als
// Folge einer Zustandsänderung, nicht als Nebeneffekt des Renderns.
useEffect(() => {
if (!isActive || remainingSeconds > 0 || hasExpiredReference.current) {
return;
}
hasExpiredReference.current = true;
onExpiredReference.current();
}, [isActive, remainingSeconds]);
return {
remainingSeconds,
// Fünf Minuten vorher wird die Anzeige auffällig. Früher zu warnen
// erzeugt Gewöhnung, später lässt keine Zeit zum Speichern.
isExpiringSoon: remainingSeconds > 0 && remainingSeconds <= 300,
};
}
/** Ohne Anmeldung gibt es nichts zu beobachten. */
function noopSubscribe(): () => void {
return () => undefined;
}
/** Formatiert eine Sekundenzahl als `mm:ss`. */
export function formatSessionClock(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

View File

@ -1,18 +1,18 @@
/** /**
* Einzelheiten eines Wiederherstellungspunkts. * Einzelheiten eines Wiederherstellungspunkts.
* *
* Diese Seite beantwortet die einzige Frage, die bei einem Backup zählt: * Diese Seite beantwortet die einzige Frage, die bei einem Backup zaehlt:
* **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der * **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der
* die Antwort belastbar wird: * die Antwort belastbar wird:
* *
* 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist. * 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist.
* 2. Die fehlenden Messungen sind die Handlungsanweisung. * 2. Die fehlenden Messungen sind die Handlungsanweisung.
* 3. Die Prüfung erzeugt den Beleg; nur der Wiederherstellungstest ist einer. * 3. Die Pruefung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
* 4. Der Schutz sagt, ob jemand das alles löschen könnte. * 4. Der Schutz sagt, ob jemand das alles loeschen koennte.
* *
* Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest * Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest
* sich wie "weitgehend in Ordnung" — bei einem beschädigten Backup wäre das * sich wie "weitgehend in Ordnung" — bei einem beschaedigten Backup waere das
* grob irreführend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar * grob irrefuehrend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
* nicht. * nicht.
*/ */
@ -116,19 +116,19 @@ export function BackupDetailPage({
const verifyMutation = useMutation(startVerification, { const verifyMutation = useMutation(startVerification, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Prüfung gestartet', 'Pruefung gestartet',
'Das Ergebnis erscheint unter „Prüfung“, sobald sie durchgelaufen ist.', 'Das Ergebnis erscheint unter „Pruefung“, sobald sie durchgelaufen ist.',
); );
setIsVerifyDialogOpen(false); setIsVerifyDialogOpen(false);
reloadAll(); reloadAll();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Prüfung ließ sich nicht starten', describeApiError(apiError), apiError.requestId), toast.showError('Die Pruefung liess sich nicht starten', describeApiError(apiError), apiError.requestId),
}); });
const legalHoldMutation = useMutation(setLegalHold, { const legalHoldMutation = useMutation(setLegalHold, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt lässt sich jetzt nicht löschen.'); toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt laesst sich jetzt nicht loeschen.');
setIsLegalHoldDialogOpen(false); setIsLegalHoldDialogOpen(false);
setHoldReason(''); setHoldReason('');
reloadAll(); reloadAll();
@ -150,7 +150,7 @@ export function BackupDetailPage({
const extendMutation = useMutation(extendRetention, { const extendMutation = useMutation(extendRetention, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Aufbewahrungsfrist verlängert'); toast.showSuccess('Aufbewahrungsfrist verlaengert');
setIsExtendDialogOpen(false); setIsExtendDialogOpen(false);
reloadAll(); reloadAll();
}, },
@ -161,13 +161,13 @@ export function BackupDetailPage({
const deleteMutation = useMutation(deleteBackup, { const deleteMutation = useMutation(deleteBackup, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Wiederherstellungspunkt gelöscht', 'Wiederherstellungspunkt geloescht',
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Blöcke werden von einem anderen Backup gebraucht.', 'Wird kein Speicher frei, liegt das an der Deduplizierung — die Bloecke werden von einem anderen Backup gebraucht.',
); );
onDeleted(); onDeleted();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
if (assuranceResource.loadState === 'loading') { if (assuranceResource.loadState === 'loading') {
@ -215,7 +215,7 @@ export function BackupDetailPage({
{mayVerify ? ( {mayVerify ? (
<Button onClick={() => setIsVerifyDialogOpen(true)}> <Button onClick={() => setIsVerifyDialogOpen(true)}>
<BadgeCheck /> <BadgeCheck />
Prüfen Pruefen
</Button> </Button>
) : null} ) : null}
</> </>
@ -251,11 +251,11 @@ export function BackupDetailPage({
</div> </div>
{/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als {/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als
Vermutung gekennzeichnet — sonst wird sie zitiert, als wäre Vermutung gekennzeichnet — sonst wird sie zitiert, als waere
sie eine Messung. */} sie eine Messung. */}
{!assurance.is_trustworthy ? ( {!assurance.is_trustworthy ? (
<Callout tone="warning" title="Diese Zahl ist eine Vermutung" className="mt-4"> <Callout tone="warning" title="Diese Zahl ist eine Vermutung" className="mt-4">
{assurance.unknown_input_count} Eingangsgrößen wurden nicht {assurance.unknown_input_count} Eingangsgroessen wurden nicht
gemessen und gehen weder positiv noch negativ ein. Solange das so gemessen und gehen weder positiv noch negativ ein. Solange das so
ist, sagt der Prozentwert wenig. ist, sagt der Prozentwert wenig.
</Callout> </Callout>
@ -307,7 +307,7 @@ export function BackupDetailPage({
</div> </div>
<dl className="mt-5 grid gap-4 border-t border-line pt-4 sm:grid-cols-2"> <dl className="mt-5 grid gap-4 border-t border-line pt-4 sm:grid-cols-2">
<DetailItem label="Zuletzt geprüft"> <DetailItem label="Zuletzt geprueft">
{assurance.last_verified_at {assurance.last_verified_at
? formatDateTime(assurance.last_verified_at) ? formatDateTime(assurance.last_verified_at)
: 'Nie'} : 'Nie'}
@ -328,7 +328,7 @@ export function BackupDetailPage({
{/* --- Schutz --- */} {/* --- Schutz --- */}
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader title="Löschschutz" /> <CardHeader title="Loeschschutz" />
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{protection ? ( {protection ? (
<> <>
@ -339,7 +339,7 @@ export function BackupDetailPage({
<ShieldOff className="size-4 text-fg-subtle" aria-hidden /> <ShieldOff className="size-4 text-fg-subtle" aria-hidden />
)} )}
<span className="text-sm text-fg"> <span className="text-sm text-fg">
{protection.is_protected ? 'Geschützt' : 'Nicht geschützt'} {protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'}
</span> </span>
</div> </div>
@ -353,7 +353,7 @@ export function BackupDetailPage({
{protection.legal_hold ? ( {protection.legal_hold ? (
<Callout tone="info" title="Legal Hold"> <Callout tone="info" title="Legal Hold">
{protection.legal_hold_reason || 'Ohne Begründung gesetzt.'} {protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'}
</Callout> </Callout>
) : null} ) : null}
@ -383,7 +383,7 @@ export function BackupDetailPage({
</Button> </Button>
)} )}
<Button size="sm" onClick={() => setIsExtendDialogOpen(true)}> <Button size="sm" onClick={() => setIsExtendDialogOpen(true)}>
Frist verlängern Frist verlaengern
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -397,7 +397,7 @@ export function BackupDetailPage({
{/* --- Ransomware --- */} {/* --- Ransomware --- */}
{ransomware ? ( {ransomware ? (
<Card> <Card>
<CardHeader title="Auffälligkeiten" /> <CardHeader title="Auffaelligkeiten" />
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<StatusBadge status={ransomware.severity} /> <StatusBadge status={ransomware.severity} />
{ransomware.summary ? ( {ransomware.summary ? (
@ -405,15 +405,15 @@ export function BackupDetailPage({
) : null} ) : null}
{ransomware.severity === 'unknown' ? ( {ransomware.severity === 'unknown' ? (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
Unter fünf Vergleichsläufen gibt es keinen belastbaren Unter fuenf Vergleichslaeufen gibt es keinen belastbaren
Basiswert. Wer nicht messen kann, hat nichts gemessen — eine Basiswert. Wer nicht messen kann, hat nichts gemessen — eine
geratene Schwelle wäre schlechter als keine. geratene Schwelle waere schlechter als keine.
</p> </p>
) : null} ) : null}
{ransomware.triggered_signal_count !== undefined ? ( {ransomware.triggered_signal_count !== undefined ? (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
{ransomware.triggered_signal_count} von 6 Signalen auffällig. {ransomware.triggered_signal_count} von 6 Signalen auffaellig.
Diese Einschätzung <strong>meldet nur</strong> und handelt nie. Diese Einschaetzung <strong>meldet nur</strong> und handelt nie.
</p> </p>
) : null} ) : null}
</CardContent> </CardContent>
@ -428,12 +428,12 @@ export function BackupDetailPage({
disabled={protection?.is_protected} disabled={protection?.is_protected}
title={ title={
protection?.is_protected protection?.is_protected
? 'Dieser Punkt steht unter Schutz und lässt sich nicht löschen.' ? 'Dieser Punkt steht unter Schutz und laesst sich nicht loeschen.'
: undefined : undefined
} }
> >
<Trash2 /> <Trash2 />
Löschen Loeschen
</Button> </Button>
) : null} ) : null}
</div> </div>
@ -453,14 +453,14 @@ export function BackupDetailPage({
<ConfirmDialog <ConfirmDialog
open={isVerifyDialogOpen} open={isVerifyDialogOpen}
onOpenChange={setIsVerifyDialogOpen} onOpenChange={setIsVerifyDialogOpen}
title="Prüfung starten" title="Pruefung starten"
confirmLabel="Prüfung starten" confirmLabel="Pruefung starten"
isLoading={verifyMutation.isRunning} isLoading={verifyMutation.isRunning}
onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)} onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)}
> >
<div className="space-y-3"> <div className="space-y-3">
<SelectInput <SelectInput
label="Prüfart" label="Pruefart"
value={verificationType} value={verificationType}
onChange={(changeEvent) => setVerificationType(changeEvent.target.value)} onChange={(changeEvent) => setVerificationType(changeEvent.target.value)}
> >
@ -482,7 +482,7 @@ export function BackupDetailPage({
{verificationType === 'restore_test' ? ( {verificationType === 'restore_test' ? (
<Callout tone="info"> <Callout tone="info">
Nur diese Prüfung ist ein <strong>Nachweis</strong>. Sie hebt die Nur diese Pruefung ist ein <strong>Nachweis</strong>. Sie hebt die
Einstufung auf „wiederherstellbar“ — alles andere sind Indizien. Einstufung auf „wiederherstellbar“ — alles andere sind Indizien.
</Callout> </Callout>
) : null} ) : null}
@ -493,17 +493,17 @@ export function BackupDetailPage({
open={isLegalHoldDialogOpen} open={isLegalHoldDialogOpen}
onOpenChange={setIsLegalHoldDialogOpen} onOpenChange={setIsLegalHoldDialogOpen}
title="Legal Hold setzen" title="Legal Hold setzen"
description="Der Punkt lässt sich danach nicht mehr löschen — unbefristet." description="Der Punkt laesst sich danach nicht mehr loeschen — unbefristet."
confirmLabel="Legal Hold setzen" confirmLabel="Legal Hold setzen"
isLoading={legalHoldMutation.isRunning} isLoading={legalHoldMutation.isRunning}
onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)} onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)}
> >
<TextArea <TextArea
label="Begründung" label="Begruendung"
required required
value={holdReason} value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)} onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
hint="Pflichtangabe. Ohne sie traut sich später niemand, den Schutz wieder aufzuheben." hint="Pflichtangabe. Ohne sie traut sich spaeter niemand, den Schutz wieder aufzuheben."
placeholder="Rechtsstreit Az. 4 O 128/26, angeordnet am 18.08.2026" placeholder="Rechtsstreit Az. 4 O 128/26, angeordnet am 18.08.2026"
/> />
</ConfirmDialog> </ConfirmDialog>
@ -518,7 +518,7 @@ export function BackupDetailPage({
onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)} onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)}
> >
<TextArea <TextArea
label="Begründung" label="Begruendung"
required required
value={holdReason} value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)} onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
@ -529,8 +529,8 @@ export function BackupDetailPage({
<ConfirmDialog <ConfirmDialog
open={isExtendDialogOpen} open={isExtendDialogOpen}
onOpenChange={setIsExtendDialogOpen} onOpenChange={setIsExtendDialogOpen}
title="Aufbewahrungsfrist verlängern" title="Aufbewahrungsfrist verlaengern"
confirmLabel="Verlängern" confirmLabel="Verlaengern"
isLoading={extendMutation.isRunning} isLoading={extendMutation.isRunning}
onConfirm={() => onConfirm={() =>
void extendMutation.run( void extendMutation.run(
@ -549,13 +549,13 @@ export function BackupDetailPage({
onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)} onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)}
/> />
<TextArea <TextArea
label="Begründung" label="Begruendung"
value={holdReason} value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)} onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
/> />
<Callout tone="info"> <Callout tone="info">
Verlängern ja, <strong>verkürzen nie</strong> — auch nicht für Verlaengern ja, <strong>verkuerzen nie</strong> — auch nicht fuer
Administratoren. Ein früheres Datum wird abgelehnt. Administratoren. Ein frueheres Datum wird abgelehnt.
</Callout> </Callout>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
@ -563,14 +563,14 @@ export function BackupDetailPage({
<ConfirmDialog <ConfirmDialog
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}
title="Wiederherstellungspunkt löschen" title="Wiederherstellungspunkt loeschen"
confirmLabel="Endgültig löschen" confirmLabel="Endgueltig loeschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
confirmationWord={backupIdentifier} confirmationWord={backupIdentifier}
confirmationHint={ confirmationHint={
<> <>
Tippen Sie zur Bestätigung die Kennung:{' '} Tippen Sie zur Bestaetigung die Kennung:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{backupIdentifier} {backupIdentifier}
</code> </code>
@ -582,7 +582,7 @@ export function BackupDetailPage({
> >
<Callout tone="warning"> <Callout tone="warning">
Wird danach kein Speicher frei, ist das <strong>kein Fehler</strong>, Wird danach kein Speicher frei, ist das <strong>kein Fehler</strong>,
sondern Deduplizierung: Die Blöcke werden von einem anderen Backup sondern Deduplizierung: Die Bloecke werden von einem anderen Backup
gebraucht. gebraucht.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>

View File

@ -1,15 +1,15 @@
/** /**
* Prüfungen — Übersicht. * Pruefungen — Uebersicht.
* *
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten * Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
* nebeneinander und wird gern verwechselt: * nebeneinander und wird gern verwechselt:
* *
* - **Zustand** (`status`): Ist die Prüfung durchgelaufen? * - **Zustand** (`status`): Ist die Pruefung durchgelaufen?
* - **Ergebnis** (`result`): Was hat sie gefunden? * - **Ergebnis** (`result`): Was hat sie gefunden?
* *
* Eine gescheiterte Prüfung ist **kein Befund am Backup**. Repository nicht * Eine gescheiterte Pruefung ist **kein Befund am Backup**. Repository nicht
* erreichbar heißt: Auftrag `failed`, Einstufung unberührt. Wer beides * erreichbar heisst: Auftrag `failed`, Einstufung unberuehrt. Wer beides
* vermengt, baut ein Prüfwerkzeug, das grundlos Alarm schlägt — und das wird * vermengt, baut ein Pruefwerkzeug, das grundlos Alarm schlaegt — und das wird
* bald nicht mehr ernst genommen. * bald nicht mehr ernst genommen.
*/ */
@ -54,7 +54,7 @@ export function VerificationPage({
const cancelMutation = useMutation(cancelVerification, { const cancelMutation = useMutation(cancelVerification, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Prüfung abgebrochen'); toast.showSuccess('Pruefung abgebrochen');
verificationsResource.reload(); verificationsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
@ -66,7 +66,7 @@ export function VerificationPage({
const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [ const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [
{ {
key: 'type', key: 'type',
header: 'Prüfart', header: 'Pruefart',
render: (job) => ( render: (job) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-fg"> <p className="font-medium text-fg">
@ -91,21 +91,21 @@ export function VerificationPage({
job.result ? ( job.result ? (
<StatusBadge status={job.result} /> <StatusBadge status={job.result} />
) : ( ) : (
// Kein Ergebnis heißt: Die Prüfung lief nicht durch. Das ist etwas // Kein Ergebnis heisst: Die Pruefung lief nicht durch. Das ist etwas
// anderes als "ohne Befund" und darf nicht danach aussehen. // anderes als "ohne Befund" und darf nicht danach aussehen.
<span className="text-xs text-fg-muted">—</span> <span className="text-xs text-fg-muted">—</span>
), ),
}, },
{ {
key: 'chunks', key: 'chunks',
header: 'Blöcke', header: 'Bloecke',
numeric: true, numeric: true,
render: (job) => ( render: (job) => (
<span> <span>
{job.chunks_checked.toLocaleString('de-DE')} {job.chunks_checked.toLocaleString('de-DE')}
{job.chunks_missing > 0 || job.chunks_corrupted > 0 ? ( {job.chunks_missing > 0 || job.chunks_corrupted > 0 ? (
<span className="ml-1 text-critical"> <span className="ml-1 text-critical">
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschädigt) ({job.chunks_missing} fehlend, {job.chunks_corrupted} beschaedigt)
</span> </span>
) : null} ) : null}
</span> </span>
@ -156,7 +156,7 @@ export function VerificationPage({
if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) { if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) {
return ( return (
<> <>
<PageHeader title="Prüfung" /> <PageHeader title="Pruefung" />
<Card> <Card>
<ErrorState <ErrorState
message={describeApiError(verificationsResource.loadError)} message={describeApiError(verificationsResource.loadError)}
@ -171,8 +171,8 @@ export function VerificationPage({
return ( return (
<> <>
<PageHeader <PageHeader
title="Prüfung" title="Pruefung"
description="Nachweise über die Wiederherstellbarkeit." description="Nachweise ueber die Wiederherstellbarkeit."
actions={ actions={
<Button variant="secondary" onClick={verificationsResource.reload}> <Button variant="secondary" onClick={verificationsResource.reload}>
<RefreshCw /> <RefreshCw />
@ -182,7 +182,9 @@ export function VerificationPage({
/> />
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis"> <Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine gescheiterte Prüfung ist kein Befund am Backup. Eine <strong>gescheiterte Pruefung</strong> ist kein Befund am Backup.
Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen
— die Einstufung des Wiederherstellungspunkts bleibt davon unberuehrt.
</Callout> </Callout>
<Card> <Card>
@ -192,8 +194,8 @@ export function VerificationPage({
getRowKey={(job) => job.id} getRowKey={(job) => job.id}
onRowClick={(job) => onOpenBackup(job.backup_id)} onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'} isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Prüfung" emptyTitle="Noch keine Pruefung"
emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt." emptyDescription="Eine Pruefung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz."
/> />
</Card> </Card>
</> </>

View File

@ -1,12 +1,12 @@
/** /**
* API-Anbindung der Wiederherstellungspunkte: Bewertung, Schutz, Prüfung. * API-Anbindung der Wiederherstellungspunkte: Bewertung, Schutz, Pruefung.
* *
* Diese drei Dinge gehören zusammen, weil sie dieselbe Frage aus drei * Diese drei Dinge gehoeren zusammen, weil sie dieselbe Frage aus drei
* Richtungen beantworten: **Kann ich mich auf dieses Backup verlassen?** * Richtungen beantworten: **Kann ich mich auf dieses Backup verlassen?**
* *
* - Die Bewertung sagt, wie gut die Auskunft belegt ist. * - Die Bewertung sagt, wie gut die Auskunft belegt ist.
* - Der Schutz sagt, ob es jemand löschen könnte. * - Der Schutz sagt, ob es jemand loeschen koennte.
* - Die Prüfung erzeugt den Beleg. * - Die Pruefung erzeugt den Beleg.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -15,17 +15,17 @@ import { requestApi } from '../../api/client';
// Bewertung (Phase 10) // Bewertung (Phase 10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Eine Eingangsgröße der Bewertung. */ /** Eine Eingangsgroesse der Bewertung. */
export interface AssuranceInput { export interface AssuranceInput {
name: string; name: string;
/** Höchstpunktzahl dieser Größe. */ /** Hoechstpunktzahl dieser Groesse. */
weight: number; weight: number;
earned_points: number; earned_points: number;
/** /**
* Meldet, ob die Größe gemessen wurde. * Meldet, ob die Groesse gemessen wurde.
* *
* Unbekannt zählt **nie** als gut. Eine nicht gemessene Größe wohlwollend * Unbekannt zaehlt **nie** als gut. Eine nicht gemessene Groesse wohlwollend
* zu schätzen wäre die bequeme und falsche Entscheidung. * zu schaetzen waere die bequeme und falsche Entscheidung.
*/ */
is_known: boolean; is_known: boolean;
} }
@ -41,19 +41,19 @@ export interface AssuranceReport {
/** /**
* Meldet, ob die Zahl belastbar ist. * Meldet, ob die Zahl belastbar ist.
* *
* Bei zu vielen ungemessenen Größen ist die Prozentzahl eine Vermutung, * Bei zu vielen ungemessenen Groessen ist die Prozentzahl eine Vermutung,
* keine Aussage — und die Oberfläche muss das sagen. * keine Aussage — und die Oberflaeche muss das sagen.
*/ */
is_trustworthy: boolean; is_trustworthy: boolean;
summary: string; summary: string;
/** Was gemessen werden müsste. Das ist die Handlungsanweisung. */ /** Was gemessen werden muesste. Das ist die Handlungsanweisung. */
missing_measurements: string[]; missing_measurements: string[];
inputs: AssuranceInput[]; inputs: AssuranceInput[];
last_verified_at?: string; last_verified_at?: string;
last_restore_test_at?: string; last_restore_test_at?: string;
} }
/** Lädt die Bewertung eines Wiederherstellungspunkts. */ /** Laedt die Bewertung eines Wiederherstellungspunkts. */
export async function getAssurance( export async function getAssurance(
backupIdentifier: string, backupIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -88,7 +88,7 @@ export interface ProtectionStatus {
enforcement_explanation?: string; enforcement_explanation?: string;
} }
/** Lädt den Schutzstatus. */ /** Laedt den Schutzstatus. */
export async function getProtection( export async function getProtection(
backupIdentifier: string, backupIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -102,7 +102,7 @@ export async function getProtection(
/** /**
* Setzt einen Legal Hold. * Setzt einen Legal Hold.
* *
* Die Begründung ist Pflicht. Ohne sie traut sich später niemand, ihn wieder * Die Begruendung ist Pflicht. Ohne sie traut sich spaeter niemand, ihn wieder
* aufzuheben — und ein Schutz, den niemand mehr aufhebt, ist ein Datenfriedhof. * aufzuheben — und ein Schutz, den niemand mehr aufhebt, ist ein Datenfriedhof.
*/ */
export async function setLegalHold( export async function setLegalHold(
@ -127,10 +127,10 @@ export async function releaseLegalHold(
} }
/** /**
* Verlängert die Aufbewahrungsfrist. * Verlaengert die Aufbewahrungsfrist.
* *
* Verlängern ja, verkürzen nie — auch nicht für Administratoren. Der Server * Verlaengern ja, verkuerzen nie — auch nicht fuer Administratoren. Der Server
* lehnt ein früheres Datum ab; die Oberfläche sagt das vorher. * lehnt ein frueheres Datum ab; die Oberflaeche sagt das vorher.
*/ */
export async function extendRetention( export async function extendRetention(
backupIdentifier: string, backupIdentifier: string,
@ -144,10 +144,10 @@ export async function extendRetention(
} }
/** /**
* Löscht einen Wiederherstellungspunkt. * Loescht einen Wiederherstellungspunkt.
* *
* `confirm_backup_id` wiederholt die Kennung wörtlich — dieselbe Hürde wie * `confirm_backup_id` wiederholt die Kennung woertlich — dieselbe Huerde wie
* beim Überschreiben einer Wiederherstellung. * beim Ueberschreiben einer Wiederherstellung.
*/ */
export async function deleteBackup( export async function deleteBackup(
backupIdentifier: string, backupIdentifier: string,
@ -162,20 +162,20 @@ export async function deleteBackup(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Ransomware-Einschätzung (Phase 16) // Ransomware-Einschaetzung (Phase 16)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Einschätzung auf Auffälligkeiten. */ /** Einschaetzung auf Auffaelligkeiten. */
export interface RansomwareAssessment { export interface RansomwareAssessment {
backup_id: string; backup_id: string;
/** none, elevated, high oder unknown. */ /** none, elevated, high oder unknown. */
severity: string; severity: string;
/** /**
* Zahl der auffälligen Signale von sechs. * Zahl der auffaelligen Signale von sechs.
* *
* Zwei auffällige ergeben `high`, nicht eines: Ein einzelnes hat viele * Zwei auffaellige ergeben `high`, nicht eines: Ein einzelnes hat viele
* harmlose Ursachen; zwei zugleich sind das Muster massenhafter * harmlose Ursachen; zwei zugleich sind das Muster massenhafter
* Verschlüsselung. * Verschluesselung.
*/ */
triggered_signal_count?: number; triggered_signal_count?: number;
signals?: Array<{ signals?: Array<{
@ -187,7 +187,7 @@ export interface RansomwareAssessment {
baseline_sample_count?: number; baseline_sample_count?: number;
} }
/** Lädt die Ransomware-Einschätzung. */ /** Laedt die Ransomware-Einschaetzung. */
export async function getRansomwareAssessment( export async function getRansomwareAssessment(
backupIdentifier: string, backupIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -199,41 +199,41 @@ export async function getRansomwareAssessment(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Prüfung (Phase 10) // Pruefung (Phase 10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Die fünf Prüfarten. */ /** Die fuenf Pruefarten. */
export const VERIFICATION_TYPES = [ export const VERIFICATION_TYPES = [
{ {
value: 'manifest', value: 'manifest',
label: 'Manifest', label: 'Manifest',
description: 'Prüft, ob das Manifest lesbar und in sich stimmig ist. Schnell, aber nur ein Indiz.', description: 'Prueft, ob das Manifest lesbar und in sich stimmig ist. Schnell, aber nur ein Indiz.',
}, },
{ {
value: 'chunk_integrity', value: 'chunk_integrity',
label: 'Blockprüfung', label: 'Blockpruefung',
description: 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.', 'Prueft jeden Block gegen seine gespeicherte Pruefsumme. Laeuft ohne Datenschluessel — die Kennung beschreibt den Klartext, geprueft wird die abgelegte Form.',
}, },
{ {
value: 'chain', value: 'chain',
label: 'Kette', label: 'Kette',
description: 'Prüft die Zusatzsicherungen gegen ihre Elternbackups.', description: 'Prueft die Zusatzsicherungen gegen ihre Elternbackups.',
}, },
{ {
value: 'metadata', value: 'metadata',
label: 'Metadaten', label: 'Metadaten',
description: 'Prüft die Angaben zu Größe, Zeitpunkt und Umfang.', description: 'Prueft die Angaben zu Groesse, Zeitpunkt und Umfang.',
}, },
{ {
value: 'restore_test', value: 'restore_test',
label: 'Wiederherstellungstest', label: 'Wiederherstellungstest',
description: 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.', 'Liest das gesamte Backup und schreibt es versuchsweise zurueck. Nur diese Pruefung ist ein Nachweis — alles andere sind Indizien. Sie verlangt ein eigenes Recht.',
}, },
] as const; ] as const;
/** Ein Prüfauftrag. */ /** Ein Pruefauftrag. */
export interface VerificationJob { export interface VerificationJob {
id: string; id: string;
backup_id: string; backup_id: string;
@ -254,7 +254,7 @@ export interface VerificationJob {
created_at: string; created_at: string;
} }
/** Lädt die Prüfaufträge. */ /** Laedt die Pruefauftraege. */
export async function listVerifications( export async function listVerifications(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<VerificationJob[]> { ): Promise<VerificationJob[]> {
@ -264,7 +264,7 @@ export async function listVerifications(
); );
} }
/** Startet eine Prüfung. */ /** Startet eine Pruefung. */
export async function startVerification( export async function startVerification(
backupIdentifier: string, backupIdentifier: string,
verificationType: string, verificationType: string,
@ -276,7 +276,7 @@ export async function startVerification(
}); });
} }
/** Bricht eine laufende Prüfung ab. */ /** Bricht eine laufende Pruefung ab. */
export async function cancelVerification( export async function cancelVerification(
verificationIdentifier: string, verificationIdentifier: string,
): Promise<VerificationJob> { ): Promise<VerificationJob> {

View File

@ -1,5 +1,5 @@
/** /**
* Tests der Übersicht. * Tests der Uebersicht.
* *
* Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf * Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf
* niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken, * niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken,
@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DashboardPage } from './DashboardPage'; import { DashboardPage } from './DashboardPage';
import type { Dashboard } from './dashboardApi'; import type { Dashboard } from './dashboardApi';
/** Baut eine Antwort mit den übergebenen Kennzahlen. */ /** Baut eine Antwort mit den uebergebenen Kennzahlen. */
function buildDashboardResponse(dashboard: Dashboard): Response { function buildDashboardResponse(dashboard: Dashboard): Response {
return { return {
status: 200, status: 200,
@ -21,7 +21,7 @@ function buildDashboardResponse(dashboard: Dashboard): Response {
} as unknown as Response; } as unknown as Response;
} }
describe('Übersicht', () => { describe('Uebersicht', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('fetch', vi.fn());
}); });
@ -30,7 +30,7 @@ describe('Übersicht', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('zeigt eine Kennzahl samt Erläuterung', async () => { it('zeigt eine Kennzahl samt Erlaeuterung', async () => {
vi.mocked(fetch).mockResolvedValue( vi.mocked(fetch).mockResolvedValue(
buildDashboardResponse({ buildDashboardResponse({
available_count: 1, available_count: 1,
@ -42,7 +42,7 @@ describe('Übersicht', () => {
available: true, available: true,
value: 87.5, value: 87.5,
unit: '%', unit: '%',
detail: '7 von 8 Läufen vollständig erfolgreich.', detail: '7 von 8 Laeufen vollstaendig erfolgreich.',
severity: 'high', severity: 'high',
}, },
], ],
@ -53,7 +53,7 @@ describe('Übersicht', () => {
expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument(); expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument();
expect(screen.getByText('87,5')).toBeInTheDocument(); expect(screen.getByText('87,5')).toBeInTheDocument();
expect(screen.getByText('7 von 8 Läufen vollständig erfolgreich.')).toBeInTheDocument(); expect(screen.getByText('7 von 8 Laeufen vollstaendig erfolgreich.')).toBeInTheDocument();
}); });
it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => { it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => {
@ -78,10 +78,10 @@ describe('Übersicht', () => {
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument(); expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument(); expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
// Der entscheidende Teil: keine Null. Eine Null hieße „keine Probleme" und // Der entscheidende Teil: keine Null. Eine Null hiesse „keine Probleme" und
// würde bedeuten „es wird nicht geprüft". // wuerde bedeuten „es wird nicht geprueft".
expect(screen.queryByText('0')).not.toBeInTheDocument(); expect(screen.queryByText('0')).not.toBeInTheDocument();
expect(screen.getByText('noch nicht verfügbar')).toBeInTheDocument(); expect(screen.getByText('noch nicht verfuegbar')).toBeInTheDocument();
}); });
it('meldet eine nicht bezifferbare Kennzahl als solche', async () => { it('meldet eine nicht bezifferbare Kennzahl als solche', async () => {
@ -94,7 +94,7 @@ describe('Übersicht', () => {
key: 'storage', key: 'storage',
title: 'Speicherbelegung', title: 'Speicherbelegung',
available: true, available: true,
detail: 'Die Gesamtkapazität ist nicht hinterlegt.', detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.',
severity: 'information', severity: 'information',
}, },
], ],
@ -103,7 +103,7 @@ describe('Übersicht', () => {
render(<DashboardPage />); render(<DashboardPage />);
// Verfügbar, aber ohne Wert: Auch hier darf keine Null stehen. // Verfuegbar, aber ohne Wert: Auch hier darf keine Null stehen.
expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument(); expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument(); expect(screen.queryByText('0')).not.toBeInTheDocument();
}); });

View File

@ -1,13 +1,13 @@
/** /**
* Übersicht der Anlage. * Uebersicht der Anlage.
* *
* Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne * Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
* Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht * Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht
* aus wie ein vergessenes; ein mit einer Null gefülltes wäre eine erfundene * aus wie ein vergessenes; ein mit einer Null gefuelltes waere eine erfundene
* Statistik (PROMPT.md §139). * Statistik (PROMPT.md §139).
* *
* Der Schweregrad färbt nur die Randlinie, nicht die Kachel. Zehn farbige * Der Schweregrad faerbt nur die Randlinie, nicht die Kachel. Zehn farbige
* Flächen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl * Flaechen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
* untergeht — und genau die soll auffallen. * untergeht — und genau die soll auffallen.
*/ */
@ -17,18 +17,10 @@ import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader'; import { PageHeader } from '@/components/layout/PageHeader';
import { Card, ErrorState, LoadingState } from '@/components/ui'; import { Card, ErrorState, LoadingState } from '@/components/ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { QuickActions } from './QuickActions';
import { fetchDashboard } from './dashboardApi'; import { fetchDashboard } from './dashboardApi';
import type { Dashboard, DashboardWidget } from './dashboardApi'; import type { Dashboard, DashboardWidget } from './dashboardApi';
export function DashboardPage({ export function DashboardPage(): React.JSX.Element {
grantedPermissions = [],
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( const loadDashboard = useCallback(
(abortSignal: AbortSignal) => fetchDashboard(abortSignal), (abortSignal: AbortSignal) => fetchDashboard(abortSignal),
[], [],
@ -38,7 +30,7 @@ export function DashboardPage({
return ( return (
<> <>
<PageHeader <PageHeader
title="Übersicht" title="Uebersicht"
description={ description={
data !== null data !== null
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.` ? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
@ -46,10 +38,6 @@ export function DashboardPage({
} }
/> />
{onNavigate ? (
<QuickActions grantedPermissions={grantedPermissions} onNavigate={onNavigate} />
) : null}
{loadState === 'loading' ? ( {loadState === 'loading' ? (
<Card> <Card>
<LoadingState label="Die Kennzahlen werden geladen …" /> <LoadingState label="Die Kennzahlen werden geladen …" />
@ -77,7 +65,7 @@ export function DashboardPage({
); );
} }
/** Randfarbe je Schweregrad. Nur die Linie, nicht die Fläche. */ /** Randfarbe je Schweregrad. Nur die Linie, nicht die Flaeche. */
const severityBorderClasses: Readonly<Record<string, string>> = { const severityBorderClasses: Readonly<Record<string, string>> = {
healthy: 'border-l-healthy', healthy: 'border-l-healthy',
info: 'border-l-info', info: 'border-l-info',
@ -92,7 +80,7 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
return ( return (
<Card className="border-l-4 border-l-neutral p-4"> <Card className="border-l-4 border-l-neutral p-4">
<p className="text-sm font-medium text-fg-muted">{widget.title}</p> <p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfügbar</p> <p className="mt-2 text-sm text-fg-subtle">noch nicht verfuegbar</p>
<p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p> <p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
</Card> </Card>
); );
@ -109,8 +97,8 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
<p className="mt-2 flex items-baseline gap-1.5"> <p className="mt-2 flex items-baseline gap-1.5">
{widget.value === undefined ? ( {widget.value === undefined ? (
// Kein Wert heißt nicht null. Eine Kennzahl, die sich nicht bilden // Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
// lässt, sagt das — der Grund steht im Detailtext darunter. // laesst, sagt das — der Grund steht im Detailtext darunter.
<span className="text-sm text-fg-muted">nicht bezifferbar</span> <span className="text-sm text-fg-muted">nicht bezifferbar</span>
) : ( ) : (
<> <>
@ -145,15 +133,15 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
/** Formatiert die Hauptzahl einer Kennzahl. */ /** Formatiert die Hauptzahl einer Kennzahl. */
function formatWidgetValue(widgetValue: number): string { function formatWidgetValue(widgetValue: number): string {
// Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest // Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest
// sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Überlegung wie // sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Ueberlegung wie
// bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie // bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie
// weiß, und auch nicht weniger. // weiss, und auch nicht weniger.
if (widgetValue > 0 && widgetValue < 0.1) { if (widgetValue > 0 && widgetValue < 0.1) {
return '< 0,1'; return '< 0,1';
} }
// Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt // Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt
// mehr als „100 %", wenn drei von tausend Läufen scheiterten. // mehr als „100 %", wenn drei von tausend Laeufen scheiterten.
if (Number.isInteger(widgetValue)) { if (Number.isInteger(widgetValue)) {
return widgetValue.toLocaleString('de-DE'); return widgetValue.toLocaleString('de-DE');
} }
@ -161,7 +149,7 @@ function formatWidgetValue(widgetValue: number): string {
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 }); return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
} }
/** Macht aus einem Schlüssel der Aufschlüsselung eine Beschriftung. */ /** Macht aus einem Schluessel der Aufschluesselung eine Beschriftung. */
function formatBreakdownLabel(breakdownKey: string): string { function formatBreakdownLabel(breakdownKey: string): string {
const readableLabel = breakdownKey.replace(/_/g, ' '); const readableLabel = breakdownKey.replace(/_/g, ' ');

View File

@ -1,140 +0,0 @@
/**
* Schnellzugriffe auf der Übersicht.
*
* Bisher endete die Übersicht bei Kennzahlen — wer etwas anlegen wollte, musste
* erst wissen, unter welchem Menüpunkt es steckt. Die Kacheln nennen die
* Handlung, nicht den Bereich: „Auftrag anlegen" statt „Sicherungsaufträge".
*
* Gezeigt wird nur, was die Rolle auch darf. Eine Kachel, die in eine
* Fehlermeldung führt, ist schlimmer als keine.
*/
import {
Archive,
BadgeCheck,
Clock,
Database,
MonitorCog,
RotateCcw,
Server,
UserPlus,
type LucideIcon,
} from 'lucide-react';
import { Card } from '@/components/ui';
interface QuickAction {
readonly label: string;
readonly hint: string;
readonly icon: LucideIcon;
readonly targetPageId: string;
/** Berechtigung, die die Handlung verlangt. */
readonly permission: string;
}
const QUICK_ACTIONS: readonly QuickAction[] = [
{
label: 'Auftrag anlegen',
hint: 'Was wann wohin',
icon: Clock,
targetPageId: 'jobs',
permission: 'jobs.write',
},
{
label: 'Wiederherstellen',
hint: 'Aus einem Punkt zurück',
icon: RotateCcw,
targetPageId: 'recovery-points',
permission: 'restores.execute',
},
{
label: 'Repository übernehmen',
hint: 'Vorhandene Ablage',
icon: Database,
targetPageId: 'repositories',
permission: 'repositories.write',
},
{
label: 'Prüfung starten',
hint: 'Nachweis erzeugen',
icon: BadgeCheck,
targetPageId: 'recovery-points',
permission: 'verification.write',
},
{
label: 'Agent aufnehmen',
hint: 'Token erzeugen',
icon: MonitorCog,
targetPageId: 'agents',
permission: 'agents.enroll',
},
{
label: 'Proxmox verbinden',
hint: 'Verbund einrichten',
icon: Server,
targetPageId: 'proxmox',
permission: 'providers.write',
},
{
label: 'Aufbewahrung',
hint: 'Regel anlegen',
icon: Archive,
targetPageId: 'retention',
permission: 'retention.write',
},
{
label: 'Benutzer anlegen',
hint: 'Konto und Rollen',
icon: UserPlus,
targetPageId: 'users',
permission: 'users.write',
},
];
export function QuickActions({
grantedPermissions,
onNavigate,
}: {
readonly grantedPermissions: readonly string[];
readonly onNavigate: (pageIdentifier: string) => void;
}) {
const isAllowed = (permission: string): boolean =>
grantedPermissions.includes('*') || grantedPermissions.includes(permission);
const visibleActions = QUICK_ACTIONS.filter((action) => isAllowed(action.permission));
if (visibleActions.length === 0) {
return null;
}
return (
<div className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{visibleActions.map((action) => {
const IconComponent = action.icon;
return (
<Card
key={action.label}
asChild
className="transition-colors hover:border-accent hover:bg-hover"
>
<button
type="button"
onClick={() => onNavigate(action.targetPageId)}
className="flex w-full items-center gap-3 p-3 text-left"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent">
<IconComponent className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-fg">
{action.label}
</span>
<span className="block truncate text-xs text-fg-muted">{action.hint}</span>
</span>
</button>
</Card>
);
})}
</div>
);
}

View File

@ -5,8 +5,8 @@
* 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 * Der Filter „Ungeprueft" heisst serverseitig `successful`. Das ist kein
* Übersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein * Uebersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
* Nachweis der Wiederherstellbarkeit. * Nachweis der Wiederherstellbarkeit.
*/ */
@ -28,26 +28,26 @@ import { formatBytes, formatDateTime, formatRelativeTime } from '@/lib/utils';
import { fetchRecoveryPoints } from './dashboardApi'; import { fetchRecoveryPoints } from './dashboardApi';
import type { RecoveryPoint } from './dashboardApi'; import type { RecoveryPoint } from './dashboardApi';
/** Auswahlmöglichkeiten des Einstufungsfilters. */ /** Auswahlmoeglichkeiten des Einstufungsfilters. */
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [ const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '', label: 'Alle Einstufungen' }, { value: '', label: 'Alle Einstufungen' },
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' }, { value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
{ value: 'verified', label: 'Geprüft' }, { value: 'verified', label: 'Geprueft' },
{ value: 'successful', label: 'Ungeprüft' }, { value: 'successful', label: 'Ungeprueft' },
{ value: 'corrupted', label: 'Beschädigt' }, { value: 'corrupted', label: 'Beschaedigt' },
]; ];
export function RecoveryPointsPage({ export function RecoveryPointsPage({
onOpenBackup, onOpenBackup,
}: { }: {
/** Öffnet die Einzelheiten eines Punktes. */ /** Oeffnet die Einzelheiten eines Punktes. */
readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined; readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined;
} = {}): React.JSX.Element { } = {}): React.JSX.Element {
const [classificationFilter, setClassificationFilter] = useState(''); const [classificationFilter, setClassificationFilter] = useState('');
const [includeDeleted, setIncludeDeleted] = useState(false); const [includeDeleted, setIncludeDeleted] = useState(false);
const [onlyProtected, setOnlyProtected] = useState(false); const [onlyProtected, setOnlyProtected] = useState(false);
// Der Schlüssel bildet die Filter ab: Ändert er sich, wird neu geladen. // Der Schluessel bildet die Filter ab: Aendert er sich, wird neu geladen.
const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`; const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`;
const loadRecoveryPoints = useCallback( const loadRecoveryPoints = useCallback(
@ -131,9 +131,9 @@ export function RecoveryPointsPage({
header: '', header: '',
render: (recoveryPoint) => render: (recoveryPoint) =>
recoveryPoint.deleted_at !== undefined ? ( recoveryPoint.deleted_at !== undefined ? (
// Ein gelöschter Punkt bleibt sichtbar, damit die Historie // Ein geloeschter Punkt bleibt sichtbar, damit die Historie
// nachvollziehbar bleibt — er wird gekennzeichnet, nicht versteckt. // nachvollziehbar bleibt — er wird gekennzeichnet, nicht versteckt.
<span className="text-xs text-fg-subtle">gelöscht</span> <span className="text-xs text-fg-subtle">geloescht</span>
) : null, ) : null,
}, },
]; ];
@ -144,7 +144,7 @@ export function RecoveryPointsPage({
title="Wiederherstellungspunkte" title="Wiederherstellungspunkte"
description={ description={
data !== null data !== null
? `${data.length} Punkte. Nur ein durchgeführter Wiederherstellungstest hebt auf „wiederherstellbar“.` ? `${data.length} Punkte. Nur ein durchgefuehrter Wiederherstellungstest hebt auf „wiederherstellbar“.`
: undefined : undefined
} }
/> />
@ -168,12 +168,12 @@ export function RecoveryPointsPage({
<CheckboxField <CheckboxField
checked={includeDeleted} checked={includeDeleted}
onCheckedChange={setIncludeDeleted} onCheckedChange={setIncludeDeleted}
label="Gelöschte einschließen" label="Geloeschte einschliessen"
/> />
<CheckboxField <CheckboxField
checked={onlyProtected} checked={onlyProtected}
onCheckedChange={setOnlyProtected} onCheckedChange={setOnlyProtected}
label="Nur geschützte" label="Nur geschuetzte"
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
/** /**
* Zugriff auf die Übersicht und die Wiederherstellungspunkte. * Zugriff auf die Uebersicht und die Wiederherstellungspunkte.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -7,29 +7,29 @@ import { requestApi } from '../../api/client';
/** Statusfarbe einer Kennzahl. */ /** Statusfarbe einer Kennzahl. */
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information'; export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
/** Eine Kennzahl der Übersicht. */ /** Eine Kennzahl der Uebersicht. */
export interface DashboardWidget { export interface DashboardWidget {
/** Maschinenlesbarer Bezeichner. */ /** Maschinenlesbarer Bezeichner. */
readonly key: string; readonly key: string;
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Meldet, ob es eine Datengrundlage gibt. */ /** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean; readonly available: boolean;
/** Erklärt eine fehlende Datengrundlage. */ /** Erklaert eine fehlende Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
/** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */ /** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */
readonly value?: number; readonly value?: number;
/** Einheit der Hauptzahl. */ /** Einheit der Hauptzahl. */
readonly unit?: string; readonly unit?: string;
/** Erläuterung in einem Satz. */ /** Erlaeuterung in einem Satz. */
readonly detail?: string; readonly detail?: string;
/** Statusfarbe. */ /** Statusfarbe. */
readonly severity: WidgetSeverity; readonly severity: WidgetSeverity;
/** Ergänzende Einzelwerte. */ /** Ergaenzende Einzelwerte. */
readonly breakdown?: Record<string, number>; readonly breakdown?: Record<string, number>;
} }
/** Die Übersicht der Anlage. */ /** Die Uebersicht der Anlage. */
export interface Dashboard { export interface Dashboard {
/** Kennzahlen in Anzeigereihenfolge. */ /** Kennzahlen in Anzeigereihenfolge. */
readonly widgets: readonly DashboardWidget[]; readonly widgets: readonly DashboardWidget[];
@ -39,9 +39,9 @@ export interface Dashboard {
readonly generated_at: string; readonly generated_at: string;
} }
/** Ein Wiederherstellungspunkt in der Übersicht. */ /** Ein Wiederherstellungspunkt in der Uebersicht. */
export interface RecoveryPoint { export interface RecoveryPoint {
/** Öffentlicher Bezeichner. */ /** Oeffentlicher Bezeichner. */
readonly id: string; readonly id: string;
/** Repository des Punktes. */ /** Repository des Punktes. */
readonly repository_id: string; readonly repository_id: string;
@ -67,7 +67,7 @@ export interface RecoveryPoint {
readonly completed_at?: string; readonly completed_at?: string;
/** Objektive Einstufung. */ /** Objektive Einstufung. */
readonly classification?: string; readonly classification?: string;
/** Letzte Integritätsprüfung in UTC. */ /** Letzte Integritaetspruefung in UTC. */
readonly last_verified_at?: string; readonly last_verified_at?: string;
/** Letzter Wiederherstellungstest in UTC. */ /** Letzter Wiederherstellungstest in UTC. */
readonly last_restore_test_at?: string; readonly last_restore_test_at?: string;
@ -77,34 +77,34 @@ export interface RecoveryPoint {
readonly immutable_until?: string; readonly immutable_until?: string;
/** Meldet einen unbefristeten Schutz. */ /** Meldet einen unbefristeten Schutz. */
readonly legal_hold: boolean; readonly legal_hold: boolean;
/** Meldet, ob eine Löschung derzeit unzulässig ist. */ /** Meldet, ob eine Loeschung derzeit unzulaessig ist. */
readonly is_protected: boolean; readonly is_protected: boolean;
/** Zeitpunkt der Löschung in UTC. */ /** Zeitpunkt der Loeschung in UTC. */
readonly deleted_at?: string; readonly deleted_at?: string;
/** Begründung der Löschung. */ /** Begruendung der Loeschung. */
readonly deletion_reason?: string; readonly deletion_reason?: string;
} }
/** Filter der Liste von Wiederherstellungspunkten. */ /** Filter der Liste von Wiederherstellungspunkten. */
export interface RecoveryPointFilter { export interface RecoveryPointFilter {
/** Beschränkung auf ein Repository. */ /** Beschraenkung auf ein Repository. */
readonly repositoryId?: string; readonly repositoryId?: string;
/** Beschränkung auf eine Einstufung. */ /** Beschraenkung auf eine Einstufung. */
readonly classification?: string; readonly classification?: string;
/** Nimmt gelöschte Punkte auf. */ /** Nimmt geloeschte Punkte auf. */
readonly includeDeleted?: boolean; readonly includeDeleted?: boolean;
/** Beschränkt auf geschützte Punkte. */ /** Beschraenkt auf geschuetzte Punkte. */
readonly onlyProtected?: boolean; readonly onlyProtected?: boolean;
/** Angeforderte Seite. */ /** Angeforderte Seite. */
readonly page?: number; readonly page?: number;
} }
/** Lädt die Übersicht. */ /** Laedt die Uebersicht. */
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> { export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {}); return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
} }
/** Lädt eine Seite von Wiederherstellungspunkten. */ /** Laedt eine Seite von Wiederherstellungspunkten. */
export async function fetchRecoveryPoints( export async function fetchRecoveryPoints(
filter: RecoveryPointFilter = {}, filter: RecoveryPointFilter = {},
abortSignal?: AbortSignal, abortSignal?: AbortSignal,

View File

@ -39,7 +39,7 @@ describe('SystemHealthPanel', () => {
expect(screen.getByText('database')).toBeInTheDocument(); expect(screen.getByText('database')).toBeInTheDocument();
}); });
// Der Zustand wird zusätzlich zur Farbe als Text ausgegeben (PROMPT.md §107). // Der Zustand wird zusaetzlich zur Farbe als Text ausgegeben (PROMPT.md §107).
expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0); expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0);
expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument(); expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument();
}); });
@ -72,7 +72,7 @@ describe('SystemHealthPanel', () => {
render(<SystemHealthPanel />); render(<SystemHealthPanel />);
// Ein Problem darf niemals beschönigt werden (PROMPT.md §140). // Ein Problem darf niemals beschoenigt werden (PROMPT.md §140).
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'), screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
@ -82,7 +82,7 @@ describe('SystemHealthPanel', () => {
expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0); expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0);
}); });
it('zeigt eine verständliche Meldung, wenn die API nicht erreichbar ist', async () => { it('zeigt eine verstaendliche Meldung, wenn die API nicht erreichbar ist', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
render(<SystemHealthPanel />); render(<SystemHealthPanel />);
@ -91,13 +91,13 @@ describe('SystemHealthPanel', () => {
expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByRole('alert')).toBeInTheDocument();
}); });
// Statt eines rohen Fehlercodes erscheint eine erklärende Meldung (PROMPT.md §124). // Statt eines rohen Fehlercodes erscheint eine erklaerende Meldung (PROMPT.md §124).
expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument(); expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
}); });
it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => { it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => {
// Eine Anfrage, die nie antwortet, hält die Komponente im Ladezustand. // Eine Anfrage, die nie antwortet, haelt die Komponente im Ladezustand.
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
render(<SystemHealthPanel />); render(<SystemHealthPanel />);

View File

@ -2,12 +2,12 @@
* Anzeige des Systemzustands. * Anzeige des Systemzustands.
* *
* Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes * Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes
* erhält der Anwender eine verständliche Erklärung und eine * erhaelt der Anwender eine verstaendliche Erklaerung und eine
* Handlungsempfehlung. * Handlungsempfehlung.
* *
* Dass eine kritische Komponente hier mit vollständigem Bericht erscheint, * Dass eine kritische Komponente hier mit vollstaendigem Bericht erscheint,
* obwohl der Endpunkt dabei mit 503 antwortet, ist Absicht: Das Monitoring * obwohl der Endpunkt dabei mit 503 antwortet, ist Absicht: Das Monitoring
* schlägt an, und die Oberfläche kann trotzdem zeigen, **was** kaputt ist. * schlaegt an, und die Oberflaeche kann trotzdem zeigen, **was** kaputt ist.
*/ */
import { useCallback } from 'react'; import { useCallback } from 'react';

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 ausdrücklich "lädt" oder "Fehler" * Ergebnis vorliegt, bleibt der Zustand ausdruecklich "laedt" oder "Fehler"
* (PROMPT.md §138/§139). * (PROMPT.md §138/§139).
*/ */
@ -13,7 +13,7 @@ import type { SystemHealth } from '../../types/api';
/** Ladezustand der Systemzustandsabfrage. */ /** Ladezustand der Systemzustandsabfrage. */
export type HealthLoadState = 'loading' | 'loaded' | 'failed'; export type HealthLoadState = 'loading' | 'loaded' | 'failed';
/** Rückgabewert des Hooks. */ /** Rueckgabewert des Hooks. */
export interface UseSystemHealthResult { export interface UseSystemHealthResult {
/** Aktueller Ladezustand. */ /** Aktueller Ladezustand. */
loadState: HealthLoadState; loadState: HealthLoadState;
@ -21,12 +21,12 @@ export interface UseSystemHealthResult {
systemHealth: SystemHealth | null; systemHealth: SystemHealth | null;
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */ /** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
loadError: ApiError | null; loadError: ApiError | null;
/** Lädt den Systemzustand erneut. */ /** Laedt den Systemzustand erneut. */
reload: () => void; reload: () => void;
} }
/** /**
* Lädt den Systemzustand von GET /api/v1/health. * Laedt den Systemzustand von GET /api/v1/health.
* *
* @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden. * @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden.
* Ein Wert von 0 deaktiviert die automatische Aktualisierung. * Ein Wert von 0 deaktiviert die automatische Aktualisierung.
@ -57,13 +57,13 @@ export function useSystemHealth(refreshIntervalMs = 0): UseSystemHealthResult {
setLoadError(null); setLoadError(null);
setLoadState('loaded'); setLoadState('loaded');
} catch (caughtError) { } catch (caughtError) {
// Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens. // Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') { if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
return; return;
} }
// Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige // Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige
// als aktuellen Zustand auszugeben wäre irreführend. // als aktuellen Zustand auszugeben waere irrefuehrend.
setSystemHealth(null); setSystemHealth(null);
setLoadError( setLoadError(
caughtError instanceof ApiError caughtError instanceof ApiError

View File

@ -2,15 +2,15 @@
* Agenten — Aufnahme und Verwaltung. * Agenten — Aufnahme und Verwaltung.
* *
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt * Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
* und danach nur noch als Hash gespeichert. Die Oberfläche sagt das * und danach nur noch als Hash gespeichert. Die Oberflaeche sagt das
* ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu * ausdruecklich und laesst sich nicht mit Escape schliessen, solange es zu
* sehen ist — wer es wegklickt, muss ein neues erzeugen. * sehen ist — wer es wegklickt, muss ein neues erzeugen.
* *
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst * Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
* könnte er sich als ein anderes System ausgeben. * koennte er sich als ein anderes System ausgeben.
*/ */
import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react'; import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource'; import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation'; import { describeApiError, useMutation } from '@/api/useMutation';
@ -32,7 +32,7 @@ import {
useToast, useToast,
type TableColumn, type TableColumn,
} from '@/components/ui'; } from '@/components/ui';
import { formatDateTime, formatRelativeTime } from '@/lib/utils'; import { formatRelativeTime } from '@/lib/utils';
import { import {
createEnrollmentToken, createEnrollmentToken,
listAgents, listAgents,
@ -71,7 +71,7 @@ export function AgentsPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Das Aufnahme-Token ließ sich nicht erzeugen', 'Das Aufnahme-Token liess sich nicht erzeugen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -209,7 +209,10 @@ export function AgentsPage({
/> />
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Der Agent holt seine Aufträge ab; der Server drückt sie nicht. Der Agent <strong>holt seine Auftraege ab</strong>; der Server drueckt
sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus
nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf
jedem gesicherten System.
</Callout> </Callout>
<Card> <Card>
@ -219,7 +222,7 @@ export function AgentsPage({
getRowKey={(agent) => agent.id} getRowKey={(agent) => agent.id}
isLoading={agentsResource.loadState === 'loading'} isLoading={agentsResource.loadState === 'loading'}
emptyTitle="Kein Agent aufgenommen" emptyTitle="Kein Agent aufgenommen"
emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde." emptyDescription="Ein Agent wird mit einem Aufnahme-Token registriert. Das Token gilt eine Stunde und nur einmal."
emptyAction={ emptyAction={
mayEnroll ? ( mayEnroll ? (
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}> <Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
@ -236,7 +239,7 @@ export function AgentsPage({
<DialogContent> <DialogContent>
<DialogHeader <DialogHeader
title="Agent aufnehmen" title="Agent aufnehmen"
description="Erzeugt ein einmalig gültiges Aufnahme-Token." description="Erzeugt ein einmalig gueltiges Aufnahme-Token."
/> />
<DialogBody> <DialogBody>
<TextInput <TextInput
@ -245,10 +248,13 @@ export function AgentsPage({
value={draftAgentName} value={draftAgentName}
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)} onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
placeholder="dateiserver-01" 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." hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst koennte er sich als ein anderes System ausgeben."
/> />
<Callout tone="info"> <Callout tone="info">
Gilt eine Stunde und nur zur Registrierung. Das Token gilt <strong>eine Stunde</strong> und nur fuer die
Registrierung. Danach erhaelt der Agent ein Betriebstoken mit
ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar
ist.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -287,7 +293,10 @@ export function AgentsPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Das Betriebstoken gilt sofort nicht mehr. Geplante Sicherungen fallen aus. Das Betriebstoken gilt <strong>sofort</strong> nicht mehr. Der Agent
beendet sich beim naechsten Versuch — ein abgelehntes Token behebt
sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
danach aus.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>
@ -297,19 +306,8 @@ export function AgentsPage({
/** /**
* Zeigt das Aufnahme-Token einmalig. * Zeigt das Aufnahme-Token einmalig.
* *
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine * Der Dialog laesst sich nicht versehentlich schliessen: Es gibt nur eine
* Schaltfläche, und sie sagt, was sie bewirkt. * Schaltflaeche, 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({ function IssuedTokenDialog({
token, token,
@ -319,199 +317,64 @@ function IssuedTokenDialog({
readonly onClose: () => void; readonly onClose: () => void;
}) { }) {
const toast = useToast(); const toast = useToast();
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [hasCopied, setHasCopied] = useState(false);
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der const copyToken = async () => {
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
const serverAddress = window.location.origin;
const copyText = async (textToCopy: string, entryKey: string) => {
try { try {
await navigator.clipboard.writeText(textToCopy); await navigator.clipboard.writeText(token.enrollment_token);
setCopiedKey(entryKey); setHasCopied(true);
window.setTimeout(() => setCopiedKey(null), 2000); window.setTimeout(() => setHasCopied(false), 2000);
} catch { } catch {
toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.'); toast.showInfo(
'Kopieren nicht moeglich',
'Markieren Sie das Token und kopieren Sie es 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 ( return (
<DialogRoot open onOpenChange={() => undefined}> <DialogRoot open onOpenChange={() => undefined}>
<DialogContent size="xl"> <DialogContent size="lg">
<DialogHeader <DialogHeader
title="Agent aufnehmen" title="Aufnahme-Token"
description={`Für „${token.agent_name}"`} description={`Fuer den Agenten „${token.agent_name}“`}
/> />
<DialogBody> <DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal"> <Callout tone="warning" title="Dieses Token erscheint genau einmal">
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst, Es wird nur als Hash gespeichert und laesst sich nicht wieder
wenn der Agent aufgenommen ist. abrufen. Schliessen Sie dieses Fenster erst, wenn Sie es sicher
hinterlegt haben — sonst muessen Sie ein neues erzeugen.
</Callout> </Callout>
<div className="border border-line bg-sunken p-3"> <div className="rounded-md border border-line bg-sunken p-3">
<div className="flex items-start justify-between gap-3"> <code className="block break-all font-mono text-sm text-fg">
<code className="min-w-0 break-all font-mono text-sm text-fg"> {token.enrollment_token}
{token.token} </code>
</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> </div>
{/* Systemwahl */} <Button onClick={() => void copyToken()}>
<div className="flex gap-2 border-b border-line pb-3"> {hasCopied ? <KeyRound /> : <Copy />}
<Button {hasCopied ? 'Kopiert' : 'Token kopieren'}
size="sm" </Button>
variant={platform === 'linux' ? 'primary' : 'ghost'}
onClick={() => setPlatform('linux')} <div className="border-t border-line pt-4">
> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
<Terminal /> Auf dem zu sichernden System
Linux </p>
</Button> <code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
<Button syncova-agent enroll --server https://&lt;dieser-server&gt; --token
size="sm" &lt;token&gt; --state /var/lib/syncova-agent/state.json
variant={platform === 'windows' ? 'primary' : 'ghost'} </code>
onClick={() => setPlatform('windows')} <p className="mt-1.5 text-xs text-fg-muted">
> `--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
<MonitorCog /> Mit einem Verzeichnis haelt sich der Agent fuer registriert und
Windows (PowerShell) laeuft ohne Token.
</Button> </p>
</div> </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> </DialogBody>
<DialogFooter> <DialogFooter>
<Button variant="primary" onClick={onClose}> <Button variant="primary" onClick={onClose}>
Der Agent ist aufgenommen Ich habe das Token hinterlegt
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -1,8 +1,8 @@
/** /**
* Geschützte Systeme — die verbundsübergreifende Bestandsliste. * Geschuetzte Systeme — die verbundsuebergreifende Bestandsliste.
* *
* Sie beantwortet die Frage, die keine der Einzelseiten beantwortet: **Was * Sie beantwortet die Frage, die keine der Einzelseiten beantwortet: **Was
* genau ist eigentlich geschützt?** Agenten und virtuelle Gäste stehen * genau ist eigentlich geschuetzt?** Agenten und virtuelle Gaeste stehen
* nebeneinander, weil sie aus Sicht eines Betreibers dasselbe sind — ein * nebeneinander, weil sie aus Sicht eines Betreibers dasselbe sind — ein
* System, von dem es Sicherungen geben soll. * System, von dem es Sicherungen geben soll.
*/ */
@ -111,7 +111,7 @@ export function ProtectedSystemsPage() {
if (agentsResource.loadState === 'failed' && agentsResource.loadError) { if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
return ( return (
<> <>
<PageHeader title="Geschützte Systeme" /> <PageHeader title="Geschuetzte Systeme" />
<Card> <Card>
<ErrorState <ErrorState
message={describeApiError(agentsResource.loadError)} message={describeApiError(agentsResource.loadError)}
@ -126,12 +126,14 @@ export function ProtectedSystemsPage() {
return ( return (
<> <>
<PageHeader <PageHeader
title="Geschützte Systeme" title="Geschuetzte Systeme"
description="Was gesichert wird — Agenten und virtuelle Gäste nebeneinander." description="Was gesichert wird — Agenten und virtuelle Gaeste nebeneinander."
/> />
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Bestand, nicht Sicherungsstand — Aufträge stehen unter „Sicherungsaufträge“. Diese Liste zeigt den <strong>Bestand</strong>, nicht den Sicherungsstand.
Dass ein System hier steht, heisst nicht, dass es einen Auftrag dafuer
gibt — das steht unter „Sicherungsauftraege“.
</Callout> </Callout>
<Card className="mb-4"> <Card className="mb-4">
@ -146,7 +148,7 @@ export function ProtectedSystemsPage() {
</Card> </Card>
<Card> <Card>
<CardHeader title="Virtuelle Gäste" /> <CardHeader title="Virtuelle Gaeste" />
<DataTable <DataTable
columns={machineColumns} columns={machineColumns}
rows={machinesResource.data ?? []} rows={machinesResource.data ?? []}

View File

@ -1,17 +1,17 @@
/** /**
* Proxmox-Verbünde. * Proxmox-Verbuende.
* *
* Dieser Bereich hatte bisher gar keine Oberfläche — neun Endpunkte, die sich * Dieser Bereich hatte bisher gar keine Oberflaeche — neun Endpunkte, die sich
* nur über die API bedienen ließen. * nur ueber die API bedienen liessen.
* *
* Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausführlich * Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausfuehrlich
* beschriftet: * beschriftet:
* *
* - **`http://` wird abgelehnt.** Über diese Verbindung geht ein API-Token. * - **`http://` wird abgelehnt.** Ueber diese Verbindung geht ein API-Token.
* - **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter * - **Ohne hinterlegten Wirtsschluessel keine SSH-Verbindung.** Einen Schalter
* „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt, * „Wirtsschluessel egal" gibt es nicht: Ein Transport, der jeden annimmt,
* macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte * macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte
* dann das Archiv, das Syncova für ein Backup hält. * dann das Archiv, das Syncova fuer ein Backup haelt.
*/ */
import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react'; import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react';
@ -90,7 +90,7 @@ export function ProxmoxPage({
onSuccess: (cluster) => { onSuccess: (cluster) => {
toast.showSuccess( toast.showSuccess(
'Verbund angelegt', 'Verbund angelegt',
`${cluster.name} — prüfen Sie als Nächstes die Verbindung.`, `${cluster.name} — pruefen Sie als Naechstes die Verbindung.`,
); );
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setDraftTokenSecret(''); setDraftTokenSecret('');
@ -98,7 +98,7 @@ export function ProxmoxPage({
clustersResource.reload(); clustersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Verbund ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Der Verbund liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const testMutation = useMutation(testCluster, { const testMutation = useMutation(testCluster, {
@ -110,11 +110,11 @@ export function ProxmoxPage({
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt', apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit völlig // Nicht erreichbar und nicht berechtigt sind zwei Lagen mit voellig
// verschiedener Abhilfe — sie dürfen nicht dieselbe Meldung ergeben. // verschiedener Abhilfe — sie duerfen nicht dieselbe Meldung ergeben.
apiError.statusCode === 503 apiError.statusCode === 503
? `${describeApiError(apiError)} — prüfen Sie Netz, Adresse und Zertifikat.` ? `${describeApiError(apiError)} — pruefen Sie Netz, Adresse und Zertifikat.`
: `${describeApiError(apiError)} — prüfen Sie Token-Kennung und Geheimnis.`, : `${describeApiError(apiError)} — pruefen Sie Token-Kennung und Geheimnis.`,
apiError.requestId, apiError.requestId,
), ),
}); });
@ -124,7 +124,7 @@ export function ProxmoxPage({
toast.showSuccess( toast.showSuccess(
'Bestand erfasst', 'Bestand erfasst',
result.summary ?? result.summary ??
`${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gäste.`, `${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gaeste.`,
); );
clustersResource.reload(); clustersResource.reload();
}, },
@ -141,7 +141,7 @@ export function ProxmoxPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Verbund ließ sich nicht entfernen', 'Der Verbund liess sich nicht entfernen',
apiError.statusCode === 409 apiError.statusCode === 409
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.' ? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
: describeApiError(apiError), : describeApiError(apiError),
@ -191,7 +191,7 @@ export function ProxmoxPage({
) : ( ) : (
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem // "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
// Versuch zu setzen machte daraus ein "zuletzt versucht", und ein // Versuch zu setzen machte daraus ein "zuletzt versucht", und ein
// seit Wochen toter Verbund sähe frisch aus. // seit Wochen toter Verbund saehe frisch aus.
<span className="text-xs text-warning">Nie erreicht</span> <span className="text-xs text-warning">Nie erreicht</span>
), ),
}, },
@ -216,7 +216,7 @@ export function ProxmoxPage({
<> <>
<PageHeader <PageHeader
title="Proxmox" title="Proxmox"
description="Virtualisierungsverbünde und ihre Gäste." description="Virtualisierungsverbuende und ihre Gaeste."
actions={ actions={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}> <Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -228,7 +228,10 @@ export function ProxmoxPage({
/> />
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben"> <Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
Ob eine wiederhergestellte Maschine startet, ist ungeprüft. Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen
Nachbau der API durch. <strong>Ob eine wiederhergestellte Maschine
startet, ist ungeprueft</strong> — dafuer stand kein Verbund zur
Verfuegung.
</Callout> </Callout>
<Card> <Card>
@ -239,7 +242,7 @@ export function ProxmoxPage({
onRowClick={(cluster) => setSelectedCluster(cluster)} onRowClick={(cluster) => setSelectedCluster(cluster)}
isLoading={clustersResource.loadState === 'loading'} isLoading={clustersResource.loadState === 'loading'}
emptyTitle="Kein Verbund eingerichtet" emptyTitle="Kein Verbund eingerichtet"
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten." emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten — kein Ticket, denn ein Token ist dauerhaft gueltig und einzeln widerrufbar."
emptyAction={ emptyAction={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}> <Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -269,7 +272,7 @@ export function ProxmoxPage({
<DialogContent size="lg"> <DialogContent size="lg">
<DialogHeader <DialogHeader
title="Proxmox-Verbund anlegen" title="Proxmox-Verbund anlegen"
description="Zugangsdaten werden verschlüsselt abgelegt und nie wieder ausgeliefert." description="Zugangsdaten werden verschluesselt abgelegt und nie wieder ausgeliefert."
/> />
<DialogBody> <DialogBody>
<TextInput <TextInput
@ -289,7 +292,7 @@ export function ProxmoxPage({
placeholder="https://pve.example:8006" placeholder="https://pve.example:8006"
error={ error={
draftEndpoint.trim().length > 8 && !endpointIsSecure draftEndpoint.trim().length > 8 && !endpointIsSecure
? 'Nur https. Über diese Verbindung geht ein API-Token.' ? 'Nur https. Ueber diese Verbindung geht ein API-Token.'
: undefined : undefined
} }
/> />
@ -320,11 +323,11 @@ export function ProxmoxPage({
value={draftFingerprint} value={draftFingerprint}
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)} onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
placeholder="AB:CD:EF:…" placeholder="AB:CD:EF:…"
hint="Fingerabdruckbindung ist strenger als eine CA-Prüfung: Es gilt genau ein Zertifikat." hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat."
/> />
<TextInput <TextInput
label="Speicherkennung für Sicherungen" label="Speicherkennung fuer Sicherungen"
required required
className="font-mono" className="font-mono"
value={draftStorageId} value={draftStorageId}
@ -339,7 +342,7 @@ export function ProxmoxPage({
onChange={(changeEvent) => onChange={(changeEvent) =>
setDraftTransport(changeEvent.target.value as TransportKind) 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." hint="Proxmox kann eine Sicherung anstossen, die entstandene Datei aber nicht ueber die API herausgeben. Deshalb braucht es einen zweiten Weg."
> >
<option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option> <option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option>
<option value="ssh">SSH zum Knoten</option> <option value="ssh">SSH zum Knoten</option>
@ -364,15 +367,15 @@ export function ProxmoxPage({
/> />
</div> </div>
<TextArea <TextArea
label="Privater Schlüssel (PEM)" label="Privater Schluessel (PEM)"
required required
className="font-mono text-xs" className="font-mono text-xs"
value={draftSshKey} value={draftSshKey}
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)} onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schlüssel */ placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schluessel */
/> />
<TextInput <TextInput
label="Wirtsschlüssel-Fingerabdruck" label="Wirtsschluessel-Fingerabdruck"
required required
className="font-mono text-xs" className="font-mono text-xs"
value={draftSshFingerprint} value={draftSshFingerprint}
@ -381,7 +384,10 @@ export function ProxmoxPage({
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten." hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
/> />
<Callout tone="warning"> <Callout tone="warning">
Einen Schalter „Wirtsschlüssel egal“ gibt es nicht. Einen Schalter „Wirtsschluessel egal" gibt es nicht. Ein
Transport, der jeden annimmt, macht aus einem Zwischenangriff
eine Einladung — der Angreifer lieferte dann das Archiv, das
Syncova fuer ein Backup haelt.
</Callout> </Callout>
</div> </div>
)} )}
@ -390,7 +396,7 @@ export function ProxmoxPage({
checked={draftKeepArchive} checked={draftKeepArchive}
onCheckedChange={setDraftKeepArchive} onCheckedChange={setDraftKeepArchive}
label="Archiv auf dem Knoten behalten" 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." hint="Normalerweise wird es nach dem Einlesen entfernt. Behaelt man es, fuellt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, fuer die keine Aufbewahrungsregel gilt."
/> />
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -455,7 +461,8 @@ export function ProxmoxPage({
}} }}
> >
<Callout tone="info"> <Callout tone="info">
Die Sicherungen der Gäste bleiben im Repository. Die vorhandenen Sicherungen der Gaeste bleiben im Repository. Verweisen
noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>
@ -508,8 +515,8 @@ function ClusterDetailCard({
header: 'Zustand', header: 'Zustand',
render: (machine) => render: (machine) =>
machine.missing_since ? ( machine.missing_since ? (
// Ein fehlender Gast wird vermerkt, nie gelöscht — eine gelöschte // Ein fehlender Gast wird vermerkt, nie geloescht — eine geloeschte
// Zeile nähme die Zuordnung zu vorhandenen Backups mit. // Zeile naehme die Zuordnung zu vorhandenen Backups mit.
<span className="text-xs text-warning"> <span className="text-xs text-warning">
Fehlt seit {formatRelativeTime(machine.missing_since)} Fehlt seit {formatRelativeTime(machine.missing_since)}
</span> </span>
@ -535,8 +542,8 @@ function ClusterDetailCard({
{machine.disk_count} {machine.disk_count}
{machine.excluded_disk_count > 0 ? ( {machine.excluded_disk_count > 0 ? (
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die // Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
// Wiederherstellung liefert sonst eine unvollständige Maschine, // Wiederherstellung liefert sonst eine unvollstaendige Maschine,
// die jemand für vollständig hält. // die jemand fuer vollstaendig haelt.
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
({machine.excluded_disk_count} ausgenommen) ({machine.excluded_disk_count} ausgenommen)
</span> </span>
@ -553,7 +560,7 @@ function ClusterDetailCard({
description={cluster.api_endpoint} description={cluster.api_endpoint}
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schließen Schliessen
</Button> </Button>
} }
/> />
@ -575,7 +582,7 @@ function ClusterDetailCard({
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4"> <div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
<Button size="sm" isLoading={isTesting} onClick={onTest}> <Button size="sm" isLoading={isTesting} onClick={onTest}>
<Network /> <Network />
Verbindung prüfen Verbindung pruefen
</Button> </Button>
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}> <Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
<Search /> <Search />
@ -594,7 +601,7 @@ function ClusterDetailCard({
<div className="mt-5 border-t border-line pt-4"> <div className="mt-5 border-t border-line pt-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle"> <p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
Gäste Gaeste
</p> </p>
<DataTable <DataTable
columns={machineColumns} columns={machineColumns}
@ -602,7 +609,7 @@ function ClusterDetailCard({
getRowKey={(machine) => machine.id} getRowKey={(machine) => machine.id}
isLoading={machinesResource.loadState === 'loading'} isLoading={machinesResource.loadState === 'loading'}
emptyTitle="Kein Gast erfasst" emptyTitle="Kein Gast erfasst"
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gäste dieses Verbunds zu sehen." emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gaeste dieses Verbunds zu sehen."
/> />
</div> </div>
</CardContent> </CardContent>

View File

@ -1,20 +1,20 @@
/** /**
* API-Anbindung von Virtualisierungsverbünden und Agenten. * API-Anbindung von Virtualisierungsverbuenden und Agenten.
* *
* Beide teilen eine Eigenschaft, die die Masken prägt: **Geheimnisse gehen nur * Beide teilen eine Eigenschaft, die die Masken praegt: **Geheimnisse gehen nur
* hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das * hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das
* Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen — * 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, * die API gibt sie nicht zurueck, und das ist kein Mangel, sondern der Grund,
* warum ein Lesezugriff auf die Konfiguration ungefährlich ist. * warum ein Lesezugriff auf die Konfiguration ungefaehrlich ist.
* *
* Die Oberfläche muss das sagen, sonst schließt jemand das Fenster mit dem * Die Oberflaeche muss das sagen, sonst schliesst jemand das Fenster mit dem
* einmalig angezeigten Aufnahme-Token. * einmalig angezeigten Aufnahme-Token.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Proxmox-Verbünde // Proxmox-Verbuende
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Zugriffsweg auf die Sicherungsarchive. */ /** Zugriffsweg auf die Sicherungsarchive. */
@ -44,7 +44,7 @@ export interface ClusterInput {
name: string; name: string;
api_endpoint: string; api_endpoint: string;
api_token_id: string; api_token_id: string;
/** Wird verschlüsselt abgelegt und nie wieder ausgeliefert. */ /** Wird verschluesselt abgelegt und nie wieder ausgeliefert. */
api_token_secret: string; api_token_secret: string;
tls_fingerprint?: string; tls_fingerprint?: string;
backup_storage_id: string; backup_storage_id: string;
@ -53,7 +53,7 @@ export interface ClusterInput {
ssh_username?: string; ssh_username?: string;
ssh_port?: number; ssh_port?: number;
ssh_private_key_pem?: string; ssh_private_key_pem?: string;
/** Ohne hinterlegten Wirtsschlüssel kommt keine SSH-Verbindung zustande. */ /** Ohne hinterlegten Wirtsschluessel kommt keine SSH-Verbindung zustande. */
ssh_host_fingerprints?: Record<string, string>; ssh_host_fingerprints?: Record<string, string>;
keep_archive_on_node?: boolean; keep_archive_on_node?: boolean;
} }
@ -83,23 +83,23 @@ export interface VirtualMachine {
/** /**
* Platten mit `backup=0`. * Platten mit `backup=0`.
* *
* Größer als null macht einen Sicherungslauf zum Teilfehler — nicht weil * Groesser als null macht einen Sicherungslauf zum Teilfehler — nicht weil
* etwas schiefging, sondern weil die Wiederherstellung sonst eine * etwas schiefging, sondern weil die Wiederherstellung sonst eine
* unvollständige Maschine liefert, die jemand für vollständig hält. * unvollstaendige Maschine liefert, die jemand fuer vollstaendig haelt.
*/ */
excluded_disk_count: number; excluded_disk_count: number;
/** /**
* Zeitpunkt, seit dem der Gast im Bestand fehlt. * Zeitpunkt, seit dem der Gast im Bestand fehlt.
* *
* Ein fehlender Gast wird nie gelöscht: Er könnte abgeschaltet oder * Ein fehlender Gast wird nie geloescht: Er koennte abgeschaltet oder
* verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu * verschoben sein, und eine geloeschte Zeile naehme die Zuordnung zu
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine * vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
* weg ist. * weg ist.
*/ */
missing_since?: string; missing_since?: string;
} }
/** Lädt die Verbünde. */ /** Laedt die Verbuende. */
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> { export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
return requestApi<Cluster[]>( return requestApi<Cluster[]>(
'/proxmox/clusters', '/proxmox/clusters',
@ -116,7 +116,7 @@ export async function createCluster(clusterInput: ClusterInput): Promise<Cluster
}); });
} }
/** Löscht einen Verbund. */ /** Loescht einen Verbund. */
export async function deleteCluster(clusterIdentifier: string): Promise<void> { export async function deleteCluster(clusterIdentifier: string): Promise<void> {
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, { return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
method: 'DELETE', method: 'DELETE',
@ -125,10 +125,10 @@ export async function deleteCluster(clusterIdentifier: string): Promise<void> {
} }
/** /**
* Prüft die Verbindung. * Prueft die Verbindung.
* *
* Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist * Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist
* von `unreachable` getrennt, weil die Abhilfe eine völlig andere ist. * von `unreachable` getrennt, weil die Abhilfe eine voellig andere ist.
*/ */
export async function testCluster( export async function testCluster(
clusterIdentifier: string, clusterIdentifier: string,
@ -148,7 +148,7 @@ export async function discoverCluster(
}); });
} }
/** Lädt die Knoten eines Verbunds. */ /** Laedt die Knoten eines Verbunds. */
export async function listClusterHosts( export async function listClusterHosts(
clusterIdentifier: string, clusterIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -159,7 +159,7 @@ export async function listClusterHosts(
); );
} }
/** Lädt die Gäste eines Verbunds. */ /** Laedt die Gaeste eines Verbunds. */
export async function listClusterMachines( export async function listClusterMachines(
clusterIdentifier: string, clusterIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -170,7 +170,7 @@ export async function listClusterMachines(
); );
} }
/** Lädt alle bekannten Gäste, verbundsübergreifend. */ /** Laedt alle bekannten Gaeste, verbundsuebergreifend. */
export async function listVirtualMachines( export async function listVirtualMachines(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<VirtualMachine[]> { ): Promise<VirtualMachine[]> {
@ -197,27 +197,20 @@ export interface Agent {
enrolled_at?: string; enrolled_at?: string;
} }
/** /** Antwort auf die Erzeugung eines Aufnahme-Tokens. */
* 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 { export interface EnrollmentToken {
id: string;
/** /**
* Das Token im Klartext — **einmalig**. * Das Token im Klartext — **einmalig**.
* *
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. * Es wird nur als Hash gespeichert und laesst sich nie wieder abrufen. Die
* Oberflaeche muss das sagen, sonst schliesst jemand das Fenster.
*/ */
token: string; enrollment_token: string;
agent_name: string; agent_name: string;
expires_at?: string; expires_at?: string;
} }
/** Lädt die Agenten. */ /** Laedt die Agenten. */
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> { export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {}); return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
} }
@ -225,7 +218,7 @@ export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
/** /**
* Erzeugt ein Aufnahme-Token. * Erzeugt ein Aufnahme-Token.
* *
* Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst könnte er * Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst koennte er
* sich als ein anderes System ausgeben. * sich als ein anderes System ausgeben.
*/ */
export async function createEnrollmentToken( export async function createEnrollmentToken(
@ -257,7 +250,7 @@ export async function rotateAgentCredentials(
); );
} }
/** Lädt den Gesundheitszustand eines Agenten. */ /** Laedt den Gesundheitszustand eines Agenten. */
export async function getAgentHealth( export async function getAgentHealth(
agentIdentifier: string, agentIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,

View File

@ -1,8 +1,8 @@
/** /**
* Tests des Backup-Assistenten. * Tests des Backup-Assistenten.
* *
* Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte * Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte
* aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine * aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden. * Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
*/ */
@ -40,15 +40,15 @@ beforeEach(() => {
vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]); vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]);
}); });
/** Führt den Assistenten bis zu einem Schritt und füllt das Nötige aus. */ /** Fuehrt den Assistenten bis zu einem Schritt und fuellt das Noetige aus. */
async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> { async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> {
await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
await user.type(screen.getByLabelText('Pfad'), '/daten'); await user.type(screen.getByLabelText('Pfad'), '/daten');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Zeitplan: der Standard "täglich 02:00" genügt. // Zeitplan: der Standard "taeglich 02:00" genuegt.
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
} }
@ -57,7 +57,7 @@ describe('Backup-Assistent', () => {
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
// Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand // Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand
// erst nach dem Test, und React meldet eine Aktualisierung außerhalb von act(). // erst nach dem Test, und React meldet eine Aktualisierung ausserhalb von act().
await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled()); await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled());
for (const stepTitle of [ for (const stepTitle of [
@ -67,9 +67,9 @@ describe('Backup-Assistent', () => {
'Repository', 'Repository',
'Aufbewahrung', 'Aufbewahrung',
'Sicherheit', 'Sicherheit',
'Prüfung', 'Pruefung',
'Benachrichtigung', 'Benachrichtigung',
'Übersicht', 'Uebersicht',
'Anlegen', 'Anlegen',
]) { ]) {
expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument(); expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument();
@ -77,8 +77,8 @@ describe('Backup-Assistent', () => {
}); });
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an // Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an
// zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren. // zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
it('hält bei einem leeren Namen auf und erklärt warum', async () => { it('haelt bei einem leeren Namen auf und erklaert warum', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -90,7 +90,7 @@ describe('Backup-Assistent', () => {
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument(); expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
}); });
it('hält ohne Quelle auf', async () => { it('haelt ohne Quelle auf', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -104,17 +104,17 @@ describe('Backup-Assistent', () => {
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle'); expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
}); });
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst waere
// jeder Blick zurück ein Datenverlust. // jeder Blick zurueck ein Datenverlust.
it('bewahrt die Eingaben beim Zurückgehen', async () => { it('bewahrt die Eingaben beim Zurueckgehen', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
await user.click(screen.getByRole('button', { name: 'Zurück' })); await user.click(screen.getByRole('button', { name: 'Zurueck' }));
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Nächtliche Sicherung'); expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Naechtliche Sicherung');
}); });
it('zeigt eine Vorschau des Zeitplans', async () => { it('zeigt eine Vorschau des Zeitplans', async () => {
@ -126,12 +126,12 @@ describe('Backup-Assistent', () => {
await user.type(screen.getByLabelText('Pfad'), '/daten'); await user.type(screen.getByLabelText('Pfad'), '/daten');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
expect(screen.getByText(/Ergibt: täglich um 02:00 Uhr/)).toBeInTheDocument(); expect(screen.getByText(/Ergibt: taeglich um 02:00 Uhr/)).toBeInTheDocument();
}); });
// Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend // Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend
// weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet. // weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet.
it('zeigt gesperrte Repositories, lässt sie aber nicht wählen', async () => { it('zeigt gesperrte Repositories, laesst sie aber nicht waehlen', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -148,7 +148,7 @@ describe('Backup-Assistent', () => {
}); });
// Eine Maske, die Werte sammelt, die niemand auswertet, ist ein // Eine Maske, die Werte sammelt, die niemand auswertet, ist ein
// vorgetäuschtes Funktionsversprechen (PROMPT.md §138). // vorgetaeuschtes Funktionsversprechen (PROMPT.md §138).
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => { it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -160,15 +160,15 @@ describe('Backup-Assistent', () => {
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Schritt 5: Aufbewahrung. // Schritt 5: Aufbewahrung.
expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument(); expect(screen.getByText('Noch nicht verfuegbar')).toBeInTheDocument();
expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument(); expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
}); });
it('legt den Auftrag an und meldet ihn nach oben', async () => { it('legt den Auftrag an und meldet ihn nach oben', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const createdJob = { id: 'abc', name: 'Nächtliche Sicherung' } as jobsApi.BackupJob; const createdJob = { id: 'abc', name: 'Naechtliche Sicherung' } as jobsApi.BackupJob;
const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob); const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob);
const handleCreated = vi.fn(); const handleCreated = vi.fn();
@ -190,20 +190,20 @@ describe('Backup-Assistent', () => {
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob)); await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
const sentRequest = createSpy.mock.calls[0]?.[0]; const sentRequest = createSpy.mock.calls[0]?.[0];
expect(sentRequest?.name).toBe('Nächtliche Sicherung'); expect(sentRequest?.name).toBe('Naechtliche Sicherung');
expect(sentRequest?.sources[0]?.id).toBe('/daten'); expect(sentRequest?.sources[0]?.id).toBe('/daten');
expect(sentRequest?.repository_id).toBe(writableRepository.id); expect(sentRequest?.repository_id).toBe(writableRepository.id);
}); });
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den // Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
// eigentlichen Grund, den die Oberfläche nicht kennen kann. // eigentlichen Grund, den die Oberflaeche nicht kennen kann.
it('zeigt eine Fehlermeldung des Servers', async () => { it('zeigt eine Fehlermeldung des Servers', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.spyOn(jobsApi, 'createJob').mockRejectedValue( vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
new ApiError({ new ApiError({
code: 'VALIDATION_FAILED', code: 'VALIDATION_FAILED',
message: 'der zeitplan läuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten', message: 'der zeitplan laeuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten',
statusCode: 422, statusCode: 422,
requestId: 'req-1', requestId: 'req-1',
}), }),
@ -225,8 +225,8 @@ describe('Backup-Assistent', () => {
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'), expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
); );
// Der Assistent bleibt stehen, damit der Anwender zurückgehen und den // Der Assistent bleibt stehen, damit der Anwender zurueckgehen und den
// Zeitplan ändern kann. // Zeitplan aendern kann.
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument(); expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
}); });
@ -251,15 +251,15 @@ describe('Backup-Assistent', () => {
); );
}); });
it('erlaubt den Sprung zurück über die Schrittleiste, nicht nach vorn', async () => { it('erlaubt den Sprung zurueck ueber die Schrittleiste, nicht nach vorn', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine // Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
// Prüfung überspringen, die der Assistent gerade führen soll. // Pruefung ueberspringen, die der Assistent gerade fuehren soll.
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled(); expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /1\s*Name/ })); await user.click(screen.getByRole('button', { name: /1\s*Name/ }));

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 führt von Name bis Anlegen. Die Prüfung geschieht je Schritt, * Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt,
* damit ein Fehler dort auffällt, wo er entsteht - nicht erst nach dem letzten * damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten
* Schritt. Rückwärts geht es ohne Datenverlust: Der Entwurf lebt in einem * Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem
* Zustand, nicht in den Eingabefeldern. * Zustand, nicht in den Eingabefeldern.
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi'; import { createJob, listRepositories } from './jobsApi';
import type { BackupJob, BackupRepository, SourceType } from './jobsApi'; import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
import { import {
buildCreateRequest, buildCreateRequest,
@ -58,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 Schließen des Assistenten noch Zustand setzt. // Antwort nach dem Schliessen des Assistenten noch Zustand setzt.
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@ -75,17 +75,17 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return () => abortController.abort(); return () => abortController.abort();
}, []); }, []);
/** Ändert ein Feld des Entwurfs. */ /** Aendert ein Feld des Entwurfs. */
function updateDraft(changedFields: Partial<JobDraft>): void { function updateDraft(changedFields: Partial<JobDraft>): void {
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields })); setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
setShowProblems(false); setShowProblems(false);
} }
/** Geht einen Schritt vor, sofern der aktuelle vollständig ist. */ /** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */
function goToNextStep(): void { function goToNextStep(): void {
if (!stepValidation.isComplete) { if (!stepValidation.isComplete) {
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang // Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang
// an zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren. // an zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
setShowProblems(true); setShowProblems(true);
return; return;
@ -95,7 +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 zurück. */ /** Geht einen Schritt zurueck. */
function goToPreviousStep(): void { function goToPreviousStep(): void {
setShowProblems(false); setShowProblems(false);
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0)); setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
@ -171,7 +171,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<div className="flex gap-2"> <div className="flex gap-2">
{currentStepIndex > 0 && ( {currentStepIndex > 0 && (
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={goToPreviousStep}> <button type="button" className="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}>
Zurück Zurueck
</button> </button>
)} )}
@ -195,7 +195,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</section> </section>
); );
/** Wählt den Inhalt des aktuellen Schrittes. */ /** Waehlt den Inhalt des aktuellen Schrittes. */
function renderStepContent(): React.JSX.Element { function renderStepContent(): React.JSX.Element {
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep]; const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
@ -227,14 +227,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
* Zeigt einen Schritt ohne Umsetzung im Backend. * Zeigt einen Schritt ohne Umsetzung im Backend.
* *
* Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die * Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die
* niemand auswertet, ist ein vorgetäuschtes Funktionsversprechen * niemand auswertet, ist ein vorgetaeuschtes Funktionsversprechen
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung * (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
* tatsächlich geschieht. * tatsaechlich geschieht.
*/ */
function renderUnavailableStep(explanationText: string): React.JSX.Element { function renderUnavailableStep(explanationText: string): React.JSX.Element {
return ( return (
<div className="rounded-md border border-line bg-sunken p-4"> <div className="rounded-md border border-line bg-sunken p-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfügbar</p> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfuegbar</p>
<p className="mt-1 text-sm text-fg-muted">{explanationText}</p> <p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
</div> </div>
); );
@ -251,7 +251,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
type="text" type="text"
value={jobDraft.name} value={jobDraft.name}
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
placeholder="Nächtliche Sicherung Dateiserver" placeholder="Naechtliche Sicherung Dateiserver"
autoFocus autoFocus
/> />
</label> </label>
@ -271,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 {
/** Ändert eine einzelne Quelle. */ /** Aendert eine einzelne Quelle. */
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void { function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
updateDraft({ updateDraft({
sources: jobDraft.sources.map((existingSource, currentIndex) => sources: jobDraft.sources.map((existingSource, currentIndex) =>
@ -302,7 +302,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)} disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
> >
{SOURCE_TYPE_LABELS[sourceType]} {SOURCE_TYPE_LABELS[sourceType]}
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfügbar)' : ''} {!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''}
</option> </option>
))} ))}
</select> </select>
@ -332,7 +332,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label> </label>
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Auszuschließen (kommasepariert, optional)</span> <span className="block text-sm font-medium text-fg">Auszuschliessen (kommasepariert, optional)</span>
<input <input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle" className="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"
@ -392,10 +392,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
} }
> >
<option value="manual">Nur auf Anforderung</option> <option value="manual">Nur auf Anforderung</option>
<option value="interval">In festen Abständen</option> <option value="interval">In festen Abstaenden</option>
<option value="hourly">Stündlich</option> <option value="hourly">Stuendlich</option>
<option value="daily">Täglich</option> <option value="daily">Taeglich</option>
<option value="weekly">Wöchentlich</option> <option value="weekly">Woechentlich</option>
<option value="monthly">Monatlich</option> <option value="monthly">Monatlich</option>
<option value="cron">Cron-Ausdruck</option> <option value="cron">Cron-Ausdruck</option>
</select> </select>
@ -476,10 +476,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label> </label>
))} ))}
</div> </div>
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." wählt, bekommt in {/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in
vier Monaten des Jahres keine Sicherung. */} vier Monaten des Jahres keine Sicherung. */}
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
Der letzte Tag des Monats fällt nie aus - anders als etwa der 31. Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
</p> </p>
</fieldset> </fieldset>
)} )}
@ -516,71 +516,6 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)} )}
<p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">Ergibt: {describeDraftSchedule(jobDraft)}</p> <p className="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>
); );
} }
@ -594,7 +529,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
if (repositories.length === 0) { if (repositories.length === 0) {
return ( return (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datenträger und Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und
wird mit <code>syncova-repo create --path &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>
); );
@ -619,7 +554,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt {/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
stillschweigend weggelassen: Sonst suchte der Anwender ein stillschweigend weggelassen: Sonst suchte der Anwender ein
Repository, das er sieht und nicht wählen kann. */} Repository, das er sieht und nicht waehlen kann. */}
{!repositoryEntry.accepts_backups && ( {!repositoryEntry.accepts_backups && (
<span className="text-xs text-warning"> <span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
@ -636,12 +571,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
function renderSecurityStep(): React.JSX.Element { function renderSecurityStep(): React.JSX.Element {
return ( return (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{/* Verschlüsselung ist keine Wahl: Der Executor verweigert den Dienst {/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schlüsselmaterial. Eine Schaltfläche zum Abschalten wäre ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
eine Einstellung, die es nicht gibt. */} eine Einstellung, die es nicht gibt. */}
<div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm"> <div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm">
<strong>Verschlüsselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM <strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschlüsselt, bevor sie das System verlassen. Der Schlüssel gehört zum Repository und verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
wird vom Dienst verwaltet. wird vom Dienst verwaltet.
</div> </div>
@ -660,7 +595,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<option value="low">Niedrig</option> <option value="low">Niedrig</option>
</select> </select>
<span className="text-xs text-fg-muted"> <span className="text-xs text-fg-muted">
Entscheidet die Reihenfolge, wenn mehrere Aufträge gleichzeitig fällig sind. Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
</span> </span>
</label> </label>
@ -681,7 +616,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
); );
} }
/** Schritt 9: Übersicht. */ /** Schritt 9: Uebersicht. */
function renderReviewStep(): React.JSX.Element { function renderReviewStep(): React.JSX.Element {
const selectedRepository = repositories.find( const selectedRepository = repositories.find(
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId, (repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
@ -729,12 +664,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dt>Sicherheit</dt> <dt>Sicherheit</dt>
<dd> <dd>
verschlüsselt, Dringlichkeit {jobDraft.priority} verschluesselt, Dringlichkeit {jobDraft.priority}
{jobDraft.bandwidthLimit.trim() !== '' && `, höchstens ${jobDraft.bandwidthLimit}`} {jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`}
</dd> </dd>
<dt>Prüfung</dt> <dt>Pruefung</dt>
<dd className="text-sm text-fg-muted">keine automatische Prüfung</dd> <dd className="text-sm text-fg-muted">keine automatische Pruefung</dd>
<dt>Benachrichtigung</dt> <dt>Benachrichtigung</dt>
<dd className="text-sm text-fg-muted">keine</dd> <dd className="text-sm text-fg-muted">keine</dd>
@ -747,8 +682,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return ( return (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<p> <p>
Der Auftrag wird angelegt und läuft ab sofort nach Zeitplan. Der erste Lauf ist eine Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollständige Sicherung; jeder weitere liest nur, was sich geändert hat. vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
</p> </p>
{submitError !== null && ( {submitError !== null && (
@ -764,8 +699,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** /**
* Bildet die Darstellung 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 — * Ein noch nicht erreichter Schritt bleibt gedaempft und ist nicht anklickbar —
* er übersprang eine Prüfung. * er uebersprang eine Pruefung.
*/ */
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'; const baseClasses = 'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs';
@ -781,7 +716,7 @@ function buildStepClassName(stepIndex: number, currentStepIndex: number): string
return `${baseClasses} text-fg-subtle`; return `${baseClasses} text-fg-subtle`;
} }
/** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */ /** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
function describeError(caughtError: unknown, fallbackMessage: string): string { function describeError(caughtError: unknown, fallbackMessage: string): string {
if (caughtError instanceof ApiError) { if (caughtError instanceof ApiError) {
return caughtError.message; return caughtError.message;

View File

@ -4,12 +4,12 @@
* Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum** * Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum**
* etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht * etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht
* nur "fehlgeschlagen", sondern Fehlercode, Fehlerklasse und Meldung — und die * nur "fehlgeschlagen", sondern Fehlercode, Fehlerklasse und Meldung — und die
* Klasse sagt zugleich, ob eine Wiederholung überhaupt etwas bringt: Ein * Klasse sagt zugleich, ob eine Wiederholung ueberhaupt etwas bringt: Ein
* Anmeldefehler behebt sich nicht durch Warten. * Anmeldefehler behebt sich nicht durch Warten.
* *
* Ein Teilfehler bekommt eine eigene Erläuterung. "Erfolgreich mit * Ein Teilfehler bekommt eine eigene Erlaeuterung. "Erfolgreich mit
* übergangenen Objekten" gibt es nicht — die Datenbank lässt es per CHECK * uebergangenen Objekten" gibt es nicht — die Datenbank laesst es per CHECK
* nicht zu, und die Oberfläche darf es erst recht nicht suggerieren. * nicht zu, und die Oberflaeche darf es erst recht nicht suggerieren.
*/ */
import { Ban, Pause, Play, RefreshCw } from 'lucide-react'; import { Ban, Pause, Play, RefreshCw } from 'lucide-react';
@ -45,7 +45,6 @@ import {
pauseJob, pauseJob,
resumeJob, resumeJob,
runJob, runJob,
WEEKDAY_LABELS,
type BackupJobRun, type BackupJobRun,
} from './jobsApi'; } from './jobsApi';
@ -85,8 +84,8 @@ export function JobDetailPage({
}, },
onError: (apiError) => onError: (apiError) =>
apiError.statusCode === 409 apiError.statusCode === 409
? toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError)) ? toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError))
: toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId), : toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId),
}); });
const pauseMutation = useMutation(pauseJob, { const pauseMutation = useMutation(pauseJob, {
@ -109,11 +108,11 @@ export function JobDetailPage({
const cancelRunMutation = useMutation(cancelJobRun, { const cancelRunMutation = useMutation(cancelJobRun, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Abbruch angefordert', 'Der Lauf endet, sobald die laufende Stufe abschließt.'); toast.showSuccess('Abbruch angefordert', 'Der Lauf endet, sobald die laufende Stufe abschliesst.');
reloadEverything(); reloadEverything();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Lauf ließ sich nicht abbrechen', describeApiError(apiError), apiError.requestId), toast.showError('Der Lauf liess sich nicht abbrechen', describeApiError(apiError), apiError.requestId),
}); });
if (jobResource.loadState === 'loading') { if (jobResource.loadState === 'loading') {
@ -123,7 +122,7 @@ export function JobDetailPage({
if (jobResource.loadState === 'failed' || !jobResource.data) { if (jobResource.loadState === 'failed' || !jobResource.data) {
return ( return (
<> <>
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Aufträgen" /> <PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Auftraegen" />
<Card> <Card>
<ErrorState <ErrorState
message={ message={
@ -187,10 +186,10 @@ export function JobDetailPage({
<span> <span>
{run.files_processed?.toLocaleString('de-DE') ?? '—'} {run.files_processed?.toLocaleString('de-DE') ?? '—'}
{run.files_skipped ? ( {run.files_skipped ? (
// Übergangene Objekte sind der Grund für den Teilfehler und // Uebergangene Objekte sind der Grund fuer den Teilfehler und
// gehören deshalb in dieselbe Zelle, nicht in eine Fußnote. // gehoeren deshalb in dieselbe Zelle, nicht in eine Fussnote.
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
({run.files_skipped.toLocaleString('de-DE')} übergangen) ({run.files_skipped.toLocaleString('de-DE')} uebergangen)
</span> </span>
) : null} ) : null}
</span> </span>
@ -223,7 +222,7 @@ export function JobDetailPage({
title={job.name} title={job.name}
description={job.description || job.schedule_description} description={job.description || job.schedule_description}
onBack={onBack} onBack={onBack}
backLabel="Zu den Aufträgen" backLabel="Zu den Auftraegen"
actions={ actions={
mayRunJobs ? ( mayRunJobs ? (
<> <>
@ -253,7 +252,7 @@ export function JobDetailPage({
{job.status === 'paused' ? ( {job.status === 'paused' ? (
<Callout tone="warning" title="Dieser Auftrag ist angehalten" className="mb-4"> <Callout tone="warning" title="Dieser Auftrag ist angehalten" className="mb-4">
Geplante Läufe entfallen, bis er fortgesetzt wird. Bereits laufende Geplante Laeufe entfallen, bis er fortgesetzt wird. Bereits laufende
Sicherungen sind davon nicht betroffen. Sicherungen sind davon nicht betroffen.
</Callout> </Callout>
) : null} ) : null}
@ -268,7 +267,7 @@ export function JobDetailPage({
</DetailItem> </DetailItem>
<DetailItem label="Dringlichkeit">{job.priority}</DetailItem> <DetailItem label="Dringlichkeit">{job.priority}</DetailItem>
<DetailItem label="Zeitplan">{job.schedule_description}</DetailItem> <DetailItem label="Zeitplan">{job.schedule_description}</DetailItem>
<DetailItem label="Nächster Lauf"> <DetailItem label="Naechster Lauf">
{job.next_run_at ? formatDateTime(job.next_run_at) : 'Nur manuell'} {job.next_run_at ? formatDateTime(job.next_run_at) : 'Nur manuell'}
</DetailItem> </DetailItem>
<DetailItem label="Bandbreitengrenze"> <DetailItem label="Bandbreitengrenze">
@ -277,15 +276,6 @@ export function JobDetailPage({
: 'Ohne Grenze'} : 'Ohne Grenze'}
</DetailItem> </DetailItem>
<DetailItem label="Quellen">{job.sources.length}</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> </dl>
<div className="mt-5 border-t border-line pt-4"> <div className="mt-5 border-t border-line pt-4">
@ -327,7 +317,7 @@ export function JobDetailPage({
<Card> <Card>
<CardHeader <CardHeader
title="Laufhistorie" title="Laufhistorie"
description="Die letzten 50 Läufe." description="Die letzten 50 Laeufe."
action={ action={
<Button variant="ghost" size="sm" onClick={runsResource.reload}> <Button variant="ghost" size="sm" onClick={runsResource.reload}>
<RefreshCw /> <RefreshCw />
@ -342,7 +332,7 @@ export function JobDetailPage({
onRowClick={(run) => setSelectedRun(run)} onRowClick={(run) => setSelectedRun(run)}
isLoading={runsResource.loadState === 'loading'} isLoading={runsResource.loadState === 'loading'}
emptyTitle="Noch kein Lauf" emptyTitle="Noch kein Lauf"
emptyDescription="Stoßen Sie den ersten Lauf über die Schaltfläche oben an." emptyDescription="Stossen Sie den ersten Lauf ueber die Schaltflaeche oben an."
/> />
</Card> </Card>
@ -380,9 +370,9 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
{run.status === 'partial_failure' ? ( {run.status === 'partial_failure' ? (
<Callout tone="warning" title="Teilfehler"> <Callout tone="warning" title="Teilfehler">
{run.files_skipped ?? 0} Objekte wurden übergangen. Ein Teilfehler {run.files_skipped ?? 0} Objekte wurden uebergangen. Ein Teilfehler
gilt <strong>nicht</strong> als Erfolg und wird bewusst nicht gilt <strong>nicht</strong> als Erfolg und wird bewusst nicht
wiederholt — die übergangenen Objekte wären beim nächsten Versuch wiederholt — die uebergangenen Objekte waeren beim naechsten Versuch
dieselben. dieselben.
</Callout> </Callout>
) : null} ) : null}
@ -399,7 +389,7 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
); );
} }
/** Einzelheiten eines ausgewählten Laufs. */ /** Einzelheiten eines ausgewaehlten Laufs. */
function RunDetailCard({ function RunDetailCard({
run, run,
onClose, onClose,
@ -414,7 +404,7 @@ function RunDetailCard({
description={formatDateTime(run.started_at)} description={formatDateTime(run.started_at)}
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schließen Schliessen
</Button> </Button>
} }
/> />
@ -434,7 +424,7 @@ function RunDetailCard({
<DetailItem label="Objekte erfasst"> <DetailItem label="Objekte erfasst">
{run.files_processed?.toLocaleString('de-DE') ?? '—'} {run.files_processed?.toLocaleString('de-DE') ?? '—'}
</DetailItem> </DetailItem>
<DetailItem label="Objekte übergangen"> <DetailItem label="Objekte uebergangen">
{run.files_skipped?.toLocaleString('de-DE') ?? '0'} {run.files_skipped?.toLocaleString('de-DE') ?? '0'}
</DetailItem> </DetailItem>
<DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem> <DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem>

View File

@ -1,19 +1,19 @@
/** /**
* Sicherungsaufträge — Liste und Bedienung. * Sicherungsauftraege — Liste und Bedienung.
* *
* Bis hierher zeigte die Oberfläche Aufträge an, die sich nicht bedienen * Bis hierher zeigte die Oberflaeche Auftraege an, die sich nicht bedienen
* ließen. Ein Betreiber, der einen Lauf außerhalb des Zeitplans anstoßen * liessen. Ein Betreiber, der einen Lauf ausserhalb des Zeitplans anstossen
* wollte, brauchte dafür die Kommandozeile. * wollte, brauchte dafuer die Kommandozeile.
* *
* Zwei Entscheidungen tragen diese Seite: * Zwei Entscheidungen tragen diese Seite:
* *
* - **Ein zweiter Anstoß ist kein Fehler, sondern eine Auskunft.** Der Server * - **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der Server
* antwortet mit 409, wenn der Auftrag bereits läuft. Das als Fehler zu * antwortet mit 409, wenn der Auftrag bereits laeuft. Das als Fehler zu
* melden wäre technisch richtig und praktisch irreführend — der Auftrag * melden waere technisch richtig und praktisch irrefuehrend — der Auftrag
* läuft ja, und genau das wollte der Betreiber. * laeuft ja, und genau das wollte der Betreiber.
* - **Löschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben * - **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
* bestehen; sie gehören zum Repository, nicht zum Auftrag. Wer das nicht * bestehen; sie gehoeren zum Repository, nicht zum Auftrag. Wer das nicht
* weiß, löscht einen Auftrag in der Annahme, damit Platz zu schaffen. * weiss, loescht einen Auftrag in der Annahme, damit Platz zu schaffen.
*/ */
import { import {
@ -47,7 +47,6 @@ import {
pauseJob, pauseJob,
resumeJob, resumeJob,
runJob, runJob,
WEEKDAY_LABELS,
type BackupJob, type BackupJob,
} from './jobsApi'; } from './jobsApi';
@ -77,30 +76,30 @@ export function JobsPage({
const runMutation = useMutation(runJob, { const runMutation = useMutation(runJob, {
onSuccess: () => { onSuccess: () => {
// 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das — // 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das —
// "Sicherung erfolgreich" wäre hier schlicht gelogen. // "Sicherung erfolgreich" waere hier schlicht gelogen.
toast.showSuccess('Lauf eingereiht', 'Der Fortschritt erscheint in der Laufhistorie.'); toast.showSuccess('Lauf eingereiht', 'Der Fortschritt erscheint in der Laufhistorie.');
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => { onError: (apiError) => {
if (apiError.statusCode === 409) { if (apiError.statusCode === 409) {
// Der Auftrag läuft bereits — das ist die Antwort auf die Frage, // Der Auftrag laeuft bereits — das ist die Antwort auf die Frage,
// nicht ein Fehler des Betreibers. // nicht ein Fehler des Betreibers.
toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError)); toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError));
return; return;
} }
toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId); toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId);
}, },
}); });
const pauseMutation = useMutation(pauseJob, { const pauseMutation = useMutation(pauseJob, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Auftrag angehalten', 'Geplante Läufe entfallen, bis er fortgesetzt wird.'); toast.showSuccess('Auftrag angehalten', 'Geplante Laeufe entfallen, bis er fortgesetzt wird.');
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Auftrag ließ sich nicht anhalten', describeApiError(apiError), apiError.requestId), toast.showError('Der Auftrag liess sich nicht anhalten', describeApiError(apiError), apiError.requestId),
}); });
const resumeMutation = useMutation(resumeJob, { const resumeMutation = useMutation(resumeJob, {
@ -109,20 +108,20 @@ export function JobsPage({
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Auftrag ließ sich nicht fortsetzen', describeApiError(apiError), apiError.requestId), toast.showError('Der Auftrag liess sich nicht fortsetzen', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteJob, { const deleteMutation = useMutation(deleteJob, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Auftrag gelöscht', 'Auftrag geloescht',
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.', 'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
); );
setJobPendingDeletion(null); setJobPendingDeletion(null);
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Auftrag ließ sich nicht löschen', describeApiError(apiError), apiError.requestId), toast.showError('Der Auftrag liess sich nicht loeschen', describeApiError(apiError), apiError.requestId),
}); });
const jobs = jobsResource.data ?? []; const jobs = jobsResource.data ?? [];
@ -134,14 +133,7 @@ export function JobsPage({
render: (job) => ( render: (job) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-fg">{job.name}</p> <p className="truncate font-medium text-fg">{job.name}</p>
<p className="truncate text-xs text-fg-muted"> <p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
{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> </div>
), ),
}, },
@ -162,14 +154,14 @@ export function JobsPage({
</span> </span>
</div> </div>
) : ( ) : (
// Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied sähe ein // Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied saehe ein
// nie gelaufener Auftrag aus wie ein funktionierender. // nie gelaufener Auftrag aus wie ein funktionierender.
<span className="text-xs text-fg-muted">Noch nie gelaufen</span> <span className="text-xs text-fg-muted">Noch nie gelaufen</span>
), ),
}, },
{ {
key: 'next_run_at', key: 'next_run_at',
header: 'Nächster Lauf', header: 'Naechster Lauf',
render: (job) => render: (job) =>
job.next_run_at ? ( job.next_run_at ? (
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span> <span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
@ -192,7 +184,7 @@ export function JobsPage({
render: (job) => ( render: (job) => (
<div <div
className="flex items-center justify-end gap-1" className="flex items-center justify-end gap-1"
// Ohne das öffnete jeder Klick auf eine Schaltfläche zusätzlich die // Ohne das oeffnete jeder Klick auf eine Schaltflaeche zusaetzlich die
// Detailseite der Zeile. // Detailseite der Zeile.
onClick={(clickEvent) => clickEvent.stopPropagation()} onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
@ -233,8 +225,8 @@ export function JobsPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Löschen" title="Loeschen"
aria-label={`Auftrag ${job.name} löschen`} aria-label={`Auftrag ${job.name} loeschen`}
onClick={() => setJobPendingDeletion(job)} onClick={() => setJobPendingDeletion(job)}
> >
<Trash2 /> <Trash2 />
@ -257,7 +249,7 @@ export function JobsPage({
if (jobsResource.loadState === 'failed' && jobsResource.loadError) { if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
return ( return (
<> <>
<PageHeader title="Sicherungsaufträge" /> <PageHeader title="Sicherungsauftraege" />
<Card> <Card>
<ErrorState <ErrorState
message={describeApiError(jobsResource.loadError)} message={describeApiError(jobsResource.loadError)}
@ -276,7 +268,7 @@ export function JobsPage({
return ( return (
<> <>
<PageHeader <PageHeader
title="Sicherungsaufträge" title="Sicherungsauftraege"
description="Was wann wohin gesichert wird." description="Was wann wohin gesichert wird."
actions={ actions={
mayWriteJobs ? ( mayWriteJobs ? (
@ -288,11 +280,13 @@ export function JobsPage({
} }
/> />
{/* Ohne beschreibbares Ziel ist kein Auftrag möglich. Das erst beim {/* Ohne beschreibbares Ziel ist kein Auftrag moeglich. Das erst beim
Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */} Absenden des Assistenten zu melden waere die unfreundlichste Stelle. */}
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? ( {repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4"> <Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories. Ein Auftrag braucht ein Ziel, das Sicherungen annimmt. Legen Sie
zuerst unter „Repositories" eines an oder pruefen Sie den Zustand der
vorhandenen.
</Callout> </Callout>
) : null} ) : null}
@ -304,7 +298,7 @@ export function JobsPage({
onRowClick={(job) => onOpenJob(job.id)} onRowClick={(job) => onOpenJob(job.id)}
isLoading={jobsResource.loadState === 'loading'} isLoading={jobsResource.loadState === 'loading'}
emptyTitle="Noch kein Sicherungsauftrag" emptyTitle="Noch kein Sicherungsauftrag"
emptyDescription="Ohne Auftrag wird nichts gesichert." emptyDescription="Ohne Auftrag wird nichts gesichert — auch nicht im Hintergrund."
emptyAction={ emptyAction={
mayWriteJobs ? ( mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}> <Button variant="primary" onClick={() => setIsWizardOpen(true)}>
@ -334,15 +328,15 @@ export function JobsPage({
setJobPendingDeletion(null); setJobPendingDeletion(null);
} }
}} }}
title="Auftrag löschen" title="Auftrag loeschen"
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`} description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
confirmLabel="Löschen" confirmLabel="Loeschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
confirmationWord={jobPendingDeletion?.name} confirmationWord={jobPendingDeletion?.name}
confirmationHint={ confirmationHint={
<> <>
Tippen Sie zur Bestätigung den Namen des Auftrags:{' '} Tippen Sie zur Bestaetigung den Namen des Auftrags:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{jobPendingDeletion?.name} {jobPendingDeletion?.name}
</code> </code>
@ -355,7 +349,9 @@ export function JobsPage({
}} }}
> >
<Callout tone="info"> <Callout tone="info">
Die Wiederherstellungspunkte bleiben bestehen — es wird kein Speicher frei. Die bereits erzeugten Wiederherstellungspunkte <strong>bleiben
bestehen</strong>. Sie gehoeren zum Repository, nicht zum Auftrag —
durch das Loeschen wird kein Speicher frei.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,10 +1,10 @@
/** /**
* API-Anbindung der Sicherungsaufträge (SYNCOVA_API.md §9). * API-Anbindung der Sicherungsauftraege (SYNCOVA_API.md §9).
* *
* Das Modul kennt nur den Vertrag nach außen. Die Gestalt der Anfrage folgt * Das Modul kennt nur den Vertrag nach aussen. Die Gestalt der Anfrage folgt
* dem Backend und nicht der Oberfläche: Der Wizard führt seinen eigenen * dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen
* Entwurf und übersetzt ihn erst beim Anlegen. Andernfalls müsste jede * Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede
* Änderung an der API sofort die Maske umbauen. * Aenderung an der API sofort die Maske umbauen.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -56,9 +56,9 @@ export interface SourceDescriptor {
id: string; id: string;
/** Sprechende Bezeichnung. */ /** Sprechende Bezeichnung. */
name?: string; name?: string;
/** Einzuschließende Muster. */ /** Einzuschliessende Muster. */
include_patterns?: string[]; include_patterns?: string[];
/** Auszuschließende Muster. */ /** Auszuschliessende Muster. */
exclude_patterns?: string[]; exclude_patterns?: string[];
} }
@ -66,7 +66,7 @@ export interface SourceDescriptor {
export interface CreateJobRequest { export interface CreateJobRequest {
/** Eindeutige Bezeichnung. */ /** Eindeutige Bezeichnung. */
name: string; name: string;
/** Erläuterung des Zwecks. */ /** Erlaeuterung des Zwecks. */
description?: string; description?: string;
/** Dringlichkeit. */ /** Dringlichkeit. */
priority?: JobPriority; priority?: JobPriority;
@ -76,48 +76,21 @@ export interface CreateJobRequest {
sources: SourceDescriptor[]; sources: SourceDescriptor[];
/** Ziel-Repository. */ /** Ziel-Repository. */
repository_id: string; repository_id: string;
/** Zulässiger Datenverlust in Sekunden. */ /** Zulaessiger Datenverlust in Sekunden. */
rpo_seconds?: number; rpo_seconds?: number;
/** Zulässige Wiederherstellungsdauer in Sekunden. */ /** Zulaessige Wiederherstellungsdauer in Sekunden. */
rto_seconds?: number; rto_seconds?: number;
/** Bandbreitengrenze in Byte je Sekunde. */ /** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number; bandwidth_limit_bps?: number;
/**
* Sicherungsart: `incremental` (Standard) oder `always_full`.
*
* Der Platzbedarf steigt bei `always_full` **nicht** nennenswert —
* unveränderte Blöcke werden dedupliziert. Was steigt, ist die Laufzeit.
*/
backup_mode?: BackupMode;
/**
* Wochentag einer erzwungenen Vollsicherung.
*
* 0 = Sonntag … 6 = Samstag. Gerechnet in der Zeitzone des Zeitplans.
*/
full_backup_weekday?: number;
} }
/** Sicherungsart eines Auftrags. */
export type BackupMode = 'incremental' | 'always_full';
/** Wochentage in der Zählung der API (0 = Sonntag). */
export const WEEKDAY_LABELS: readonly string[] = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
];
/** Auftrag in der Antwort der API. */ /** Auftrag in der Antwort der API. */
export interface BackupJob { export interface BackupJob {
/** Öffentlicher Bezeichner. */ /** Oeffentlicher Bezeichner. */
id: string; id: string;
/** Bezeichnung. */ /** Bezeichnung. */
name: string; name: string;
/** Erläuterung. */ /** Erlaeuterung. */
description?: string; description?: string;
/** Zustand. */ /** Zustand. */
status: string; status: string;
@ -125,13 +98,13 @@ export interface BackupJob {
priority: JobPriority; priority: JobPriority;
/** Zeitplan. */ /** Zeitplan. */
schedule: ScheduleDescriptor; schedule: ScheduleDescriptor;
/** Erklärung des Zeitplans in einem Satz. */ /** Erklaerung des Zeitplans in einem Satz. */
schedule_description: string; schedule_description: string;
/** Quellen. */ /** Quellen. */
sources: SourceDescriptor[]; sources: SourceDescriptor[];
/** Ziel-Repository. */ /** Ziel-Repository. */
repository_id: string; repository_id: string;
/** Nächster Zeitpunkt in UTC. */ /** Naechster Zeitpunkt in UTC. */
next_run_at?: string; next_run_at?: string;
/** Beginn des letzten Laufs in UTC. */ /** Beginn des letzten Laufs in UTC. */
last_run_at?: string; last_run_at?: string;
@ -139,15 +112,11 @@ export interface BackupJob {
last_outcome?: string; last_outcome?: string;
/** Bandbreitengrenze in Byte je Sekunde. */ /** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number; bandwidth_limit_bps?: number;
/** Sicherungsart. */
backup_mode?: BackupMode;
/** Wochentag einer erzwungenen Vollsicherung. */
full_backup_weekday?: number;
} }
/** Sicherungsziel in der Antwort der API. */ /** Sicherungsziel in der Antwort der API. */
export interface BackupRepository { export interface BackupRepository {
/** Öffentlicher Bezeichner. */ /** Oeffentlicher Bezeichner. */
id: string; id: string;
/** Sprechende Bezeichnung. */ /** Sprechende Bezeichnung. */
name: string; name: string;
@ -160,26 +129,26 @@ export interface BackupRepository {
/** /**
* Meldet, ob dieses Ziel Sicherungen annimmt. * Meldet, ob dieses Ziel Sicherungen annimmt.
* *
* Die Auskunft kommt vom Server. Die Oberfläche müsste sonst wissen, welche * Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche
* Zustände schreibend sind - eine Regel, die dort nicht hingehört. * Zustaende schreibend sind - eine Regel, die dort nicht hingehoert.
*/ */
accepts_backups: boolean; accepts_backups: boolean;
/** Meldet den gehärteten Modus. */ /** Meldet den gehaerteten Modus. */
hardened: boolean; hardened: boolean;
} }
/** /**
* Lädt die bekannten Sicherungsziele. * Laedt die bekannten Sicherungsziele.
* *
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei * Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
* exactOptionalPropertyTypes ist ein ausdrückliches undefined etwas anderes * exactOptionalPropertyTypes ist ein ausdrueckliches undefined etwas anderes
* als ein fehlendes Feld. * als ein fehlendes Feld.
*/ */
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> { export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {}); return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
} }
/** Lädt die vorhandenen Sicherungsaufträge. */ /** Laedt die vorhandenen Sicherungsauftraege. */
export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> { export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> {
return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {}); return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {});
} }
@ -190,18 +159,18 @@ export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bedienung der Aufträge // Bedienung der Auftraege
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// Bis hierher konnte die Oberfläche einen Auftrag anlegen und ansehen — mehr // Bis hierher konnte die Oberflaeche einen Auftrag anlegen und ansehen — mehr
// nicht. Eine Liste, deren Einträge sich nicht bedienen lassen, ist ein // nicht. Eine Liste, deren Eintraege sich nicht bedienen lassen, ist ein
// Bericht, keine Konsole. // Bericht, keine Konsole.
/** Ein einzelner Lauf eines Auftrags. */ /** Ein einzelner Lauf eines Auftrags. */
export interface BackupJobRun { export interface BackupJobRun {
/** Öffentlicher Bezeichner. */ /** Oeffentlicher Bezeichner. */
id: string; id: string;
/** Zugehöriger Auftrag. */ /** Zugehoeriger Auftrag. */
job_id: string; job_id: string;
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */ /** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
status: string; status: string;
@ -211,15 +180,15 @@ export interface BackupJobRun {
completed_at?: string; completed_at?: string;
/** Gelesene Bytes. */ /** Gelesene Bytes. */
bytes_processed?: number; bytes_processed?: number;
/** Tatsächlich abgelegte Bytes nach Deduplizierung. */ /** Tatsaechlich abgelegte Bytes nach Deduplizierung. */
bytes_written?: number; bytes_written?: number;
/** Anzahl erfasster Dateien. */ /** Anzahl erfasster Dateien. */
files_processed?: number; files_processed?: number;
/** /**
* Übergangene Objekte. * Uebergangene Objekte.
* *
* Größer als null bedeutet Teilfehler — die Datenbank lässt * Groesser als null bedeutet Teilfehler — die Datenbank laesst
* "erfolgreich mit übergangenen Objekten" per CHECK gar nicht zu. * "erfolgreich mit uebergangenen Objekten" per CHECK gar nicht zu.
*/ */
files_skipped?: number; files_skipped?: number;
/** Durchsatz in Byte je Sekunde. */ /** Durchsatz in Byte je Sekunde. */
@ -232,7 +201,7 @@ export interface BackupJobRun {
error_class?: string; error_class?: string;
} }
/** Lädt einen einzelnen Auftrag. */ /** Laedt einen einzelnen Auftrag. */
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> { export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
return requestApi<BackupJob>( return requestApi<BackupJob>(
`/jobs/${encodeURIComponent(jobIdentifier)}`, `/jobs/${encodeURIComponent(jobIdentifier)}`,
@ -240,7 +209,7 @@ export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal):
); );
} }
/** Lädt die Laufhistorie eines Auftrags. */ /** Laedt die Laufhistorie eines Auftrags. */
export async function listJobRuns( export async function listJobRuns(
jobIdentifier: string, jobIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -252,11 +221,11 @@ export async function listJobRuns(
} }
/** /**
* Stößt einen Lauf an. * Stoesst einen Lauf an.
* *
* Die Antwort ist 202, nicht 201: Der Lauf ist eingereiht, die Sicherung hat * Die Antwort ist 202, nicht 201: Der Lauf ist eingereiht, die Sicherung hat
* nicht begonnen. Ein zweiter Anstoß bei laufendem Auftrag ergibt 409 — das * nicht begonnen. Ein zweiter Anstoss bei laufendem Auftrag ergibt 409 — das
* ist eine Auskunft, kein Fehler, und wird in der Oberfläche als solche * ist eine Auskunft, kein Fehler, und wird in der Oberflaeche als solche
* gezeigt. * gezeigt.
*/ */
export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> { export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> {
@ -266,7 +235,7 @@ export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }
}); });
} }
/** Hält einen Auftrag an. Laufende Sicherungen bleiben unberührt. */ /** Haelt einen Auftrag an. Laufende Sicherungen bleiben unberuehrt. */
export async function pauseJob(jobIdentifier: string): Promise<BackupJob> { export async function pauseJob(jobIdentifier: string): Promise<BackupJob> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, { return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
method: 'POST', method: 'POST',
@ -281,11 +250,11 @@ export async function resumeJob(jobIdentifier: string): Promise<BackupJob> {
} }
/** /**
* Löscht einen Auftrag. * Loescht einen Auftrag.
* *
* Die bereits erzeugten Wiederherstellungspunkte bleiben bestehen — sie * Die bereits erzeugten Wiederherstellungspunkte bleiben bestehen — sie
* gehören zum Repository, nicht zum Auftrag. Das muss die Oberfläche sagen, * gehoeren zum Repository, nicht zum Auftrag. Das muss die Oberflaeche sagen,
* sonst löscht jemand einen Auftrag in der Annahme, damit Platz zu schaffen. * sonst loescht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
*/ */
export async function deleteJob(jobIdentifier: string): Promise<void> { export async function deleteJob(jobIdentifier: string): Promise<void> {
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, { return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {

View File

@ -1,7 +1,7 @@
/** /**
* Tests des Wizard-Modells. * Tests des Wizard-Modells.
* *
* Geprüft wird die Logik ohne gerenderte Maske: welcher Schritt vollständig * Geprueft wird die Logik ohne gerenderte Maske: welcher Schritt vollstaendig
* ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden. * ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden.
*/ */
@ -19,11 +19,11 @@ import {
} from './wizardModel'; } from './wizardModel';
import type { JobDraft } from './wizardModel'; import type { JobDraft } from './wizardModel';
/** Liefert einen vollständig ausgefüllten Entwurf. */ /** Liefert einen vollstaendig ausgefuellten Entwurf. */
function buildCompleteDraft(): JobDraft { function buildCompleteDraft(): JobDraft {
return { return {
...createEmptyDraft(), ...createEmptyDraft(),
name: 'Nächtliche Sicherung', name: 'Naechtliche Sicherung',
sources: [ sources: [
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' }, { sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
], ],
@ -43,8 +43,8 @@ describe('Schrittfolge', () => {
}); });
}); });
describe('Prüfung der Schritte', () => { describe('Pruefung der Schritte', () => {
it('nimmt einen vollständigen Entwurf an', () => { it('nimmt einen vollstaendigen Entwurf an', () => {
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull(); expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
}); });
@ -56,7 +56,7 @@ describe('Prüfung der Schritte', () => {
}); });
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern - // Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern -
// die gefährlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht. // die gefaehrlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
it('verlangt mindestens eine Quelle', () => { it('verlangt mindestens eine Quelle', () => {
const sourcelessDraft = { const sourcelessDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
@ -108,13 +108,13 @@ describe('Prüfung der Schritte', () => {
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true); expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true);
}); });
it('weist eine unmögliche Uhrzeit ab', () => { it('weist eine unmoegliche Uhrzeit ab', () => {
const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' }; const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' };
expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false); expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false);
}); });
it('verlangt fünf Felder in einem Cron-Ausdruck', () => { it('verlangt fuenf Felder in einem Cron-Ausdruck', () => {
const cronDraft: JobDraft = { const cronDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
scheduleType: 'cron', scheduleType: 'cron',
@ -128,9 +128,9 @@ describe('Prüfung der Schritte', () => {
expect(validateStep('schedule', cronDraft).isComplete).toBe(true); expect(validateStep('schedule', cronDraft).isComplete).toBe(true);
}); });
// Aufbewahrung, Prüfung und Benachrichtigung fragen nichts ab und dürfen // Aufbewahrung, Pruefung und Benachrichtigung fragen nichts ab und duerfen
// den Anwender deshalb auch nicht aufhalten. // den Anwender deshalb auch nicht aufhalten.
it('hält bei den nicht umgesetzten Schritten nicht auf', () => { it('haelt bei den nicht umgesetzten Schritten nicht auf', () => {
const emptyDraft = createEmptyDraft(); const emptyDraft = createEmptyDraft();
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) { for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
@ -142,7 +142,7 @@ describe('Prüfung der Schritte', () => {
describe('Bandbreitenangabe', () => { describe('Bandbreitenangabe', () => {
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das // 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
// Achtfache der beabsichtigten Rate. // Achtfache der beabsichtigten Rate.
it('hält Bit und Byte auseinander', () => { it('haelt Bit und Byte auseinander', () => {
const bitValue = parseBandwidthToBytesPerSecond('100Mbit'); const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
const byteValue = parseBandwidthToBytesPerSecond('100MB'); const byteValue = parseBandwidthToBytesPerSecond('100MB');
@ -151,7 +151,7 @@ describe('Bandbreitenangabe', () => {
expect(bitValue).toBeLessThan(byteValue); expect(bitValue).toBeLessThan(byteValue);
}); });
it('nimmt die gebräuchlichen Schreibweisen an', () => { it('nimmt die gebraeuchlichen Schreibweisen an', () => {
expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800); expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800);
expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800); expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800);
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864); expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
@ -170,10 +170,10 @@ describe('Bandbreitenangabe', () => {
}); });
describe('Anfrage an die API', () => { describe('Anfrage an die API', () => {
it('überträgt Name, Quelle, Zeitplan und Ziel', () => { it('uebertraegt Name, Quelle, Zeitplan und Ziel', () => {
const jobRequest = buildCreateRequest(buildCompleteDraft()); const jobRequest = buildCreateRequest(buildCompleteDraft());
expect(jobRequest.name).toBe('Nächtliche Sicherung'); expect(jobRequest.name).toBe('Naechtliche Sicherung');
expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111'); expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111');
expect(jobRequest.schedule.type).toBe('daily'); expect(jobRequest.schedule.type).toBe('daily');
expect(jobRequest.schedule.time).toBe('02:00'); expect(jobRequest.schedule.time).toBe('02:00');
@ -184,15 +184,15 @@ describe('Anfrage an die API', () => {
// Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je // Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je
// nach Standort zu einer anderen Uhrzeit. // nach Standort zu einer anderen Uhrzeit.
it('sendet die Zeitzone bei Zeitplänen mit Uhrzeit', () => { it('sendet die Zeitzone bei Zeitplaenen mit Uhrzeit', () => {
const jobRequest = buildCreateRequest(buildCompleteDraft()); const jobRequest = buildCreateRequest(buildCompleteDraft());
expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin'); expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin');
}); });
// Ein Intervallplan zählt Abstände, keine Uhrzeiten. Eine Zeitzone wäre // Ein Intervallplan zaehlt Abstaende, keine Uhrzeiten. Eine Zeitzone waere
// dort bedeutungslos. // dort bedeutungslos.
it('lässt die Zeitzone bei einem Intervallplan weg', () => { it('laesst die Zeitzone bei einem Intervallplan weg', () => {
const intervalDraft: JobDraft = { const intervalDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
scheduleType: 'interval', scheduleType: 'interval',
@ -205,9 +205,9 @@ describe('Anfrage an die API', () => {
expect(jobRequest.schedule.interval_seconds).toBe(21_600); expect(jobRequest.schedule.interval_seconds).toBe(21_600);
}); });
// Ein leeres Feld mitzusenden überschriebe auf dem Server einen sinnvollen // Ein leeres Feld mitzusenden ueberschriebe auf dem Server einen sinnvollen
// Standard mit einem Leerwert. // Standard mit einem Leerwert.
it('lässt leere Felder weg', () => { it('laesst leere Felder weg', () => {
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' }; const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
const jobRequest = buildCreateRequest(sparseDraft); const jobRequest = buildCreateRequest(sparseDraft);
@ -216,13 +216,13 @@ describe('Anfrage an die API', () => {
expect(jobRequest.bandwidth_limit_bps).toBeUndefined(); expect(jobRequest.bandwidth_limit_bps).toBeUndefined();
}); });
it('überträgt die Bandbreitengrenze in Byte je Sekunde', () => { it('uebertraegt die Bandbreitengrenze in Byte je Sekunde', () => {
const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' }; const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' };
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800); expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
}); });
it('übergeht leere Quellzeilen', () => { it('uebergeht leere Quellzeilen', () => {
const mixedDraft: JobDraft = { const mixedDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
sources: [ sources: [
@ -246,10 +246,10 @@ describe('Anfrage an die API', () => {
}); });
describe('Beschreibung des Zeitplans', () => { describe('Beschreibung des Zeitplans', () => {
it('beschreibt die Zeitpläne verständlich', () => { it('beschreibt die Zeitplaene verstaendlich', () => {
const dailyDraft = buildCompleteDraft(); const dailyDraft = buildCompleteDraft();
expect(describeDraftSchedule(dailyDraft)).toBe('täglich um 02:00 Uhr (Europe/Berlin)'); expect(describeDraftSchedule(dailyDraft)).toBe('taeglich um 02:00 Uhr (Europe/Berlin)');
expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung'); expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung');

View File

@ -1,9 +1,9 @@
/** /**
* Tests des Liniendiagramms. * Tests des Liniendiagramms.
* *
* Sie prüfen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben * Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
* wurde: **Eine Lücke wird nicht überbrückt und nicht auf null gezogen.** * wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.**
* Beides ergäbe eine Kurve, die etwas anderes behauptet als die Daten hergeben. * Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
*/ */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
@ -30,12 +30,12 @@ function pointWithoutValue(minuteOffset: number): DataPoint {
}; };
} }
/** Baut eine Reihe aus den übergebenen Punkten. */ /** Baut eine Reihe aus den uebergebenen Punkten. */
function seriesOf(points: readonly DataPoint[]): Series { function seriesOf(points: readonly DataPoint[]): Series {
return { name: 'test', label: 'Testreihe', unit: 'count', points }; return { name: 'test', label: 'Testreihe', unit: 'count', points };
} }
/** Liest alle gezeichneten Linienzüge aus dem Dokument. */ /** Liest alle gezeichneten Linienzuege aus dem Dokument. */
function readPathDefinitions(container: HTMLElement): string[] { function readPathDefinitions(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll('path')).map( return Array.from(container.querySelectorAll('path')).map(
(pathElement) => pathElement.getAttribute('d') ?? '', (pathElement) => pathElement.getAttribute('d') ?? '',
@ -43,7 +43,7 @@ function readPathDefinitions(container: HTMLElement): string[] {
} }
describe('Liniendiagramm', () => { describe('Liniendiagramm', () => {
it('unterbricht die Linie an einer Lücke, statt sie zu überbrücken', () => { it('unterbricht die Linie an einer Luecke, statt sie zu ueberbruecken', () => {
// Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf. // Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf.
const { container } = render( const { container } = render(
<LineChart <LineChart
@ -62,19 +62,19 @@ describe('Liniendiagramm', () => {
const pathDefinitions = readPathDefinitions(container); const pathDefinitions = readPathDefinitions(container);
// Zwei getrennte Linienzüge — nicht einer, der die Lücke überspringt. // Zwei getrennte Linienzuege — nicht einer, der die Luecke ueberspringt.
expect(pathDefinitions).toHaveLength(2); expect(pathDefinitions).toHaveLength(2);
for (const pathDefinition of pathDefinitions) { for (const pathDefinition of pathDefinitions) {
// Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger // Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger
// Linienzug mit zwei M-Befehlen wäre zwar optisch gleich, ließe sich // Linienzug mit zwei M-Befehlen waere zwar optisch gleich, liesse sich
// aber nicht mehr als getrennt erkennen. // aber nicht mehr als getrennt erkennen.
expect(pathDefinition.startsWith('M')).toBe(true); expect(pathDefinition.startsWith('M')).toBe(true);
expect(pathDefinition.split('M').length - 1).toBe(1); expect(pathDefinition.split('M').length - 1).toBe(1);
} }
}); });
it('zieht eine Lücke nicht auf den Nullpunkt', () => { it('zieht eine Luecke nicht auf den Nullpunkt', () => {
const { container } = render( const { container } = render(
<LineChart <LineChart
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]} series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
@ -86,7 +86,7 @@ describe('Liniendiagramm', () => {
const allCoordinates = pathDefinitions.join(' '); const allCoordinates = pathDefinitions.join(' ');
// Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der // Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der
// Zeichenfläche. Taucht er auf, wurde die Lücke als Null gezeichnet — und // Zeichenflaeche. Taucht er auf, wurde die Luecke als Null gezeichnet — und
// die Kurve behauptet einen Einbruch, den es nicht gab. // die Kurve behauptet einen Einbruch, den es nicht gab.
const lowestDrawnPosition = Math.max( const lowestDrawnPosition = Math.max(
...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])), ...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])),
@ -96,8 +96,8 @@ describe('Liniendiagramm', () => {
}); });
it('zeichnet eine einzelne Messung als Punkt', () => { it('zeichnet eine einzelne Messung als Punkt', () => {
// Eine einzelne Messung ergäbe eine Linie der Länge null und wäre // Eine einzelne Messung ergaebe eine Linie der Laenge null und waere
// unsichtbar — die Kurve sähe aus wie „nichts gemessen". // unsichtbar — die Kurve saehe aus wie „nichts gemessen".
const { container } = render( const { container } = render(
<LineChart <LineChart
series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]} series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]}
@ -116,20 +116,20 @@ describe('Liniendiagramm', () => {
/>, />,
); );
// Kein leeres Achsenkreuz: Das sähe aus wie eine Kurve auf null. // Kein leeres Achsenkreuz: Das saehe aus wie eine Kurve auf null.
expect(screen.getByText(/keine Daten/)).toBeInTheDocument(); expect(screen.getByText(/keine Daten/)).toBeInTheDocument();
expect(screen.getByText(/nicht/)).toBeInTheDocument(); expect(screen.getByText(/nicht/)).toBeInTheDocument();
}); });
it('beginnt die Werteachse bei null', () => { it('beginnt die Werteachse bei null', () => {
// Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche // Eine abgeschnittene Achse laesst kleine Schwankungen wie Einbrueche
// aussehen — der häufigste Weg, mit korrekten Zahlen etwas Falsches zu // aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu
// zeigen. // zeigen.
render( render(
<LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />, <LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />,
); );
// Die Achse läuft von null bis zum größten Wert der Reihe. // Die Achse laeuft von null bis zum groessten Wert der Reihe.
expect(screen.getByText('0')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('102')).toBeInTheDocument(); expect(screen.getByText('102')).toBeInTheDocument();
}); });

View File

@ -1,29 +1,29 @@
/** /**
* Liniendiagramm als SVG, ohne Diagrammbibliothek. * Liniendiagramm als SVG, ohne Diagrammbibliothek.
* *
* Warum keine? Die gängigen Bibliotheken bringen mehr Code mit, als die ganze * Warum keine? Die gaengigen Bibliotheken bringen mehr Code mit, als die ganze
* Oberfläche heute hat — und die eine Eigenschaft, auf die es hier ankommt, * Oberflaeche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
* beherrschen sie standardmäßig falsch: **Sie zeichnen Lücken als Nullen.** * beherrschen sie standardmaessig falsch: **Sie zeichnen Luecken als Nullen.**
* *
* Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die * Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die
* dort auf den Nullpunkt fällt, lässt eine Anlage aussehen, als wäre ihre * dort auf den Nullpunkt faellt, laesst eine Anlage aussehen, als waere ihre
* Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei * Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei
* unterbricht die Linie stattdessen. * unterbricht die Linie stattdessen.
*/ */
import type { DataPoint, Series, SeriesUnit } from './metricsApi'; import type { DataPoint, Series, SeriesUnit } from './metricsApi';
/** Abmessungen der Zeichenfläche. */ /** Abmessungen der Zeichenflaeche. */
const CHART_VIEWBOX_WIDTH = 720; const CHART_VIEWBOX_WIDTH = 720;
/** Höhe der Zeichenfläche. */ /** Hoehe der Zeichenflaeche. */
const CHART_VIEWBOX_HEIGHT = 220; const CHART_VIEWBOX_HEIGHT = 220;
/** Innenabstand links für die Beschriftung der Werteachse. */ /** Innenabstand links fuer die Beschriftung der Werteachse. */
const CHART_PADDING_LEFT = 64; const CHART_PADDING_LEFT = 64;
/** Innenabstand rechts. */ /** Innenabstand rechts. */
const CHART_PADDING_RIGHT = 12; const CHART_PADDING_RIGHT = 12;
/** Innenabstand oben. */ /** Innenabstand oben. */
const CHART_PADDING_TOP = 12; const CHART_PADDING_TOP = 12;
/** Innenabstand unten für die Zeitachse. */ /** Innenabstand unten fuer die Zeitachse. */
const CHART_PADDING_BOTTOM = 28; const CHART_PADDING_BOTTOM = 28;
/** Farben der Reihen in Zeichenreihenfolge. */ /** Farben der Reihen in Zeichenreihenfolge. */
@ -46,19 +46,19 @@ interface LineChartProperties {
export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element { export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element {
const maximumValue = findMaximumValue(series); const maximumValue = findMaximumValue(series);
// Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Fläche mit // Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Flaeche mit
// Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz. // Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz.
if (maximumValue === null) { if (maximumValue === null) {
return ( return (
<p className="chart__empty"> <p className="chart__empty">
In diesem Zeitraum wurde nichts gemessen. Die leere Fläche bedeutet „keine Daten", nicht In diesem Zeitraum wurde nichts gemessen. Die leere Flaeche bedeutet „keine Daten", nicht
„Wert null". „Wert null".
</p> </p>
); );
} }
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiß das nach // Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiss das nach
// der Rückgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen. // der Rueckgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
const scaleMaximum: number = maximumValue; const scaleMaximum: number = maximumValue;
const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT; const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT;
@ -76,8 +76,8 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
/** Rechnet einen Wert in eine Y-Koordinate um. */ /** Rechnet einen Wert in eine Y-Koordinate um. */
function verticalPositionOf(dataValue: number): number { function verticalPositionOf(dataValue: number): number {
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse lässt // Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse laesst
// kleine Schwankungen wie Einbrüche aussehen — der häufigste Weg, mit // kleine Schwankungen wie Einbrueche aussehen — der haeufigste Weg, mit
// einem korrekten Diagramm etwas Falsches zu zeigen. // einem korrekten Diagramm etwas Falsches zu zeigen.
return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight; return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight;
} }
@ -126,11 +126,11 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
/** Eigenschaften der Hilfslinien. */ /** Eigenschaften der Hilfslinien. */
interface GridLinesProperties { interface GridLinesProperties {
/** Größter Wert der Reihen. */ /** Groesster Wert der Reihen. */
readonly maximumValue: number; readonly maximumValue: number;
/** Einheit der Werte. */ /** Einheit der Werte. */
readonly unit: SeriesUnit; readonly unit: SeriesUnit;
/** Breite der Zeichenfläche. */ /** Breite der Zeichenflaeche. */
readonly plotWidth: number; readonly plotWidth: number;
/** Rechnet einen Wert in eine Y-Koordinate um. */ /** Rechnet einen Wert in eine Y-Koordinate um. */
readonly verticalPositionOf: (dataValue: number) => number; readonly verticalPositionOf: (dataValue: number) => number;
@ -197,8 +197,8 @@ function SeriesPath({
/> />
))} ))}
{/* Ein einzelner Messpunkt ohne Nachbarn ergäbe eine Linie der Länge {/* Ein einzelner Messpunkt ohne Nachbarn ergaebe eine Linie der Laenge
null und wäre unsichtbar. Er wird deshalb als Punkt gezeichnet. */} null und waere unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
{series.points.map((dataPoint, pointIndex) => {series.points.map((dataPoint, pointIndex) =>
dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? ( dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? (
<circle <circle
@ -218,8 +218,8 @@ function SeriesPath({
/** /**
* Baut die Linienabschnitte einer Reihe. * Baut die Linienabschnitte einer Reihe.
* *
* Jede zusammenhängende Folge von Punkten mit Wert wird ein eigener Abschnitt. * Jede zusammenhaengende Folge von Punkten mit Wert wird ein eigener Abschnitt.
* Eine Lücke unterbricht die Linie — sie wird nicht überbrückt und nicht auf * Eine Luecke unterbricht die Linie — sie wird nicht ueberbrueckt und nicht auf
* null gezogen. * null gezogen.
*/ */
function buildPathSegments( function buildPathSegments(
@ -263,7 +263,7 @@ function isIsolatedPoint(points: readonly DataPoint[], pointIndex: number): bool
return !previousHasValue && !nextHasValue; return !previousHasValue && !nextHasValue;
} }
/** Sucht den größten Wert aller Reihen. */ /** Sucht den groessten Wert aller Reihen. */
function findMaximumValue(series: readonly Series[]): number | null { function findMaximumValue(series: readonly Series[]): number | null {
let maximumValue: number | null = null; let maximumValue: number | null = null;
@ -281,8 +281,8 @@ function findMaximumValue(series: readonly Series[]): number | null {
} }
} }
// Ein Maximum von null macht die Division unmöglich. Eine Reihe aus lauter // Ein Maximum von null macht die Division unmoeglich. Eine Reihe aus lauter
// Nullen ist ein gültiger Fall — etwa null Fehlschläge. // Nullen ist ein gueltiger Fall — etwa null Fehlschlaege.
if (maximumValue !== null && maximumValue === 0) { if (maximumValue !== null && maximumValue === 0) {
return 1; return 1;
} }
@ -290,7 +290,7 @@ function findMaximumValue(series: readonly Series[]): number | null {
return maximumValue; return maximumValue;
} }
/** Faktor zwischen zwei Größeneinheiten. */ /** Faktor zwischen zwei Groesseneinheiten. */
const BYTE_UNIT_STEP = 1024; const BYTE_UNIT_STEP = 1024;
/** Schreibt einen Achsenwert lesbar. */ /** Schreibt einen Achsenwert lesbar. */

View File

@ -1,9 +1,9 @@
/** /**
* Kennzahlen und Diagramme. * Kennzahlen und Diagramme.
* *
* Alle zwölf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage * Alle zwoelf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage
* — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt. * — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt.
* Dieselbe Regel wie in der Übersicht: Ein weggelassenes Diagramm sieht aus wie * Dieselbe Regel wie in der Uebersicht: Ein weggelassenes Diagramm sieht aus wie
* ein vergessenes, ein leeres wie eine Anlage ohne Betrieb. * ein vergessenes, ein leeres wie eine Anlage ohne Betrieb.
*/ */
@ -16,7 +16,7 @@ import { LineChart } from './LineChart';
import { fetchChart, fetchChartCatalog } from './metricsApi'; import { fetchChart, fetchChartCatalog } from './metricsApi';
import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi'; import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi';
/** Auswählbare Zeiträume mit ihrer Beschriftung. */ /** Auswaehlbare Zeitraeume mit ihrer Beschriftung. */
const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [ const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '1h', label: 'Letzte Stunde' }, { value: '1h', label: 'Letzte Stunde' },
{ value: '24h', label: 'Letzte 24 Stunden' }, { value: '24h', label: 'Letzte 24 Stunden' },
@ -93,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 gewählte Zeitraum. */ /** Der gewaehlte Zeitraum. */
readonly timeRange: string; readonly timeRange: string;
} }
@ -105,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 Oberfläche sähe aus wie eine // antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine
// Störung. Es ist keine — die Funktion gibt es nur noch nicht. // Stoerung. Es ist keine — die Funktion gibt es nur noch nicht.
const { loadState, data, loadError, reload } = useApiResource<Chart>( const { loadState, data, loadError, reload } = useApiResource<Chart>(
loadChart, loadChart,
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable', definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
@ -115,7 +115,7 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
if (!definition.available) { if (!definition.available) {
return ( return (
<Card> <Card>
<CardHeader title={definition.title} description="noch nicht verfügbar" /> <CardHeader title={definition.title} description="noch nicht verfuegbar" />
<CardContent> <CardContent>
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p> <p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</CardContent> </CardContent>

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;
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Erklärung. */ /** Erklaerung. */
readonly description: string; readonly description: string;
/** Einheit der Werte. */ /** Einheit der Werte. */
readonly unit: SeriesUnit; readonly unit: SeriesUnit;
@ -27,7 +27,7 @@ export interface ChartDefinition {
readonly source: string; readonly source: string;
/** Meldet, ob es eine Datengrundlage gibt. */ /** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean; readonly available: boolean;
/** Erklärt eine fehlende Datengrundlage. */ /** Erklaert eine fehlende Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
} }
@ -37,7 +37,7 @@ export interface ChartCatalog {
readonly charts: readonly ChartDefinition[]; readonly charts: readonly ChartDefinition[];
/** Zahl der Diagramme mit Datengrundlage. */ /** Zahl der Diagramme mit Datengrundlage. */
readonly available_count: number; readonly available_count: number;
/** Wählbare Zeiträume. */ /** Waehlbare Zeitraeume. */
readonly ranges: readonly string[]; readonly ranges: readonly string[];
} }
@ -45,7 +45,7 @@ export interface ChartCatalog {
export interface DataPoint { export interface DataPoint {
/** Beginn des Zeitfensters in UTC. */ /** Beginn des Zeitfensters in UTC. */
readonly timestamp: string; readonly timestamp: string;
/** Wert; nur gültig, wenn has_value gesetzt ist. */ /** Wert; nur gueltig, wenn has_value gesetzt ist. */
readonly value?: number; readonly value?: number;
/** /**
* Meldet, ob in diesem Zeitfenster etwas gemessen wurde. * Meldet, ob in diesem Zeitfenster etwas gemessen wurde.
@ -70,13 +70,13 @@ export interface Series {
readonly points: readonly DataPoint[]; readonly points: readonly DataPoint[];
} }
/** Ein vollständiges Diagramm. */ /** Ein vollstaendiges Diagramm. */
export interface Chart { export interface Chart {
/** Bezeichner. */ /** Bezeichner. */
readonly metric: string; readonly metric: string;
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Erklärung. */ /** Erklaerung. */
readonly description: string; readonly description: string;
/** Ausgewerteter Zeitraum. */ /** Ausgewerteter Zeitraum. */
readonly window: { readonly window: {
@ -90,12 +90,12 @@ export interface Chart {
readonly note?: string; readonly note?: string;
} }
/** Lädt den Katalog aller Diagramme. */ /** Laedt den Katalog aller Diagramme. */
export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> { export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> {
return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {}); return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {});
} }
/** Lädt ein Diagramm für den angegebenen Zeitraum. */ /** Laedt ein Diagramm fuer den angegebenen Zeitraum. */
export async function fetchChart( export async function fetchChart(
metricName: string, metricName: string,
timeRange: string, timeRange: string,

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 verlässt die * Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlaesst die
* Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort * Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort
* überlebt eine erfundene Null jede mündliche Erläuterung. * ueberlebt eine erfundene Null jede muendliche Erlaeuterung.
*/ */
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReportsPage } from './ReportsPage'; import { ReportsPage } from './ReportsPage';
/** Baut eine Antwort in der Standardhülle. */ /** Baut eine Antwort in der Standardhuelle. */
function buildJsonResponse(payload: unknown): Response { function buildJsonResponse(payload: unknown): Response {
return { return {
status: 200, status: 200,
@ -28,14 +28,14 @@ const reportCatalog = [
{ {
type: 'daily_backup', type: 'daily_backup',
title: 'Tagesbericht Sicherungen', title: 'Tagesbericht Sicherungen',
description: 'Alle Sicherungsläufe eines Tages.', description: 'Alle Sicherungslaeufe eines Tages.',
period_kind: 'range', period_kind: 'range',
default_period: 'letzte 24 Stunden', default_period: 'letzte 24 Stunden',
formats: ['json', 'csv', 'pdf'], formats: ['json', 'csv', 'pdf'],
}, },
{ {
type: 'repository_capacity', type: 'repository_capacity',
title: 'Bericht Repository-Kapazität', title: 'Bericht Repository-Kapazitaet',
description: 'Belegung und Zustand aller Repositories.', description: 'Belegung und Zustand aller Repositories.',
period_kind: 'point_in_time', period_kind: 'point_in_time',
formats: ['json', 'csv', 'pdf'], formats: ['json', 'csv', 'pdf'],
@ -46,34 +46,34 @@ const reportCatalog = [
const reportWithUnknownMetric = { const reportWithUnknownMetric = {
type: 'daily_backup', type: 'daily_backup',
title: 'Tagesbericht Sicherungen', title: 'Tagesbericht Sicherungen',
description: 'Alle Sicherungsläufe eines Tages.', description: 'Alle Sicherungslaeufe eines Tages.',
period_from: '2026-08-12T00:00:00Z', period_from: '2026-08-12T00:00:00Z',
period_to: '2026-08-13T00:00:00Z', period_to: '2026-08-13T00:00:00Z',
generated_at: '2026-08-13T06:00:00Z', generated_at: '2026-08-13T06:00:00Z',
generated_by: 'prüfer', generated_by: 'pruefer',
sections: [ sections: [
{ {
title: 'Überblick', title: 'Ueberblick',
metrics: [ metrics: [
{ label: 'Läufe insgesamt', value: 0, unit: 'count', is_known: true }, { label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true },
{ {
label: 'Erfolgsquote', label: 'Erfolgsquote',
unit: 'percent', unit: 'percent',
is_known: false, is_known: false,
unknown_reason: 'Im gewählten Zeitraum wurde kein Lauf abgeschlossen.', unknown_reason: 'Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.',
}, },
], ],
tables: [ tables: [
{ {
title: 'Einzelne Läufe', title: 'Einzelne Laeufe',
columns: ['Auftrag', 'Ergebnis'], columns: ['Auftrag', 'Ergebnis'],
rows: [], rows: [],
empty_notice: 'Im gewählten Zeitraum wurde kein Lauf begonnen.', empty_notice: 'Im gewaehlten Zeitraum wurde kein Lauf begonnen.',
}, },
], ],
}, },
], ],
notes: ['Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'], notes: ['Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'],
}; };
describe('Berichte', () => { describe('Berichte', () => {
@ -94,10 +94,10 @@ describe('Berichte', () => {
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument(); expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
}); });
expect(screen.getByText('Bericht Repository-Kapazität')).toBeInTheDocument(); expect(screen.getByText('Bericht Repository-Kapazitaet')).toBeInTheDocument();
// Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er // Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er
// nicht auswertet, wäre ein Versprechen, das er nicht einlöst. // nicht auswertet, waere ein Versprechen, das er nicht einloest.
expect(screen.getByText('Zustandsbericht')).toBeInTheDocument(); expect(screen.getByText('Zustandsbericht')).toBeInTheDocument();
expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument(); expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
}); });
@ -119,18 +119,18 @@ describe('Berichte', () => {
expect(screen.getByText('Erfolgsquote')).toBeInTheDocument(); expect(screen.getByText('Erfolgsquote')).toBeInTheDocument();
}); });
// Die ungemessene Quote steht als „nicht gemessen" da — mit Begründung. // Die ungemessene Quote steht als „nicht gemessen" da — mit Begruendung.
expect(screen.getByText('nicht gemessen')).toBeInTheDocument(); expect(screen.getByText('nicht gemessen')).toBeInTheDocument();
expect( expect(
screen.getByText('Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'), screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'),
).toBeInTheDocument(); ).toBeInTheDocument();
// Die gemessene Null bleibt dagegen sichtbar: „null Läufe" ist eine // Die gemessene Null bleibt dagegen sichtbar: „null Laeufe" ist eine
// Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden. // Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden.
// Die gemessene Null steht als Zahl da und trägt **keine** Begründung — // Die gemessene Null steht als Zahl da und traegt **keine** Begruendung —
// sie braucht keine. Genau daran unterscheidet sie sich von der // sie braucht keine. Genau daran unterscheidet sie sich von der
// ungemessenen Quote daneben. // ungemessenen Quote daneben.
const measuredValue = screen.getByText('Läufe insgesamt').nextElementSibling; const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling;
expect(measuredValue?.textContent).toBe('0'); expect(measuredValue?.textContent).toBe('0');
}); });
@ -148,11 +148,11 @@ describe('Berichte', () => {
await userEvent.click(screen.getByText('Tagesbericht Sicherungen')); await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Einzelne Läufe')).toBeInTheDocument(); expect(screen.getByText('Einzelne Laeufe')).toBeInTheDocument();
}); });
expect( expect(
screen.getByText('Im gewählten Zeitraum wurde kein Lauf begonnen.'), screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });

View File

@ -1,9 +1,9 @@
/** /**
* Berichte. * Berichte.
* *
* Die Seite, die in Phase 12 als „noch nicht verfügbar" im Menü stand. * Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand.
* *
* Zwei Dinge unterscheidet sie von einer gewöhnlichen Berichtsmaske: * Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske:
* *
* Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV * Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt * und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
@ -41,13 +41,13 @@ interface ReportCatalogEntry {
readonly type: string; readonly type: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklärt, welche Frage der Bericht beantwortet. */ /** Erklaert, welche Frage der Bericht beantwortet. */
readonly description: string; readonly description: string;
/** Art des Zeitbezugs. */ /** Art des Zeitbezugs. */
readonly period_kind: PeriodKind; readonly period_kind: PeriodKind;
/** Beschreibung des Standardzeitraums. */ /** Beschreibung des Standardzeitraums. */
readonly default_period?: string; readonly default_period?: string;
/** Verfügbare Ausgabeformate. */ /** Verfuegbare Ausgabeformate. */
readonly formats: readonly ReportFormat[]; readonly formats: readonly ReportFormat[];
} }
@ -55,7 +55,7 @@ interface ReportCatalogEntry {
interface ReportMetric { interface ReportMetric {
/** Beschriftung. */ /** Beschriftung. */
readonly label: string; readonly label: string;
/** Gemessener Wert; nur gültig, wenn is_known gilt. */ /** Gemessener Wert; nur gueltig, wenn is_known gilt. */
readonly value?: number; readonly value?: number;
/** Einheit. */ /** Einheit. */
readonly unit?: string; readonly unit?: string;
@ -63,13 +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;
/** Erklärt einen fehlenden Wert. */ /** Erklaert einen fehlenden Wert. */
readonly unknown_reason?: string; readonly unknown_reason?: string;
} }
/** Eine Tabelle eines Berichts. */ /** Eine Tabelle eines Berichts. */
interface ReportTable { interface ReportTable {
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Spaltenbeschriftungen. */ /** Spaltenbeschriftungen. */
readonly columns: readonly string[]; readonly columns: readonly string[];
@ -81,9 +81,9 @@ interface ReportTable {
/** Ein Abschnitt eines Berichts. */ /** Ein Abschnitt eines Berichts. */
interface ReportSection { interface ReportSection {
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Erklärung. */ /** Erklaerung. */
readonly description?: string; readonly description?: string;
/** Kennzahlen. */ /** Kennzahlen. */
readonly metrics?: readonly ReportMetric[]; readonly metrics?: readonly ReportMetric[];
@ -97,7 +97,7 @@ interface GeneratedReport {
readonly type: string; readonly type: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklärung. */ /** Erklaerung. */
readonly description: string; readonly description: string;
/** Beginn des Zeitraums. */ /** Beginn des Zeitraums. */
readonly period_from: string; readonly period_from: string;
@ -109,19 +109,19 @@ interface GeneratedReport {
readonly generated_by?: string; readonly generated_by?: string;
/** Abschnitte. */ /** Abschnitte. */
readonly sections: readonly ReportSection[]; readonly sections: readonly ReportSection[];
/** Benannte Lücken und Einschränkungen. */ /** Benannte Luecken und Einschraenkungen. */
readonly notes?: readonly string[]; readonly notes?: readonly string[];
} }
/** Zustand einer laufenden Ausgabe. */ /** Zustand einer laufenden Ausgabe. */
interface ExportState { interface ExportState {
/** Format, das gerade erzeugt wird; null, wenn nichts läuft. */ /** Format, das gerade erzeugt wird; null, wenn nichts laeuft. */
readonly runningFormat: ReportFormat | null; readonly runningFormat: ReportFormat | null;
/** Fehler der letzten Ausgabe. */ /** Fehler der letzten Ausgabe. */
readonly exportError: ApiError | null; readonly exportError: ApiError | null;
} }
/** Lädt den Berichtskatalog. */ /** Laedt den Berichtskatalog. */
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> { function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal }); return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
} }
@ -202,11 +202,11 @@ export function ReportsPage(): React.JSX.Element {
if (catalogResource.loadState === 'failed' || catalogResource.data === null) { if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer // loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
// blieb — dann steht der Katalog trotzdem nicht zur Verfügung. // blieb — dann steht der Katalog trotzdem nicht zur Verfuegung.
return ( return (
<Card> <Card>
<ErrorState <ErrorState
title="Der Berichtskatalog steht nicht zur Verfügung." title="Der Berichtskatalog steht nicht zur Verfuegung."
message={ message={
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
} }
@ -221,7 +221,7 @@ export function ReportsPage(): React.JSX.Element {
<> <>
<PageHeader <PageHeader
title="Berichte" title="Berichte"
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." description="Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei — einschliesslich der Angaben, die sich nicht messen liessen."
/> />
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@ -246,7 +246,7 @@ export function ReportsPage(): React.JSX.Element {
nicht historisiert; "vom letzten Dienstag" kann es nicht geben. */} 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 ?? 'waehlbar'}`}
</span> </span>
</button> </button>
))} ))}
@ -264,7 +264,7 @@ export function ReportsPage(): React.JSX.Element {
); );
} }
/** Zeigt einen erzeugten Bericht mit seinen Ausgabemöglichkeiten. */ /** Zeigt einen erzeugten Bericht mit seinen Ausgabemoeglichkeiten. */
function ReportPanel(properties: { function ReportPanel(properties: {
readonly catalogEntry: ReportCatalogEntry; readonly catalogEntry: ReportCatalogEntry;
readonly previewReport: GeneratedReport | null; readonly previewReport: GeneratedReport | null;
@ -356,8 +356,8 @@ function ReportPreview(properties: { readonly report: GeneratedReport }): React.
<dt className="text-xs text-fg-subtle">{reportMetric.label}</dt> <dt className="text-xs text-fg-subtle">{reportMetric.label}</dt>
<dd className="mt-1"> <dd className="mt-1">
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl. {/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
Eine erfundene Null überlebt in einem Prüfordner jede Eine erfundene Null ueberlebt in einem Pruefordner jede
mündliche Erläuterung. */} muendliche Erlaeuterung. */}
<span <span
className={ className={
reportMetric.is_known reportMetric.is_known
@ -412,7 +412,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
<div className="mt-4"> <div className="mt-4">
<h4 className="text-sm font-medium text-fg">{table.title}</h4> <h4 className="text-sm font-medium text-fg">{table.title}</h4>
<p className="mt-1 text-sm text-fg-muted"> <p className="mt-1 text-sm text-fg-muted">
{table.empty_notice ?? 'Keine Einträge.'} {table.empty_notice ?? 'Keine Eintraege.'}
</p> </p>
</div> </div>
); );
@ -441,10 +441,10 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
</tr> </tr>
</thead> </thead>
{/* {/*
Die Position ist der Schlüssel: Eine Berichtszeile trägt keine Die Position ist der Schluessel: Eine Berichtszeile traegt keine
Kennung, und zwei Zeilen können Zeichen für Zeichen gleich sein. Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein.
Unbedenklich, weil die Tabelle statisch ist — sie wird weder Unbedenklich, weil die Tabelle statisch ist — sie wird weder
umsortiert noch ergänzt, sondern mit dem Bericht neu aufgebaut. umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut.
*/} */}
<tbody> <tbody>
{visibleRows.map((tableRow, rowIndex) => ( {visibleRows.map((tableRow, rowIndex) => (
@ -462,7 +462,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
{table.rows.length > visibleRows.length && ( {table.rows.length > visibleRows.length && (
<p className="mt-1.5 text-xs text-fg-muted"> <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 enthaelt alle.
</p> </p>
)} )}
</div> </div>
@ -539,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 Übersicht). * liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Uebersicht).
*/ */
function formatPercent(percentValue: number): string { function formatPercent(percentValue: number): string {
if (percentValue > 0 && percentValue < 0.1) { if (percentValue > 0 && percentValue < 0.1) {
@ -555,11 +555,11 @@ function formatTimestamp(isoTimestamp: string): string {
} }
/** /**
* Löst den Browser-Download einer Datei aus. * Loest den Browser-Download einer Datei aus.
* *
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das hält der Browser * Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser
* jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei * jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei
* einem Monatsbericht über eine große Anlage sind das schnell einige Megabyte * einem Monatsbericht ueber eine grosse Anlage sind das schnell einige Megabyte
* je Klick. * je Klick.
*/ */
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void { function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {

View File

@ -1,13 +1,13 @@
/** /**
* Repositories — Liste, Übernahme und Wartung. * Repositories — Liste, Uebernahme und Wartung.
* *
* Die Maske zur Übernahme sagt ausdrücklich, dass hier **nichts angelegt** * Die Maske zur Uebernahme sagt ausdruecklich, dass hier **nichts angelegt**
* wird. Ein Repository entsteht auf einem Datenträger; der Server öffnet das * wird. Ein Repository entsteht auf einem Datentraeger; der Server oeffnet das
* vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis * vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis
* trägt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich. * traegt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
* *
* Der Integritätslauf trennt zwei Lagen, die gern vermengt werden: „Die * Der Integritaetslauf trennt zwei Lagen, die gern vermengt werden: „Die
* Prüfung schlug fehl" und „das Repository ist beschädigt". Das eine ist ein * Pruefung schlug fehl" und „das Repository ist beschaedigt". Das eine ist ein
* Problem des Werkzeugs, das andere ein Datenverlust. * Problem des Werkzeugs, das andere ein Datenverlust.
*/ */
@ -36,7 +36,7 @@ import {
useToast, useToast,
type TableColumn, type TableColumn,
} from '@/components/ui'; } from '@/components/ui';
import { formatBytes, formatDateTime } from '@/lib/utils'; import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils';
import { import {
adoptRepository, adoptRepository,
checkRepositoryHealth, checkRepositoryHealth,
@ -44,7 +44,7 @@ import {
measureEnforcement, measureEnforcement,
rebuildCatalog, rebuildCatalog,
startIntegrityScan, startIntegrityScan,
type IntegrityScanDetails, type IntegrityScanResult,
type Repository, type Repository,
} from './repositoriesApi'; } from './repositoriesApi';
@ -56,7 +56,7 @@ export function RepositoriesPage({
const toast = useToast(); const toast = useToast();
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false); const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null); const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
const [scanResult, setScanResult] = useState<IntegrityScanDetails | null>(null); const [scanResult, setScanResult] = useState<IntegrityScanResult | null>(null);
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null); const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
const [adoptName, setAdoptName] = useState(''); const [adoptName, setAdoptName] = useState('');
@ -72,7 +72,7 @@ export function RepositoriesPage({
const adoptMutation = useMutation(adoptRepository, { const adoptMutation = useMutation(adoptRepository, {
onSuccess: (repository) => { onSuccess: (repository) => {
toast.showSuccess( toast.showSuccess(
'Repository übernommen', 'Repository uebernommen',
`${repository.name} ist jetzt in der Control Plane bekannt.`, `${repository.name} ist jetzt in der Control Plane bekannt.`,
); );
setIsAdoptDialogOpen(false); setIsAdoptDialogOpen(false);
@ -82,7 +82,7 @@ export function RepositoriesPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Übernahme schlug fehl', 'Die Uebernahme schlug fehl',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -94,17 +94,17 @@ export function RepositoriesPage({
setRepositoryPendingScan(null); setRepositoryPendingScan(null);
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung // Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt, // unterscheidet beides — ein Pruefwerkzeug, das grundlos Alarm schlaegt,
// wird bald nicht mehr ernst genommen. // wird bald nicht mehr ernst genommen.
if (result.missing_chunks > 0 || result.corrupted_chunks > 0) { if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
toast.showError( toast.showError(
'Der Integritätslauf hat Befunde', 'Der Integritaetslauf hat Befunde',
`${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`, `${result.chunks_missing} Bloecke fehlen, ${result.chunks_corrupted} sind beschaedigt.`,
); );
} else { } else {
toast.showSuccess( toast.showSuccess(
'Integritätslauf ohne Befund', 'Integritaetslauf ohne Befund',
`${result.chunks_checked.toLocaleString('de-DE')} Blöcke geprüft.`, `${result.chunks_checked.toLocaleString('de-DE')} Bloecke geprueft.`,
); );
} }
@ -112,28 +112,18 @@ export function RepositoriesPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Integritätslauf ließ sich nicht durchführen', 'Der Integritaetslauf liess sich nicht durchfuehren',
`${describeApiError(apiError)} — das ist ein Problem der Prüfung, kein Befund am Repository.`, `${describeApiError(apiError)} — das ist ein Problem der Pruefung, kein Befund am Repository.`,
apiError.requestId, apiError.requestId,
), ),
}); });
const healthMutation = useMutation(checkRepositoryHealth, { const healthMutation = useMutation(checkRepositoryHealth, {
onSuccess: (checkResponse) => { onSuccess: (health) => {
// Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der toast.showSuccess(
// Hülle, die zweite in `details`. 'Gesundheitspruefung abgeschlossen',
if (!checkResponse.reachable) { health.message ?? `Zustand: ${health.status}`,
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(); repositoriesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
@ -153,10 +143,11 @@ export function RepositoriesPage({
}); });
const rebuildMutation = useMutation(rebuildCatalog, { const rebuildMutation = useMutation(rebuildCatalog, {
onSuccess: (checkResponse) => { onSuccess: (result) => {
toast.showSuccess( toast.showSuccess(
'Katalog neu aufgebaut', 'Katalog neu aufgebaut',
`${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`, result.summary ??
`${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
); );
repositoriesResource.reload(); repositoriesResource.reload();
}, },
@ -191,7 +182,7 @@ export function RepositoriesPage({
}, },
{ {
key: 'enforcement', key: 'enforcement',
header: 'Löschschutz', header: 'Loeschschutz',
render: (repository) => render: (repository) =>
repository.enforcement_level ? ( repository.enforcement_level ? (
<StatusBadge status={repository.enforcement_level} /> <StatusBadge status={repository.enforcement_level} />
@ -208,10 +199,10 @@ export function RepositoriesPage({
}, },
{ {
key: 'hardened', key: 'hardened',
header: 'Gehärtet', header: 'Gehaertet',
render: (repository) => render: (repository) =>
repository.hardened ? ( repository.hardened ? (
<ShieldCheck className="size-4 text-healthy" aria-label="gehärtet" /> <ShieldCheck className="size-4 text-healthy" aria-label="gehaertet" />
) : ( ) : (
<span className="text-xs text-fg-muted">nein</span> <span className="text-xs text-fg-muted">nein</span>
), ),
@ -242,7 +233,7 @@ export function RepositoriesPage({
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}> <Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus /> <Plus />
Repository übernehmen Repository uebernehmen
</Button> </Button>
) : null ) : null
} }
@ -256,12 +247,12 @@ export function RepositoriesPage({
onRowClick={(repository) => setSelectedRepository(repository)} onRowClick={(repository) => setSelectedRepository(repository)}
isLoading={repositoriesResource.loadState === 'loading'} isLoading={repositoriesResource.loadState === 'loading'}
emptyTitle="Kein Repository eingetragen" emptyTitle="Kein Repository eingetragen"
emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen." emptyDescription="Ein Repository entsteht auf einem Datentraeger mit „syncova-repo create“ und wird hier uebernommen."
emptyAction={ emptyAction={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}> <Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus /> <Plus />
Repository übernehmen Repository uebernehmen
</Button> </Button>
) : null ) : null
} }
@ -275,7 +266,7 @@ export function RepositoriesPage({
description={selectedRepository.location} description={selectedRepository.location}
action={ action={
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}> <Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
Schließen Schliessen
</Button> </Button>
} }
/> />
@ -288,7 +279,7 @@ export function RepositoriesPage({
<DetailItem label="Belegt"> <DetailItem label="Belegt">
{formatBytes(selectedRepository.used_bytes)} {formatBytes(selectedRepository.used_bytes)}
</DetailItem> </DetailItem>
<DetailItem label="Zuletzt geprüft"> <DetailItem label="Zuletzt geprueft">
{selectedRepository.last_health_check_at {selectedRepository.last_health_check_at
? formatDateTime(selectedRepository.last_health_check_at) ? formatDateTime(selectedRepository.last_health_check_at)
: 'Nie'} : 'Nie'}
@ -303,7 +294,7 @@ export function RepositoriesPage({
onClick={() => void healthMutation.run(selectedRepository.id)} onClick={() => void healthMutation.run(selectedRepository.id)}
> >
<RefreshCw /> <RefreshCw />
Gesundheit prüfen Gesundheit pruefen
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -315,7 +306,7 @@ export function RepositoriesPage({
</Button> </Button>
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}> <Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
<Database /> <Database />
Integritätslauf Integritaetslauf
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -329,7 +320,10 @@ export function RepositoriesPage({
) : null} ) : null}
<Callout tone="info" className="mt-4"> <Callout tone="info" className="mt-4">
Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei. Der <strong>Katalog</strong> ist nur ein Beschleuniger; verbindlich
sind die Manifeste. Ihn neu aufzubauen ist verlustfrei — und
zugleich der Nachweis, dass dieses Repository ohne die Datenbank
auskommt.
</Callout> </Callout>
</CardContent> </CardContent>
</Card> </Card>
@ -339,20 +333,20 @@ export function RepositoriesPage({
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} /> <IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
) : null} ) : null}
{/* --- Übernahme --- */} {/* --- Uebernahme --- */}
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}> <DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader <DialogHeader
title="Repository übernehmen" title="Repository uebernehmen"
description="Ein vorhandenes Repository in die Control Plane aufnehmen." description="Ein vorhandenes Repository in die Control Plane aufnehmen."
/> />
<DialogBody> <DialogBody>
<Callout tone="warning" title="Hier wird nichts angelegt"> <Callout tone="warning" title="Hier wird nichts angelegt">
Ein Repository entsteht auf einem Datenträger: Ein Repository entsteht auf einem Datentraeger:
<code className="mt-1 block rounded bg-sunken px-2 py-1 font-mono text-xs"> <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 sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
</code> </code>
Dieser Schritt öffnet das vorhandene und liest seine Kennung aus Dieser Schritt oeffnet das vorhandene und liest seine Kennung aus
dem Descriptor. dem Descriptor.
</Callout> </Callout>
@ -370,7 +364,7 @@ export function RepositoriesPage({
value={adoptLocation} value={adoptLocation}
onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)} onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)}
placeholder="/srv/syncova-repository" 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." hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Eintraege auf dasselbe Verzeichnis ergaeben Wettlauf um die Schreibsperre."
/> />
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -388,7 +382,7 @@ export function RepositoriesPage({
}) })
} }
> >
Übernehmen Uebernehmen
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -397,7 +391,7 @@ export function RepositoriesPage({
<ConfirmDialog <ConfirmDialog
open={repositoryPendingScan !== null} open={repositoryPendingScan !== null}
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)} onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
title="Integritätslauf starten" title="Integritaetslauf starten"
confirmLabel="Lauf starten" confirmLabel="Lauf starten"
isLoading={scanMutation.isRunning} isLoading={scanMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -407,75 +401,68 @@ export function RepositoriesPage({
}} }}
> >
<p className="text-sm text-fg"> <p className="text-sm text-fg">
Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das. Jeder Block wird gegen seine gespeicherte Pruefsumme geprueft. Bei
grossen Bestaenden dauert das lange — der Datenschluessel wird dafuer
nicht gebraucht.
</p> </p>
</ConfirmDialog> </ConfirmDialog>
</> </>
); );
} }
/** Ergebnis eines Integritätslaufs. */ /** Ergebnis eines Integritaetslaufs. */
function IntegrityScanCard({ function IntegrityScanCard({
result, result,
onClose, onClose,
}: { }: {
readonly result: IntegrityScanDetails; readonly result: IntegrityScanResult;
readonly onClose: () => void; readonly onClose: () => void;
}) { }) {
const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0; const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0;
return ( return (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader <CardHeader
title="Ergebnis des Integritätslaufs" title="Ergebnis des Integritaetslaufs"
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schließen Schliessen
</Button> </Button>
} }
/> />
<CardContent> <CardContent>
<Callout tone={hasFindings ? 'critical' : 'healthy'}> <Callout tone={hasFindings ? 'critical' : 'healthy'}>
{hasFindings {hasFindings
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.' ? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups waeren unvollstaendig.'
: result.summary} : 'Ohne Befund. Jeder gepruefte Block stimmt mit seiner Pruefsumme ueberein.'}
</Callout> </Callout>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Blöcke geprüft"> <DetailItem label="Bloecke geprueft">
{result.chunks_checked.toLocaleString('de-DE')} {result.chunks_checked.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Fehlend"> <DetailItem label="Fehlend">
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}> <span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
{result.missing_chunks.toLocaleString('de-DE')} {result.chunks_missing.toLocaleString('de-DE')}
</span> </span>
</DetailItem> </DetailItem>
<DetailItem label="Beschädigt"> <DetailItem label="Beschaedigt">
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}> <span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
{result.corrupted_chunks.toLocaleString('de-DE')} {result.chunks_corrupted.toLocaleString('de-DE')}
</span> </span>
</DetailItem> </DetailItem>
<DetailItem label="Backups"> <DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
{result.backups_healthy} von {result.backups_checked} vollständig
</DetailItem>
</dl> </dl>
{!result.verified_chunk_contents ? ( {result.findings && result.findings.length > 0 ? (
<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"> <div className="mt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle"> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Betroffene Backups Betroffene Objekte
</p> </p>
<ul className="mt-1.5 space-y-1"> <ul className="mt-1.5 space-y-1">
{result.affected_backup_ids.map((backupIdentifier) => ( {result.findings.map((finding) => (
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted"> <li key={finding} className="break-all font-mono text-xs text-fg-muted">
{backupIdentifier} {finding}
</li> </li>
))} ))}
</ul> </ul>
@ -486,10 +473,10 @@ function IntegrityScanCard({
); );
} }
/** Kachel mit der Speicherbelegung, für die Übersicht. */ /** Kachel mit der Speicherbelegung, fuer die Uebersicht. */
export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) { export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) {
// Ohne hinterlegte Kapazität gibt es keinen Prozentsatz. Ihn zu erfinden // Ohne hinterlegte Kapazitaet gibt es keinen Prozentsatz. Ihn zu erfinden
// wäre die häufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen. // waere die haeufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
const hasCapacity = const hasCapacity =
repository.total_bytes !== undefined && repository.total_bytes > 0; repository.total_bytes !== undefined && repository.total_bytes > 0;
@ -501,7 +488,7 @@ export function RepositoryCapacityTile({ repository }: { readonly repository: Re
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
{hasCapacity {hasCapacity
? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}` ? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}`
: `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazität nicht hinterlegt`} : `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazitaet nicht hinterlegt`}
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,94 +0,0 @@
/**
* Regressionstest zum schwarzen Bildschirm auf /retention.
*
* `GET /retention-policies` liefert ein **Objekt** `{ policies, predefined }`,
* als einziger von zehn geprüften Listenendpunkten. Die Oberfläche behandelte
* es als Array; `map` gibt es auf einem Objekt nicht, React hängte den ganzen
* Baum aus, und übrig blieb eine leere Seite — im dunklen Thema schwarz.
*
* Der Test füttert die **echte** Antwortform. Ein Test mit einem Array hätte
* den Fehler nie gefunden — und genau das war passiert.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ToastProvider } from '@/components/ui';
import { RetentionPage } from './RetentionPage';
/** Baut eine Antwort in der Hülle der API. */
function apiResponse(payload: unknown): Response {
return {
ok: true,
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
json: async () => ({ data: payload, meta: { request_id: 'req-1' } }),
} as unknown as Response;
}
/** Die Antwortform, die der Server tatsächlich liefert. */
const echteAntwort = {
policies: [],
predefined: [
{
name: '7 Tage',
description: 'Behalten werden alles jünger als 7 Tage, die letzten 1 Backups.',
keep_within_seconds: 604800,
keep_last: 1,
keep_daily: 0,
keep_weekly: 0,
keep_monthly: 0,
keep_yearly: 0,
},
],
};
describe('Aufbewahrung', () => {
it('stürzt nicht ab, wenn die Regelliste ein Objekt ist', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (requestUrl: string) =>
String(requestUrl).includes('retention-policies')
? apiResponse(echteAntwort)
: apiResponse([]),
),
);
render(
<ToastProvider>
<RetentionPage grantedPermissions={['*']} />
</ToastProvider>,
);
// Die Seite steht, statt den Baum abzureißen.
await waitFor(() => {
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
});
// Und die mitgelieferten Vorlagen erscheinen als anklickbare Kacheln.
expect(await screen.findByText('7 Tage')).toBeInTheDocument();
});
it('kommt ohne die beiden Felder zurecht', async () => {
// Ein Server, der beide Felder weglässt, darf die Seite nicht schwärzen.
vi.stubGlobal(
'fetch',
vi.fn(async (requestUrl: string) =>
String(requestUrl).includes('retention-policies')
? apiResponse({})
: apiResponse([]),
),
);
render(
<ToastProvider>
<RetentionPage grantedPermissions={['*']} />
</ToastProvider>,
);
await waitFor(() => {
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
});
expect(screen.getByText('Keine Aufbewahrungsregel')).toBeInTheDocument();
});
});

View File

@ -3,16 +3,16 @@
* *
* Zwei Dinge tragen diese Seite: * Zwei Dinge tragen diese Seite:
* *
* - **`keep_last` schützt das letzte vorhandene Backup.** Ohne es löschte * - **`keep_last` schuetzt das letzte vorhandene Backup.** Ohne es loeschte
* „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup — * „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup —
* genau dann, wenn man es am dringendsten braucht. Eine Regel ganz ohne * 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, * Haltevorgabe wird abgelehnt; wird eine 1 ergaenzt, sagt die Oberflaeche das,
* statt es still zu tun. * statt es still zu tun.
* - **Die Vorschau ist keine Formalie.** Sie löscht nichts und liefert die * - **Die Vorschau ist keine Formalie.** Sie loescht nichts und liefert die
* Zahl, die danach wörtlich bestätigt werden muss. * Zahl, die danach woertlich bestaetigt werden muss.
* *
* Dass beim Löschen oft **kein Speicher frei wird**, ist kein Fehler, sondern * Dass beim Loeschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rückfrage. * Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rueckfrage.
*/ */
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
@ -85,12 +85,12 @@ export function RetentionPage({
const createMutation = useMutation(createRetentionPolicy, { const createMutation = useMutation(createRetentionPolicy, {
onSuccess: (policy) => { onSuccess: (policy) => {
// Eine stillschweigend ergänzte Haltevorgabe wäre eine Änderung, die // Eine stillschweigend ergaenzte Haltevorgabe waere eine Aenderung, die
// niemand sieht — und die später niemand erklären kann. // niemand sieht — und die spaeter niemand erklaeren kann.
if (!draftKeepLast || Number(draftKeepLast) < 1) { if (!draftKeepLast || Number(draftKeepLast) < 1) {
toast.showInfo( toast.showInfo(
'Regel angelegt', 'Regel angelegt',
`„${policy.name}" — es wurde eine Mindesthaltung von 1 ergänzt, damit nie das letzte Backup entfällt.`, `„${policy.name}" — es wurde eine Mindesthaltung von 1 ergaenzt, damit nie das letzte Backup entfaellt.`,
); );
} else { } else {
toast.showSuccess('Regel angelegt', policy.name); toast.showSuccess('Regel angelegt', policy.name);
@ -101,12 +101,12 @@ export function RetentionPage({
policiesResource.reload(); policiesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Regel ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Die Regel liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteRetentionPolicy, { const deleteMutation = useMutation(deleteRetentionPolicy, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Regel gelöscht'); toast.showSuccess('Regel geloescht');
setPolicyPendingDeletion(null); setPolicyPendingDeletion(null);
policiesResource.reload(); policiesResource.reload();
}, },
@ -119,7 +119,7 @@ export function RetentionPage({
setPreviewResult(preview); setPreviewResult(preview);
toast.showInfo( toast.showInfo(
'Vorschau erstellt', 'Vorschau erstellt',
`${preview.backups_to_delete} Wiederherstellungspunkte würden entfallen. Es wurde nichts gelöscht.`, `${preview.backups_to_delete} Wiederherstellungspunkte wuerden entfallen. Es wurde nichts geloescht.`,
); );
}, },
onError: (apiError) => onError: (apiError) =>
@ -132,7 +132,7 @@ export function RetentionPage({
'Aufbewahrung angewendet', 'Aufbewahrung angewendet',
result.bytes_to_free result.bytes_to_free
? `${formatBytes(result.bytes_to_free)} frei geworden.` ? `${formatBytes(result.bytes_to_free)} frei geworden.`
: 'Es wurde kein Speicher frei — die Blöcke werden von anderen Backups gebraucht (Deduplizierung).', : 'Es wurde kein Speicher frei — die Bloecke werden von anderen Backups gebraucht (Deduplizierung).',
); );
setIsApplyDialogOpen(false); setIsApplyDialogOpen(false);
setPreviewResult(null); setPreviewResult(null);
@ -141,8 +141,7 @@ export function RetentionPage({
toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const policies = policiesResource.data?.policies ?? []; const policies = policiesResource.data ?? [];
const predefinedTemplates = policiesResource.data?.predefined ?? [];
const repositories = repositoriesResource.data ?? []; const repositories = repositoriesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [ const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
@ -164,14 +163,14 @@ export function RetentionPage({
header: 'Mindestens', header: 'Mindestens',
numeric: true, numeric: true,
render: (policy) => ( render: (policy) => (
<span title="Schützt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist."> <span title="Schuetzt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist.">
{policy.keep_last ?? '—'} {policy.keep_last ?? '—'}
</span> </span>
), ),
}, },
{ {
key: 'grid', key: 'grid',
header: 'Täglich / Wöchentlich / Monatlich / Jährlich', header: 'Taeglich / Woechentlich / Monatlich / Jaehrlich',
render: (policy) => render: (policy) =>
[policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly] [policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly]
.map((value) => value ?? '—') .map((value) => value ?? '—')
@ -186,7 +185,7 @@ export function RetentionPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={`Regel ${policy.name} löschen`} aria-label={`Regel ${policy.name} loeschen`}
onClick={(clickEvent) => { onClick={(clickEvent) => {
clickEvent.stopPropagation(); clickEvent.stopPropagation();
setPolicyPendingDeletion(policy); setPolicyPendingDeletion(policy);
@ -228,57 +227,15 @@ export function RetentionPage({
} }
/> />
{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"> <Card className="mb-4">
<CardHeader title="Eigene Regeln" /> <CardHeader title="Regeln" />
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
rows={policies} rows={policies}
getRowKey={(policy) => policy.id} getRowKey={(policy) => policy.id}
isLoading={policiesResource.loadState === 'loading'} isLoading={policiesResource.loadState === 'loading'}
emptyTitle="Keine Aufbewahrungsregel" emptyTitle="Keine Aufbewahrungsregel"
emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand löscht." emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand loescht."
/> />
</Card> </Card>
@ -286,7 +243,7 @@ export function RetentionPage({
<Card> <Card>
<CardHeader <CardHeader
title="Regel anwenden" title="Regel anwenden"
description="Die Vorschau löscht nichts und zeigt, was entfiele." description="Die Vorschau loescht nichts und zeigt, was entfiele."
/> />
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@ -295,7 +252,7 @@ export function RetentionPage({
value={selectedRepositoryId} value={selectedRepositoryId}
onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)} onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)}
> >
<option value="">Bitte wählen</option> <option value="">Bitte waehlen</option>
{repositories.map((repository) => ( {repositories.map((repository) => (
<option key={repository.id} value={repository.id}> <option key={repository.id} value={repository.id}>
{repository.name} {repository.name}
@ -308,7 +265,7 @@ export function RetentionPage({
value={selectedPolicyId} value={selectedPolicyId}
onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)} onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)}
> >
<option value="">Bitte wählen</option> <option value="">Bitte waehlen</option>
{policies.map((policy) => ( {policies.map((policy) => (
<option key={policy.id} value={policy.id}> <option key={policy.id} value={policy.id}>
{policy.name} {policy.name}
@ -337,7 +294,7 @@ export function RetentionPage({
: undefined : undefined
} }
> >
Anwenden und löschen Anwenden und loeschen
</Button> </Button>
</div> </div>
@ -345,18 +302,18 @@ export function RetentionPage({
<div className="rounded-md border border-line bg-sunken p-3"> <div className="rounded-md border border-line bg-sunken p-3">
<p className="text-sm text-fg"> <p className="text-sm text-fg">
<strong>{previewResult.backups_to_delete}</strong>{' '} <strong>{previewResult.backups_to_delete}</strong>{' '}
Wiederherstellungspunkte würden entfallen Wiederherstellungspunkte wuerden entfallen
{previewResult.backups_protected {previewResult.backups_protected
? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten` ? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten`
: ''} : ''}
. .
</p> </p>
<p className="mt-1 text-sm text-fg-muted"> <p className="mt-1 text-sm text-fg-muted">
Frei würden voraussichtlich {formatBytes(previewResult.bytes_to_free)}. Frei wuerden voraussichtlich {formatBytes(previewResult.bytes_to_free)}.
</p> </p>
<p className="mt-2 text-xs text-fg-muted"> <p className="mt-2 text-xs text-fg-muted">
Deutlich weniger als die Summe der Backupgrößen ist der Deutlich weniger als die Summe der Backupgroessen ist der
Normalfall: Deduplizierte Blöcke werden von anderen Normalfall: Deduplizierte Bloecke werden von anderen
Wiederherstellungspunkten weiter gebraucht. Wiederherstellungspunkten weiter gebraucht.
</p> </p>
</div> </div>
@ -392,10 +349,10 @@ export function RetentionPage({
min={1} min={1}
value={draftKeepLast} value={draftKeepLast}
onChange={(changeEvent) => setDraftKeepLast(changeEvent.target.value)} onChange={(changeEvent) => setDraftKeepLast(changeEvent.target.value)}
hint="Schützt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist." hint="Schuetzt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist."
/> />
<TextInput <TextInput
label="Tägliche behalten" label="Taegliche behalten"
type="number" type="number"
min={0} min={0}
value={draftKeepDaily} value={draftKeepDaily}
@ -411,7 +368,9 @@ export function RetentionPage({
</div> </div>
<Callout tone="warning"> <Callout tone="warning">
Ohne Haltevorgabe wird die Regel abgelehnt. Eine Regel <strong>ohne jede Haltevorgabe</strong> wird abgelehnt.
Sonst loeschte „30 Tage" bei einem laenger nicht gesicherten System
jedes Backup — genau dann, wenn man es braucht.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -441,9 +400,9 @@ export function RetentionPage({
<ConfirmDialog <ConfirmDialog
open={policyPendingDeletion !== null} open={policyPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)} onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)}
title="Regel löschen" title="Regel loeschen"
description={policyPendingDeletion?.name} description={policyPendingDeletion?.name}
confirmLabel="Löschen" confirmLabel="Loeschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -453,7 +412,8 @@ export function RetentionPage({
}} }}
> >
<p className="text-sm text-fg-muted"> <p className="text-sm text-fg-muted">
Künftig wird nach dieser Regel nichts mehr entfernt. Bereits geloeschte Wiederherstellungspunkte kommen dadurch nicht
zurueck. Kuenftig wird nach dieser Regel nichts mehr entfernt.
</p> </p>
</ConfirmDialog> </ConfirmDialog>
@ -461,14 +421,14 @@ export function RetentionPage({
open={isApplyDialogOpen} open={isApplyDialogOpen}
onOpenChange={setIsApplyDialogOpen} onOpenChange={setIsApplyDialogOpen}
title="Aufbewahrung anwenden" title="Aufbewahrung anwenden"
description="Wiederherstellungspunkte werden endgültig entfernt." description="Wiederherstellungspunkte werden endgueltig entfernt."
confirmLabel="Endgültig löschen" confirmLabel="Endgueltig loeschen"
isDestructive isDestructive
isLoading={applyMutation.isRunning} isLoading={applyMutation.isRunning}
confirmationWord={String(previewResult?.backups_to_delete ?? '')} confirmationWord={String(previewResult?.backups_to_delete ?? '')}
confirmationHint={ confirmationHint={
<> <>
Tippen Sie zur Bestätigung die Zahl der zu löschenden Punkte:{' '} Tippen Sie zur Bestaetigung die Zahl der zu loeschenden Punkte:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{previewResult?.backups_to_delete} {previewResult?.backups_to_delete}
</code> </code>
@ -483,7 +443,8 @@ export function RetentionPage({
} }
> >
<Callout tone="critical"> <Callout tone="critical">
Geschützte Punkte bleiben unberührt. Geschuetzte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben
unberuehrt — der Server lehnt ihre Loeschung ab.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,12 +1,12 @@
/** /**
* API-Anbindung der Repositories (SYNCOVA_API.md §8). * API-Anbindung der Repositories (SYNCOVA_API.md §8).
* *
* Ein Punkt prägt die ganze Maske: **`POST /repositories` legt nichts an, es * Ein Punkt praegt die ganze Maske: **`POST /repositories` legt nichts an, es
* übernimmt.** Ein Repository entsteht auf einem Datenträger — mit * uebernimmt.** Ein Repository entsteht auf einem Datentraeger — mit
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt öffnet * `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt oeffnet
* ein vorhandenes und liest dessen Kennung aus dem Descriptor. * ein vorhandenes und liest dessen Kennung aus dem Descriptor.
* *
* Wer das nicht weiß, trägt einen Pfad ein, bekommt einen Fehler und sucht * Wer das nicht weiss, traegt einen Pfad ein, bekommt einen Fehler und sucht
* ihn an der falschen Stelle. * ihn an der falschen Stelle.
*/ */
@ -24,60 +24,32 @@ export interface Repository {
hardened: boolean; hardened: boolean;
/** Belegter Speicher; nicht immer bekannt. */ /** Belegter Speicher; nicht immer bekannt. */
used_bytes?: number; used_bytes?: number;
/** Gesamtkapazität, sofern hinterlegt. */ /** Gesamtkapazitaet, sofern hinterlegt. */
total_bytes?: number; total_bytes?: number;
/** Gemessene Durchsetzungsstufe des Löschschutzes. */ /** Gemessene Durchsetzungsstufe des Loeschschutzes. */
enforcement_level?: string; enforcement_level?: string;
last_health_check_at?: string; last_health_check_at?: string;
created_at?: string; created_at?: string;
} }
/** /** Ergebnis eines Integritaetslaufs. */
* Antworthülle der Repository-Prüfungen. export interface IntegrityScanResult {
* /** Geprueft. */
* **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; chunks_checked: number;
/** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */ /** Nicht auffindbar. */
missing_chunks: number; chunks_missing: number;
/** Prüfsumme stimmt nicht. */ /** Pruefsumme stimmt nicht. */
corrupted_chunks: number; chunks_corrupted: number;
/** Blöcke ohne Verweis aus einem Manifest. */ manifests_checked?: number;
orphaned_chunks: number; manifests_invalid?: number;
backups_checked: number; duration_seconds?: number;
backups_healthy: number; /** Zusammenfassung im Klartext. */
/** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */ summary?: string;
verified_chunk_contents: boolean; /** Betroffene Objekte, sofern benennbar. */
healthy: boolean; findings?: string[];
summary: string;
/** Betroffene Backups, sofern benennbar. */
affected_backup_ids?: string[] | null;
} }
/** Ergebnis einer Gesundheitsprüfung. */ /** Ergebnis einer Gesundheitspruefung. */
export interface RepositoryHealth { export interface RepositoryHealth {
status: string; status: string;
/** Freier Platz am Ablageort. */ /** Freier Platz am Ablageort. */
@ -96,9 +68,9 @@ export interface RetentionPreview {
/** Punkte, die durch Schutz erhalten bleiben. */ /** Punkte, die durch Schutz erhalten bleiben. */
backups_protected?: number; backups_protected?: number;
/** /**
* Speicher, der frei würde. * Speicher, der frei wuerde.
* *
* Oft deutlich weniger als die Summe der Backupgrößen — das ist * Oft deutlich weniger als die Summe der Backupgroessen — das ist
* Deduplizierung und kein Fehler. * Deduplizierung und kein Fehler.
*/ */
bytes_to_free?: number; bytes_to_free?: number;
@ -106,12 +78,12 @@ export interface RetentionPreview {
affected?: Array<{ backup_id: string; completed_at?: string; reason?: string }>; affected?: Array<{ backup_id: string; completed_at?: string; reason?: string }>;
} }
/** Lädt die Repositories. */ /** Laedt die Repositories. */
export async function listRepositories(abortSignal?: AbortSignal): Promise<Repository[]> { export async function listRepositories(abortSignal?: AbortSignal): Promise<Repository[]> {
return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {}); return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
} }
/** Lädt ein einzelnes Repository. */ /** Laedt ein einzelnes Repository. */
export async function getRepository( export async function getRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -123,10 +95,10 @@ export async function getRepository(
} }
/** /**
* Übernimmt ein vorhandenes Repository in die Control Plane. * Uebernimmt ein vorhandenes Repository in die Control Plane.
* *
* Es wird **nicht angelegt**: Der Server öffnet das Verzeichnis und liest die * Es wird **nicht angelegt**: Der Server oeffnet das Verzeichnis und liest die
* Kennung aus dem Descriptor. Liegt dort keines, schlägt der Aufruf fehl. * Kennung aus dem Descriptor. Liegt dort keines, schlaegt der Aufruf fehl.
*/ */
export async function adoptRepository(repositoryRequest: { export async function adoptRepository(repositoryRequest: {
name: string; name: string;
@ -140,7 +112,7 @@ export async function adoptRepository(repositoryRequest: {
}); });
} }
/** Ändert Name oder Zustand eines Repositorys. */ /** Aendert Name oder Zustand eines Repositorys. */
export async function updateRepository( export async function updateRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
changes: { name?: string; status?: string }, changes: { name?: string; status?: string },
@ -151,73 +123,63 @@ export async function updateRepository(
); );
} }
/** Prüft die Erreichbarkeit. */ /** Prueft die Erreichbarkeit. */
export async function testRepository( export async function testRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryCheckResponse<Record<string, unknown>>> { ): Promise<RepositoryHealth> {
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>( return requestApi<RepositoryHealth>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
{ method: 'POST' }, { method: 'POST' },
); );
} }
/** Führt eine Gesundheitsprüfung aus. */ /** Fuehrt eine Gesundheitspruefung aus. */
export async function checkRepositoryHealth( export async function checkRepositoryHealth(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryCheckResponse<RepositoryHealth>> { ): Promise<RepositoryHealth> {
return requestApi<RepositoryCheckResponse<RepositoryHealth>>( return requestApi<RepositoryHealth>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
{ method: 'POST' }, { method: 'POST' },
); );
} }
/** /**
* Startet einen Integritätslauf. * Startet einen Integritaetslauf.
* *
* Er prüft jeden Block gegen die gespeicherte Prüfsumme der **abgelegten** * Er prueft jeden Block gegen die gespeicherte Pruefsumme der **abgelegten**
* Form — deshalb braucht er den Datenschlüssel nicht. Ein Befund ist ein * Form — deshalb braucht er den Datenschluessel nicht. Ein Befund ist ein
* Ergebnis, kein Fehler des Laufs; die Oberfläche trennt beides. * Ergebnis, kein Fehler des Laufs; die Oberflaeche trennt beides.
*/ */
export async function startIntegrityScan( export async function startIntegrityScan(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<IntegrityScanDetails> { ): Promise<IntegrityScanResult> {
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>( return requestApi<IntegrityScanResult>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
{ method: 'POST', idempotencyKey: true }, { 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. * Baut den Katalog neu auf.
* *
* Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er * Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er
* lässt sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis, * laesst sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis,
* dass das Repository ohne die Datenbank auskommt. * dass das Repository ohne die Datenbank auskommt.
*/ */
export async function rebuildCatalog( export async function rebuildCatalog(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> { ): Promise<{ backups_found?: number; summary?: string }> {
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>( return requestApi<{ backups_found?: number; summary?: string }>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
{ method: 'POST', idempotencyKey: true }, { method: 'POST', idempotencyKey: true },
); );
} }
/** /**
* Misst die Durchsetzungsstufe des Löschschutzes. * Misst die Durchsetzungsstufe des Loeschschutzes.
* *
* Gemessen, nicht behauptet: Der Server legt Probedateien an und versucht sie * Gemessen, nicht behauptet: Der Server legt Probedateien an und versucht sie
* zu löschen. Gemeldet wird nur, was das Betriebssystem nachweislich * zu loeschen. Gemeldet wird nur, was das Betriebssystem nachweislich
* verhindert. * verhindert.
*/ */
export async function measureEnforcement( export async function measureEnforcement(
@ -235,7 +197,7 @@ export async function measureEnforcement(
); );
} }
/** Zeigt, was eine Aufbewahrungsregel entfernen würde. Löscht nichts. */ /** Zeigt, was eine Aufbewahrungsregel entfernen wuerde. Loescht nichts. */
export async function previewRetention( export async function previewRetention(
repositoryIdentifier: string, repositoryIdentifier: string,
policyIdentifier: string, policyIdentifier: string,
@ -247,9 +209,9 @@ export async function previewRetention(
} }
/** /**
* Wendet eine Aufbewahrungsregel an und löscht. * Wendet eine Aufbewahrungsregel an und loescht.
* *
* `confirm_deletion` wiederholt die Zahl der zu löschenden Punkte wörtlich — * `confirm_deletion` wiederholt die Zahl der zu loeschenden Punkte woertlich —
* die Vorschau davor ist keine Formalie, sondern die Grundlage dieser Zahl. * die Vorschau davor ist keine Formalie, sondern die Grundlage dieser Zahl.
*/ */
export async function applyRetention( export async function applyRetention(
@ -276,7 +238,7 @@ export interface RetentionPolicy {
id: string; id: string;
name: string; name: string;
keep_within_seconds?: number; keep_within_seconds?: number;
/** Schützt das letzte vorhandene Backup. */ /** Schuetzt das letzte vorhandene Backup. */
keep_last?: number; keep_last?: number;
keep_daily?: number; keep_daily?: number;
keep_weekly?: number; keep_weekly?: number;
@ -285,41 +247,14 @@ export interface RetentionPolicy {
time_zone?: string; time_zone?: string;
} }
/** /** Laedt die Aufbewahrungsregeln. */
* 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( export async function listRetentionPolicies(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<RetentionPolicyList> { ): Promise<RetentionPolicy[]> {
const response = await requestApi<RetentionPolicyList>( return requestApi<RetentionPolicy[]>(
'/retention-policies', '/retention-policies',
abortSignal ? { signal: abortSignal } : {}, 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. */ /** Legt eine Aufbewahrungsregel an. */
@ -333,7 +268,7 @@ export async function createRetentionPolicy(
}); });
} }
/** Löscht eine Aufbewahrungsregel. */ /** Loescht eine Aufbewahrungsregel. */
export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> { export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> {
return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, { return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, {
method: 'DELETE', method: 'DELETE',

View File

@ -1,189 +0,0 @@
/**
* Auswahl dessen, was zurückgeholt werden soll.
*
* Bisher gab es nur „alles" — ein Textfeld für einen Teilbaum, das voraussetzte,
* dass man die Pfade im Backup auswendig kennt. Jetzt lässt sich blättern.
*
* Der gewählte Pfad wandert als `path_prefix` in die Anfrage. Das genügt für
* beides: Der Server vergleicht auf Gleichheit **oder** Präfix mit
* Verzeichnisgrenze, trifft also sowohl einen ganzen Ordner als auch eine
* einzelne Datei. „dokumente" trifft dabei nicht „dokumentation".
*
* Bewusste Grenze: **eine** Auswahl je Lauf, kein Mehrfachhaken. Eine Liste
* ausgewählter Pfade kennt die API nicht, und sie vorzutäuschen — etwa durch
* mehrere Läufe hintereinander — ergäbe mehrere Wiederherstellungen mit
* getrenntem Ausgang. Ein „teilweise fehlgeschlagen" ließe sich dann niemandem
* mehr erklären.
*/
import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { Button, Callout, ErrorState, LoadingState } from '@/components/ui';
import { formatBytes, formatDateTime } from '@/lib/utils';
import { browseBackupContents } from './browseApi';
export function BackupContentPicker({
backupIdentifier,
selectedPath,
onSelect,
}: {
readonly backupIdentifier: string;
/** Leer bedeutet: das gesamte Backup. */
readonly selectedPath: string;
readonly onSelect: (contentPath: string) => void;
}) {
const [currentPath, setCurrentPath] = useState('');
const contentsResource = useApiResource(
useCallback(
(abortSignal) => browseBackupContents(backupIdentifier, currentPath, abortSignal),
[backupIdentifier, currentPath],
),
`${backupIdentifier}|${currentPath}`,
);
const listing = contentsResource.data;
return (
<div className="space-y-3">
{/* Der Regelfall steht oben und ist vorausgewählt: Die meisten
Wiederherstellungen holen alles zurück. */}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={selectedPath === '' ? 'primary' : 'secondary'}
onClick={() => onSelect('')}
>
Gesamtes Backup
</Button>
<span className="text-xs text-fg-muted">
{listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''}
</span>
</div>
{/* Pfadleiste */}
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setCurrentPath('')}
>
Wurzel
</button>
{currentPath
.split('/')
.filter((segment) => segment !== '')
.map((segment, segmentIndex, allSegments) => (
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setCurrentPath(allSegments.slice(0, segmentIndex + 1).join('/'))}
>
{segment}
</button>
</span>
))}
</div>
<div className="max-h-72 overflow-y-auto border border-line">
{contentsResource.loadState === 'loading' ? (
<LoadingState label="Inhalt wird gelesen …" />
) : contentsResource.loadState === 'failed' && contentsResource.loadError ? (
<ErrorState
message={describeApiError(contentsResource.loadError)}
requestId={contentsResource.loadError.requestId}
onRetry={contentsResource.reload}
/>
) : (
<ul className="divide-y divide-line">
{currentPath !== '' ? (
<li>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
onClick={() => setCurrentPath(listing?.parent_path ?? '')}
>
<FolderOpen className="size-4 shrink-0" aria-hidden />
… eine Ebene höher
</button>
</li>
) : null}
{(listing?.entries ?? []).map((entry) => (
<li key={entry.path} className="flex items-center">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
onClick={() =>
entry.is_directory ? setCurrentPath(entry.path) : onSelect(entry.path)
}
>
{entry.is_directory ? (
<Folder className="size-4 shrink-0 text-fg-muted" aria-hidden />
) : (
<File className="size-4 shrink-0 text-fg-subtle" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg">{entry.name}</span>
<span className="shrink-0 text-[11px] tabular text-fg-muted">
{entry.is_directory
? entry.child_count
? `${entry.child_count} Objekte · ${formatBytes(entry.total_bytes)}`
: ''
: formatBytes(entry.size_bytes)}
</span>
</button>
<Button
size="sm"
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
className="mr-1 shrink-0"
onClick={() => onSelect(entry.path)}
title={
entry.is_directory
? 'Diesen Ordner mit allem darin zurückholen'
: 'Nur diese Datei zurückholen'
}
>
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
</Button>
</li>
))}
{(listing?.entries ?? []).length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-fg-muted">
Dieser Ordner ist im Backup leer.
</li>
) : null}
</ul>
)}
</div>
{selectedPath ? (
<Callout tone="info">
Zurückgeholt wird nur: <code className="font-mono">{selectedPath}</code>
<p className="mt-1 text-xs">
Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des
Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das
Backup.
</p>
</Callout>
) : (
<Callout tone="info">Zurückgeholt wird das gesamte Backup.</Callout>
)}
{listing?.entries.some((entry) => entry.modified_at) ? (
<p className="text-xs text-fg-subtle">
Stand der Dateien:{' '}
{formatDateTime(
listing.entries.find((entry) => entry.modified_at)?.modified_at,
)}
</p>
) : null}
</div>
);
}

View File

@ -1,220 +0,0 @@
/**
* Auswahl des Zielverzeichnisses.
*
* Ersetzt das Textfeld, in das man einen Pfad tippte und erst nach der
* Vorabprüfung erfuhr, dass der Dienst dort gar nicht schreiben darf.
*
* Die tragende Angabe ist **`is_writable`**, und sie wird gemessen: Der Server
* legt eine Probedatei an und entfernt sie wieder. Aus den Rechtebits ließe sie
* sich nicht ableiten — der Dienst läuft mit `ProtectSystem=strict`, und davon
* steht nichts im Modus. Genau deshalb endete eine Wiederherstellung nach
* `/opt/test` mit „permission denied", obwohl das Verzeichnis beschreibbar
* aussah.
*
* Nicht beschreibbare Verzeichnisse werden **gezeigt**, nicht versteckt: Sie
* lassen sich betreten, um tiefer zu blättern, aber nicht auswählen. Sie
* wegzulassen ließe den Betreiber im Dunkeln, warum sein Pfad fehlt.
*/
import { ChevronRight, FolderOpen, Lock, Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import {
Button,
Callout,
ErrorState,
LoadingState,
TextInput,
} from '@/components/ui';
import { cn } from '@/lib/utils';
import { browseFilesystem } from './browseApi';
export function DirectoryPicker({
selectedPath,
onSelect,
}: {
readonly selectedPath: string;
readonly onSelect: (directoryPath: string) => void;
}) {
// Der Startpunkt ist das übergeordnete Verzeichnis der Auswahl, sonst die
// Wurzel — so landet man beim zweiten Öffnen dort, wo man aufgehört hat.
const [currentPath, setCurrentPath] = useState(() => {
const trimmedSelection = selectedPath.trim();
if (!trimmedSelection.startsWith('/')) {
return '/';
}
const parentPath = trimmedSelection.replace(/\/[^/]*$/, '');
return parentPath === '' ? '/' : parentPath;
});
const [newFolderName, setNewFolderName] = useState('');
const browseResource = useApiResource(
useCallback((abortSignal) => browseFilesystem(currentPath, abortSignal), [currentPath]),
currentPath,
);
const listing = browseResource.data;
return (
<div className="space-y-3">
{/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen
brauchbaren Ort zu kommen. */}
{listing?.suggested_paths && listing.suggested_paths.length > 0 ? (
<div>
<p className="mb-1.5 text-xs text-fg-subtle">Beschreibbare Orte</p>
<div className="flex flex-wrap gap-1.5">
{listing.suggested_paths.map((suggestedPath) => (
<Button
key={suggestedPath}
size="sm"
variant={selectedPath === suggestedPath ? 'primary' : 'secondary'}
onClick={() => {
onSelect(suggestedPath);
setCurrentPath(suggestedPath);
}}
>
{suggestedPath}
</Button>
))}
</div>
</div>
) : null}
{/* Pfadleiste */}
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setCurrentPath('/')}
>
/
</button>
{currentPath
.split('/')
.filter((segment) => segment !== '')
.map((segment, segmentIndex, allSegments) => (
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() =>
setCurrentPath('/' + allSegments.slice(0, segmentIndex + 1).join('/'))
}
>
{segment}
</button>
</span>
))}
</div>
{/* Liste */}
<div className="max-h-64 overflow-y-auto border border-line">
{browseResource.loadState === 'loading' ? (
<LoadingState label="Verzeichnis wird gelesen …" />
) : browseResource.loadState === 'failed' && browseResource.loadError ? (
<ErrorState
message={describeApiError(browseResource.loadError)}
requestId={browseResource.loadError.requestId}
onRetry={browseResource.reload}
/>
) : (
<ul className="divide-y divide-line">
{listing?.parent_path ? (
<li>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
onClick={() => setCurrentPath(listing.parent_path!)}
>
<FolderOpen className="size-4 shrink-0" aria-hidden />
… eine Ebene höher
</button>
</li>
) : null}
{(listing?.entries ?? []).map((entry) => (
<li key={entry.path} className="flex items-center">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
onClick={() => setCurrentPath(entry.path)}
title={entry.forbidden_reason}
>
{entry.is_writable ? (
<FolderOpen className="size-4 shrink-0 text-fg-muted" aria-hidden />
) : (
<Lock className="size-4 shrink-0 text-fg-subtle" aria-hidden />
)}
<span className={cn('truncate', entry.is_writable ? 'text-fg' : 'text-fg-subtle')}>
{entry.name}
</span>
{!entry.is_writable ? (
<span className="ml-auto shrink-0 text-[11px] text-fg-subtle">
{entry.forbidden_reason ? 'gesperrt' : 'nicht beschreibbar'}
</span>
) : null}
</button>
{entry.is_writable ? (
<Button
size="sm"
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
className="mr-1 shrink-0"
onClick={() => onSelect(entry.path)}
>
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
</Button>
) : null}
</li>
))}
{(listing?.entries ?? []).length === 0 && !listing?.parent_path ? (
<li className="px-3 py-6 text-center text-sm text-fg-muted">
Keine Unterverzeichnisse.
</li>
) : null}
</ul>
)}
</div>
{/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er
im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach
an den aktuellen Pfad. */}
<div className="flex items-end gap-2">
<TextInput
label="Neues Unterverzeichnis"
className="flex-1"
value={newFolderName}
onChange={(changeEvent) => setNewFolderName(changeEvent.target.value)}
placeholder="wiederherstellung-2026-08-18"
hint="Es wird beim Zurückschreiben angelegt."
/>
<Button
className="mb-0.5"
disabled={!newFolderName.trim()}
onClick={() => {
const combinedPath =
(currentPath === '/' ? '' : currentPath) + '/' + newFolderName.trim();
onSelect(combinedPath);
setNewFolderName('');
}}
>
<Plus />
Übernehmen
</Button>
</div>
{selectedPath ? (
<Callout tone="info">
Ziel: <code className="font-mono">{selectedPath}</code>
</Callout>
) : null}
</div>
);
}

View File

@ -1,26 +1,26 @@
/** /**
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts. * Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
* *
* Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht * Bis hierher liess sich eine Wiederherstellung ueber die Oberflaeche gar nicht
* auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen — * ausloesen. Wer im Ernstfall Daten zurueckbrauchte, musste `curl` bemuehen —
* ausgerechnet in der Lage, in der niemand experimentieren will. * ausgerechnet in der Lage, in der niemand experimentieren will.
* *
* Der Assistent hat vier Schritte, und der dritte ist der Grund für seine * Der Assistent hat vier Schritte, und der dritte ist der Grund fuer seine
* Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die * Existenz: **Die Vorabpruefung schreibt nichts und stellt fest, ob die
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block * Wiederherstellung gelingen kann** — insbesondere, ob jeder benoetigte Block
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas * noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
* gesichert hat. * gesichert hat.
* *
* Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt: * Die drei Huerden vor dem Ueberschreiben sind hier sichtbar umgesetzt:
* *
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden. * 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
* 2. Die Berechtigung `restores.overwrite` prüft der Server; sie steckt nicht * 2. Die Berechtigung `restores.overwrite` prueft der Server; sie steckt nicht
* in `restores.execute`. * in `restores.execute`.
* 3. `confirm_overwrite` verlangt den **wörtlich wiederholten Zielpfad**. * 3. `confirm_overwrite` verlangt den **woertlich wiederholten Zielpfad**.
* *
* Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts * Laeuft die dritte Huerde ins Leere — weil das Ziel leer ist und nichts
* überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das * ueberschrieben wird —, entfaellt sie. Ein Ritual ohne Anlass gewoehnt das
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt. * Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
*/ */
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react'; import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
@ -41,8 +41,6 @@ import {
useToast, useToast,
} from '@/components/ui'; } from '@/components/ui';
import { cn, formatBytes, formatDuration } from '@/lib/utils'; import { cn, formatBytes, formatDuration } from '@/lib/utils';
import { BackupContentPicker } from './BackupContentPicker';
import { DirectoryPicker } from './DirectoryPicker';
import { import {
createRestore, createRestore,
validateRestore, validateRestore,
@ -54,8 +52,8 @@ import {
const WIZARD_STEPS = [ const WIZARD_STEPS = [
{ key: 'ziel', label: 'Ziel' }, { key: 'ziel', label: 'Ziel' },
{ key: 'umfang', label: 'Umfang' }, { key: 'umfang', label: 'Umfang' },
{ key: 'prüfung', label: 'Vorabprüfung' }, { key: 'pruefung', label: 'Vorabpruefung' },
{ key: 'ausführen', label: 'Ausführen' }, { key: 'ausfuehren', label: 'Ausfuehren' },
] as const; ] as const;
export function RestoreWizard({ export function RestoreWizard({
@ -76,7 +74,7 @@ export function RestoreWizard({
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [currentStepIndex, setCurrentStepIndex] = useState(0);
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst
// wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten). // waere jeder Blick zurueck ein Datenverlust (wie im Backup-Assistenten).
const [targetPath, setTargetPath] = useState(''); const [targetPath, setTargetPath] = useState('');
const [pathPrefix, setPathPrefix] = useState(''); const [pathPrefix, setPathPrefix] = useState('');
const [overwriteExisting, setOverwriteExisting] = useState(false); const [overwriteExisting, setOverwriteExisting] = useState(false);
@ -129,7 +127,7 @@ export function RestoreWizard({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Vorabprüfung ist fehlgeschlagen', 'Die Vorabpruefung ist fehlgeschlagen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -140,13 +138,13 @@ export function RestoreWizard({
onSuccess: (session) => { onSuccess: (session) => {
toast.showSuccess( toast.showSuccess(
'Wiederherstellung gestartet', 'Wiederherstellung gestartet',
'Der Fortschritt erscheint in der Übersicht.', 'Der Fortschritt erscheint in der Uebersicht.',
); );
onStarted(session.id); onStarted(session.id);
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Wiederherstellung ließ sich nicht starten', 'Die Wiederherstellung liess sich nicht starten',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -154,8 +152,8 @@ export function RestoreWizard({
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/'); const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
// Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich // Die woertliche Bestaetigung wird nur verlangt, wenn tatsaechlich
// überschrieben wird — der Server sagt das nach der Prüfung. // ueberschrieben wird — der Server sagt das nach der Pruefung.
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false; const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
const confirmationSatisfied = const confirmationSatisfied =
!confirmationRequired || typedConfirmation.trim() === targetPath.trim(); !confirmationRequired || typedConfirmation.trim() === targetPath.trim();
@ -172,15 +170,38 @@ export function RestoreWizard({
<DialogBody className="min-h-64"> <DialogBody className="min-h-64">
{currentStepIndex === 0 ? ( {currentStepIndex === 0 ? (
<DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} /> <div className="space-y-4">
<TextInput
label="Zielverzeichnis"
required
placeholder="/srv/wiederherstellung"
value={targetPath}
onChange={(changeEvent) => setTargetPath(changeEvent.target.value)}
hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt."
error={
targetPath.trim() && !targetPath.trim().startsWith('/')
? 'Der Pfad muss absolut sein und mit / beginnen.'
: undefined
}
/>
<Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurueck an den
Ursprungsort zu schreiben ueberschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
</Callout>
</div>
) : null} ) : null}
{currentStepIndex === 1 ? ( {currentStepIndex === 1 ? (
<div className="space-y-4"> <div className="space-y-4">
<BackupContentPicker <TextInput
backupIdentifier={backupIdentifier} label="Nur ein Teilbaum (optional)"
selectedPath={pathPrefix} placeholder="daten/projekte"
onSelect={setPathPrefix} value={pathPrefix}
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
hint="Leer lassen, um alles zurueckzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prueft einen Teil, nicht das Backup."
/> />
<div className="space-y-3 border-t border-line pt-4"> <div className="space-y-3 border-t border-line pt-4">
@ -188,28 +209,36 @@ export function RestoreWizard({
checked={overwriteExisting} checked={overwriteExisting}
onCheckedChange={setOverwriteExisting} onCheckedChange={setOverwriteExisting}
disabled={!mayOverwrite} disabled={!mayOverwrite}
label="Vorhandene Dateien überschreiben" label="Vorhandene Dateien ueberschreiben"
hint={ hint={
mayOverwrite mayOverwrite
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.' ? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusaetzlich verlangt der Server danach den woertlich wiederholten Zielpfad.'
: 'Ihrer Rolle fehlt das Recht restores.overwrite.' : 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
} }
/> />
<CheckboxField <CheckboxField
checked={skipPermissions} checked={skipPermissions}
onCheckedChange={setSkipPermissions} onCheckedChange={setSkipPermissions}
label="Rechte nicht zurückschreiben" label="Rechte nicht zurueckschreiben"
hint="Dateien entstehen mit den Standardrechten des Dienstkontos." hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
/> />
<CheckboxField <CheckboxField
checked={skipDeepCheck} checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck} onCheckedChange={setSkipDeepCheck}
label="Blockprüfung überspringen" label="Blockpruefung ueberspringen"
hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind." hint="Beschleunigt die Vorabpruefung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benoetigte Block noch vorhanden ist."
/> />
</div> </div>
{skipDeepCheck ? (
<Callout tone="warning">
Ohne Blockpruefung sagt die Vorabpruefung nur, dass das
Manifest lesbar ist — nicht, dass die Daten dazu noch
existieren. Der Bericht weist das aus.
</Callout>
) : null}
</div> </div>
) : null} ) : null}
@ -226,11 +255,11 @@ export function RestoreWizard({
{confirmationRequired ? ( {confirmationRequired ? (
<> <>
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '} Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
<strong>überschrieben</strong>. <strong>ueberschrieben</strong>.
</> </>
) : ( ) : (
<> <>
Das Ziel ist leer. Es wird nichts überschrieben. Das Ziel ist leer. Es wird nichts ueberschrieben.
</> </>
)} )}
</Callout> </Callout>
@ -252,8 +281,8 @@ export function RestoreWizard({
{confirmationRequired ? ( {confirmationRequired ? (
<Field <Field
label="Zielpfad zur Bestätigung wiederholen" label="Zielpfad zur Bestaetigung wiederholen"
hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus." hint="Woertlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
required required
htmlFor="restore-confirm" htmlFor="restore-confirm"
> >
@ -279,7 +308,7 @@ export function RestoreWizard({
disabled={validationMutation.isRunning || createMutation.isRunning} disabled={validationMutation.isRunning || createMutation.isRunning}
> >
<ArrowLeft /> <ArrowLeft />
Zurück Zurueck
</Button> </Button>
) : ( ) : (
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
@ -304,7 +333,7 @@ export function RestoreWizard({
isLoading={validationMutation.isRunning} isLoading={validationMutation.isRunning}
onClick={() => void validationMutation.run()} onClick={() => void validationMutation.run()}
> >
Vorabprüfung starten Vorabpruefung starten
<ArrowRight /> <ArrowRight />
</Button> </Button>
) : null} ) : null}
@ -372,10 +401,10 @@ function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number
} }
/** /**
* Ergebnis der Vorabprüfung. * Ergebnis der Vorabpruefung.
* *
* Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund, * Fehlende Bloecke stehen ganz oben und in Rot. Sie sind der einzige Befund,
* bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen * bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** gelingen
* kann — alles andere ist ein Hinweis. * kann — alles andere ist ein Hinweis.
*/ */
function ValidationSummary({ function ValidationSummary({
@ -388,8 +417,8 @@ function ValidationSummary({
if (isRunning || !validationResult) { if (isRunning || !validationResult) {
return ( return (
<div className="py-10 text-center text-sm text-fg-muted"> <div className="py-10 text-center text-sm text-fg-muted">
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder Die Vorabpruefung laeuft. Bei aktiver Blockpruefung wird jeder
benötigte Block gesucht — das dauert bei großen Beständen. benoetigte Block gesucht — das dauert bei grossen Bestaenden.
</div> </div>
); );
} }
@ -424,7 +453,7 @@ function ValidationSummary({
> >
{validationResult.can_proceed {validationResult.can_proceed
? 'Die Wiederherstellung kann beginnen' ? 'Die Wiederherstellung kann beginnen'
: 'Die Wiederherstellung ist nicht möglich'} : 'Die Wiederherstellung ist nicht moeglich'}
</p> </p>
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p> <p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
</div> </div>
@ -445,15 +474,15 @@ function ValidationSummary({
<div> <div>
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt> <dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
<dd className="tabular text-sm text-fg"> <dd className="tabular text-sm text-fg">
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil {/* -1 bedeutet unbekannt. Als 0 zu zeigen waere das Gegenteil
der Wahrheit — es sähe nach "voll" aus. */} der Wahrheit — es saehe nach "voll" aus. */}
{report.available_target_bytes < 0 {report.available_target_bytes < 0
? 'unbekannt' ? 'unbekannt'
: formatBytes(report.available_target_bytes)} : formatBytes(report.available_target_bytes)}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-fg-subtle">Dauer der Prüfung</dt> <dt className="text-xs text-fg-subtle">Dauer der Pruefung</dt>
<dd className="tabular text-sm text-fg"> <dd className="tabular text-sm text-fg">
{formatDuration(report.duration_seconds)} {formatDuration(report.duration_seconds)}
</dd> </dd>
@ -462,11 +491,11 @@ function ValidationSummary({
) : null} ) : null}
{report && report.missing_chunk_count > 0 ? ( {report && report.missing_chunk_count > 0 ? (
<Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}> <Callout tone="critical" title={`${report.missing_chunk_count} Bloecke fehlen`}>
Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten Von {report.unique_chunk_count.toLocaleString('de-DE')} benoetigten
Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht Bloecken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das auffindbar. Die Wiederherstellung waere unvollstaendig. Pruefen Sie das
Repository mit einem Integritätslauf. Repository mit einem Integritaetslauf.
</Callout> </Callout>
) : null} ) : null}

View File

@ -1,14 +1,14 @@
/** /**
* Wiederherstellungen — Übersicht und Bedienung. * Wiederherstellungen — Uebersicht und Bedienung.
* *
* Der Zustand `failed` mit vorhandenem Prüfpunkt ist der interessanteste Fall * Der Zustand `failed` mit vorhandenem Pruefpunkt ist der interessanteste Fall
* dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die * dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die
* Fortsetzung schreibt ab dem letzten vollständig abgelegten Pfad weiter. * Fortsetzung schreibt ab dem letzten vollstaendig abgelegten Pfad weiter.
* *
* Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein * Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein
* zweiter Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der * zweiter Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der
* erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die * erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die
* jemand auslöst — und die Seite sagt, was dabei geschieht. * jemand ausloest — und die Seite sagt, was dabei geschieht.
*/ */
import { Ban, PlayCircle, RefreshCw } from 'lucide-react'; import { Ban, PlayCircle, RefreshCw } from 'lucide-react';
@ -103,7 +103,7 @@ export function RestoresPage({
render: (session) => ( render: (session) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusBadge status={session.status} /> <StatusBadge status={session.status} />
{/* Ein Prüfpunkt bei gescheitertem Lauf heißt "unterbrochen", {/* Ein Pruefpunkt bei gescheitertem Lauf heisst "unterbrochen",
nicht "verloren". Das ist der Unterschied zwischen einer nicht "verloren". Das ist der Unterschied zwischen einer
Fortsetzung und einem Neubeginn. */} Fortsetzung und einem Neubeginn. */}
{session.status === 'failed' && session.checkpoint ? ( {session.status === 'failed' && session.checkpoint ? (
@ -130,7 +130,7 @@ export function RestoresPage({
{session.files_restored.toLocaleString('de-DE')} {session.files_restored.toLocaleString('de-DE')}
{session.files_skipped > 0 ? ( {session.files_skipped > 0 ? (
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
({session.files_skipped.toLocaleString('de-DE')} übergangen) ({session.files_skipped.toLocaleString('de-DE')} uebergangen)
</span> </span>
) : null} ) : null}
</span> </span>
@ -138,7 +138,7 @@ export function RestoresPage({
}, },
{ {
key: 'bytes_restored', key: 'bytes_restored',
header: 'Zurückgeschrieben', header: 'Zurueckgeschrieben',
numeric: true, numeric: true,
render: (session) => formatBytes(session.bytes_restored), render: (session) => formatBytes(session.bytes_restored),
}, },
@ -200,7 +200,7 @@ export function RestoresPage({
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '} Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '}
gestartet — dort steht, was gesichert wurde und ob es geprüft ist. gestartet — dort steht, was gesichert wurde und ob es geprueft ist.
</Callout> </Callout>
<Card> <Card>
@ -211,7 +211,7 @@ export function RestoresPage({
onRowClick={(session) => setSelectedSession(session)} onRowClick={(session) => setSelectedSession(session)}
isLoading={restoresResource.loadState === 'loading'} isLoading={restoresResource.loadState === 'loading'}
emptyTitle="Noch keine Wiederherstellung" emptyTitle="Noch keine Wiederherstellung"
emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen." emptyDescription="Fuehren Sie eine durch, bevor Sie eine brauchen. Erst ein durchgefuehrter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz."
/> />
</Card> </Card>
@ -236,7 +236,7 @@ export function RestoresPage({
> >
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<p className="text-fg"> <p className="text-fg">
Es wird ab dem letzten vollständig geschriebenen Pfad weitergearbeitet. Es wird ab dem letzten vollstaendig geschriebenen Pfad weitergearbeitet.
</p> </p>
{sessionPendingResume?.checkpoint?.last_completed_path ? ( {sessionPendingResume?.checkpoint?.last_completed_path ? (
<div className="rounded-md border border-line bg-sunken p-2.5"> <div className="rounded-md border border-line bg-sunken p-2.5">
@ -247,7 +247,10 @@ export function RestoresPage({
</div> </div>
) : null} ) : null}
<Callout tone="warning"> <Callout tone="warning">
Die beim Abbruch angefangene Datei wird ersetzt. Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst
entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
Lauf werden ersetzt — <strong>nur</strong> bei einer Fortsetzung,
nicht bei einem neuen Lauf.
</Callout> </Callout>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
@ -270,7 +273,7 @@ function RestoreDetailCard({
description={session.target_path} description={session.target_path}
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schließen Schliessen
</Button> </Button>
} }
/> />
@ -285,11 +288,11 @@ function RestoreDetailCard({
<DetailItem label="Dateien"> <DetailItem label="Dateien">
{session.files_restored.toLocaleString('de-DE')} {session.files_restored.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Übergangen"> <DetailItem label="Uebergangen">
{session.files_skipped.toLocaleString('de-DE')} {session.files_skipped.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem> <DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
<DetailItem label="Überschreiben erlaubt"> <DetailItem label="Ueberschreiben erlaubt">
{session.overwrite_existing ? 'ja' : 'nein'} {session.overwrite_existing ? 'ja' : 'nein'}
</DetailItem> </DetailItem>
</dl> </dl>
@ -303,15 +306,15 @@ function RestoreDetailCard({
{session.checkpoint ? ( {session.checkpoint ? (
<div className="mt-4 rounded-md border border-line bg-sunken p-3"> <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"> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Prüfpunkt Pruefpunkt
</p> </p>
<p className="mt-1 break-all font-mono text-xs text-fg"> <p className="mt-1 break-all font-mono text-xs text-fg">
{session.checkpoint.last_completed_path ?? '—'} {session.checkpoint.last_completed_path ?? '—'}
</p> </p>
<p className="mt-1 text-xs text-fg-muted"> <p className="mt-1 text-xs text-fg-muted">
Er entsteht erst, <strong>nachdem</strong> eine Datei vollständig Er entsteht erst, <strong>nachdem</strong> eine Datei vollstaendig
und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei
wäre schlimmer als keiner. waere schlimmer als keiner.
</p> </p>
</div> </div>
) : null} ) : null}

View File

@ -1,82 +0,0 @@
/**
* Blättern im Dateisystem des Servers und im Inhalt eines Backups.
*
* Beides gehört zur Wiederherstellung: Wohin darf zurückgeschrieben werden, und
* was steckt überhaupt in dem Backup? Ein Textfeld beantwortet weder das eine
* noch das andere.
*/
import { requestApi } from '../../api/client';
/** Ein Verzeichnis auf dem Server. */
export interface FilesystemEntry {
name: string;
path: string;
is_directory: boolean;
/**
* Meldet, ob der **Dienst** hier anlegen darf.
*
* Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: Der
* Dienst läuft mit `ProtectSystem=strict`, und davon steht nichts im Modus.
*/
is_writable: boolean;
/** Grund, wenn der Zielschutz den Ort ausschließt. */
forbidden_reason?: string;
}
/** Antwort auf das Blättern im Dateisystem. */
export interface BrowseFilesystemResponse {
path: string;
parent_path?: string;
entries: FilesystemEntry[];
/** Orte, an denen der Dienst nachweislich schreiben darf. */
suggested_paths?: string[];
}
/** Ein Eintrag im Inhaltsverzeichnis eines Backups. */
export interface BackupContentEntry {
name: string;
/** Genau der Wert, den eine Wiederherstellung als `path_prefix` erwartet. */
path: string;
is_directory: boolean;
entry_type: string;
size_bytes?: number;
modified_at?: string;
mode?: string;
/** Zahl der Einträge unterhalb eines Verzeichnisses. */
child_count?: number;
/** Datenmenge unterhalb eines Verzeichnisses. */
total_bytes?: number;
}
/** Antwort auf das Blättern im Backup. */
export interface BrowseBackupResponse {
backup_id: string;
path: string;
parent_path?: string;
entries: BackupContentEntry[];
total_entry_count: number;
}
/** Listet die Verzeichnisse unterhalb eines Pfades. */
export async function browseFilesystem(
directoryPath: string,
abortSignal?: AbortSignal,
): Promise<BrowseFilesystemResponse> {
return requestApi<BrowseFilesystemResponse>(
`/filesystem/browse?path=${encodeURIComponent(directoryPath)}`,
abortSignal ? { signal: abortSignal } : {},
);
}
/** Listet den Inhalt eines Backups auf einer Ebene. */
export async function browseBackupContents(
backupIdentifier: string,
contentPath: string,
abortSignal?: AbortSignal,
): Promise<BrowseBackupResponse> {
return requestApi<BrowseBackupResponse>(
`/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`,
abortSignal ? { signal: abortSignal } : {},
);
}

View File

@ -1,22 +1,22 @@
/** /**
* API-Anbindung der Wiederherstellung (SYNCOVA_API.md §12). * API-Anbindung der Wiederherstellung (SYNCOVA_API.md §12).
* *
* Die Vorabprüfung ist der Kern, nicht das Zurückschreiben: `validate` * Die Vorabpruefung ist der Kern, nicht das Zurueckschreiben: `validate`
* schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* — * schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* —
* insbesondere, ob **jeder benötigte Block noch da ist**. Ein Manifest allein * insbesondere, ob **jeder benoetigte Block noch da ist**. Ein Manifest allein
* belegt nur, dass jemand einmal etwas gesichert hat. * belegt nur, dass jemand einmal etwas gesichert hat.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
/** Schweregrad eines Befundes der Vorabprüfung. */ /** Schweregrad eines Befundes der Vorabpruefung. */
export type ValidationSeverity = 'blocking' | 'warning' | 'info'; export type ValidationSeverity = 'blocking' | 'warning' | 'info';
/** Einzelner Befund der Vorabprüfung. */ /** Einzelner Befund der Vorabpruefung. */
export interface ValidationFinding { export interface ValidationFinding {
/** Maschinenlesbarer Code. */ /** Maschinenlesbarer Code. */
code: string; code: string;
/** Schweregrad; nur `blocking` verhindert die Ausführung. */ /** Schweregrad; nur `blocking` verhindert die Ausfuehrung. */
severity: ValidationSeverity; severity: ValidationSeverity;
/** Meldung im Klartext. */ /** Meldung im Klartext. */
message: string; message: string;
@ -24,7 +24,7 @@ export interface ValidationFinding {
detail?: string; detail?: string;
} }
/** Bericht der Vorabprüfung. */ /** Bericht der Vorabpruefung. */
export interface ValidationReport { export interface ValidationReport {
backup_id: string; backup_id: string;
target_path: string; target_path: string;
@ -33,11 +33,11 @@ export interface ValidationReport {
entry_count: number; entry_count: number;
/** Zahl wiederherzustellender Dateien. */ /** Zahl wiederherzustellender Dateien. */
file_count: number; file_count: number;
/** Zurückzuschreibende Datenmenge. */ /** Zurueckzuschreibende Datenmenge. */
total_bytes: number; total_bytes: number;
/** Zahl benötigter Blöcke. */ /** Zahl benoetigter Bloecke. */
unique_chunk_count: number; unique_chunk_count: number;
/** Zahl fehlender Blöcke — jeder einzelne verhindert die Wiederherstellung. */ /** Zahl fehlender Bloecke — jeder einzelne verhindert die Wiederherstellung. */
missing_chunk_count: number; missing_chunk_count: number;
/** Freier Platz am Ziel; -1 bedeutet unbekannt. */ /** Freier Platz am Ziel; -1 bedeutet unbekannt. */
available_target_bytes: number; available_target_bytes: number;
@ -45,20 +45,20 @@ export interface ValidationReport {
duration_seconds: number; duration_seconds: number;
} }
/** Antwort der Vorabprüfung. */ /** Antwort der Vorabpruefung. */
export interface ValidationResponse { export interface ValidationResponse {
/** Darf die Wiederherstellung beginnen? */ /** Darf die Wiederherstellung beginnen? */
can_proceed: boolean; can_proceed: boolean;
/** Verlangt das Ziel eine wörtliche Bestätigung? */ /** Verlangt das Ziel eine woertliche Bestaetigung? */
requires_overwrite_confirmation: boolean; requires_overwrite_confirmation: boolean;
/** Zusammenfassung in einem Satz. */ /** Zusammenfassung in einem Satz. */
summary: string; summary: string;
report: ValidationReport | null; report: ValidationReport | null;
} }
/** Prüfpunkt einer unterbrochenen Wiederherstellung. */ /** Pruefpunkt einer unterbrochenen Wiederherstellung. */
export interface RestoreCheckpoint { export interface RestoreCheckpoint {
/** Zuletzt vollständig geschriebener Pfad. */ /** Zuletzt vollstaendig geschriebener Pfad. */
last_completed_path?: string; last_completed_path?: string;
files_restored?: number; files_restored?: number;
bytes_restored?: number; bytes_restored?: number;
@ -95,31 +95,31 @@ export interface CreateRestoreRequest {
target_type: string; target_type: string;
/** Zielverzeichnis. */ /** Zielverzeichnis. */
target_path: string; target_path: string;
/** Beschränkung auf einen Teilbaum des Manifests. */ /** Beschraenkung auf einen Teilbaum des Manifests. */
path_prefix?: string; path_prefix?: string;
/** Erste Hürde: das Kennzeichen. */ /** Erste Huerde: das Kennzeichen. */
overwrite_existing?: boolean; overwrite_existing?: boolean;
/** /**
* Dritte Hürde: der Zielpfad, wörtlich wiederholt. * Dritte Huerde: der Zielpfad, woertlich wiederholt.
* *
* Die zweite Hürde ist die Berechtigung `restores.overwrite` und liegt beim * Die zweite Huerde ist die Berechtigung `restores.overwrite` und liegt beim
* Server. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht * Server. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht
* damit nicht aus. * damit nicht aus.
*/ */
confirm_overwrite?: string; confirm_overwrite?: string;
/** Rechte nicht mit zurückschreiben. */ /** Rechte nicht mit zurueckschreiben. */
skip_permissions?: boolean; skip_permissions?: boolean;
/** /**
* Blockprüfung überspringen. * Blockpruefung ueberspringen.
* *
* Beschleunigt die Vorabprüfung erheblich und **senkt ihre Aussagekraft**: * Beschleunigt die Vorabpruefung erheblich und **senkt ihre Aussagekraft**:
* Ohne sie ist nicht belegt, dass jeder benötigte Block noch da ist. Der * Ohne sie ist nicht belegt, dass jeder benoetigte Block noch da ist. Der
* Bericht weist das als Hinweis aus. * Bericht weist das als Hinweis aus.
*/ */
skip_deep_check?: boolean; skip_deep_check?: boolean;
} }
/** Führt die Vorabprüfung aus. Sie schreibt nichts. */ /** Fuehrt die Vorabpruefung aus. Sie schreibt nichts. */
export async function validateRestore( export async function validateRestore(
validationRequest: CreateRestoreRequest, validationRequest: CreateRestoreRequest,
): Promise<ValidationResponse> { ): Promise<ValidationResponse> {
@ -140,7 +140,7 @@ export async function createRestore(
}); });
} }
/** Lädt die Wiederherstellungen. */ /** Laedt die Wiederherstellungen. */
export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSession[]> { export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSession[]> {
return requestApi<RestoreSession[]>( return requestApi<RestoreSession[]>(
'/restores?page_size=50', '/restores?page_size=50',
@ -148,7 +148,7 @@ export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSe
); );
} }
/** Lädt eine einzelne Wiederherstellung. */ /** Laedt eine einzelne Wiederherstellung. */
export async function getRestore( export async function getRestore(
restoreIdentifier: string, restoreIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -171,7 +171,7 @@ export async function cancelRestore(restoreIdentifier: string): Promise<RestoreS
* Setzt eine unterbrochene Wiederherstellung fort. * Setzt eine unterbrochene Wiederherstellung fort.
* *
* Es gibt bewusst **keinen** automatischen Wiederholungsversuch: Ein zweiter * Es gibt bewusst **keinen** automatischen Wiederholungsversuch: Ein zweiter
* Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der erste * Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der erste
* bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung. * bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung.
*/ */
export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> { export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> {

View File

@ -1,12 +1,12 @@
/** /**
* Security Center. * Security Center.
* *
* Die Seite, die in Phase 12 als „noch nicht verfügbar" stand — mit dem * Die Seite, die in Phase 12 als „noch nicht verfuegbar" stand — mit dem
* Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt * Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt
* gibt es beides. * gibt es beides.
* *
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt * Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt
* Schweregrad, Erklärung, betroffenes Objekt und Empfehlung. Die Empfehlung ist * Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung. * der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
*/ */
@ -34,29 +34,29 @@ type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
/** Ein Sicherheitsbefund. */ /** Ein Sicherheitsbefund. */
interface SecurityFinding { interface SecurityFinding {
/** Prüfbereich. */ /** Pruefbereich. */
readonly area: string; readonly area: string;
/** Schweregrad. */ /** Schweregrad. */
readonly severity: FindingSeverity; readonly severity: FindingSeverity;
/** Überschrift. */ /** Ueberschrift. */
readonly title: string; readonly title: string;
/** Erklärung, warum der Zustand ein Problem ist. */ /** Erklaerung, warum der Zustand ein Problem ist. */
readonly explanation: string; readonly explanation: string;
/** Das betroffene Objekt. */ /** Das betroffene Objekt. */
readonly affected_object: string; readonly affected_object: string;
/** Die nächste Handlung. */ /** Die naechste Handlung. */
readonly recommendation: string; readonly recommendation: string;
} }
/** Ergebnis eines Prüfbereichs. */ /** Ergebnis eines Pruefbereichs. */
interface AreaResult { interface AreaResult {
/** Bezeichner. */ /** Bezeichner. */
readonly area: string; readonly area: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Meldet, ob der Bereich geprüft werden konnte. */ /** Meldet, ob der Bereich geprueft werden konnte. */
readonly available: boolean; readonly available: boolean;
/** Erklärt einen nicht prüfbaren Bereich. */ /** Erklaert einen nicht pruefbaren Bereich. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
/** Befunde des Bereichs. */ /** Befunde des Bereichs. */
readonly findings: readonly SecurityFinding[]; readonly findings: readonly SecurityFinding[];
@ -72,13 +72,13 @@ interface AreaResult {
interface SecurityAssessment { interface SecurityAssessment {
/** Gesamtbewertung in Prozent. */ /** Gesamtbewertung in Prozent. */
readonly score: number; readonly score: number;
/** Erreichbare Punkte der geprüften Bereiche. */ /** Erreichbare Punkte der geprueften Bereiche. */
readonly maximum_score: number; readonly maximum_score: number;
/** Einstufung. */ /** Einstufung. */
readonly grade: string; readonly grade: string;
/** Ergebnisse je Bereich. */ /** Ergebnisse je Bereich. */
readonly areas: readonly AreaResult[]; readonly areas: readonly AreaResult[];
/** Zahl nicht prüfbarer Bereiche. */ /** Zahl nicht pruefbarer Bereiche. */
readonly unchecked_area_count: number; readonly unchecked_area_count: number;
/** Zahl kritischer Befunde. */ /** Zahl kritischer Befunde. */
readonly critical_finding_count: number; readonly critical_finding_count: number;
@ -86,7 +86,7 @@ interface SecurityAssessment {
readonly high_finding_count: number; readonly high_finding_count: number;
/** Zusammenfassung. */ /** Zusammenfassung. */
readonly summary: string; readonly summary: string;
/** Nicht geprüfte Bereiche. */ /** Nicht gepruefte Bereiche. */
readonly unchecked_areas: readonly string[]; readonly unchecked_areas: readonly string[];
} }
@ -111,7 +111,7 @@ export function SecurityPage(): React.JSX.Element {
<> <>
<PageHeader <PageHeader
title="Security Center" title="Security Center"
description="Was geprüft wurde — und was nicht." description="Was geprueft wurde — und was nicht."
/> />
{loadState === 'loading' ? ( {loadState === 'loading' ? (
@ -144,12 +144,12 @@ export function SecurityPage(): React.JSX.Element {
/** /**
* Zeigt die Gesamtbewertung. * Zeigt die Gesamtbewertung.
* *
* Neben der Prozentzahl steht immer die erreichbare Höchstpunktzahl und die * Neben der Prozentzahl steht immer die erreichbare Hoechstpunktzahl und die
* Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in 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 * Rechnung ein — ihn als bestanden zu werten waere Schoenfaerberei, als
* durchgefallen eine Behauptung. * durchgefallen eine Behauptung.
* *
* Ein kritischer Befund deckelt die Einstufung unabhängig von der Zahl: 88 von * Ein kritischer Befund deckelt die Einstufung unabhaengig von der Zahl: 88 von
* 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend". * 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend".
*/ */
function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element { function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element {
@ -181,15 +181,15 @@ function ScoreHeader({ response }: { readonly response: SecurityResponse }): Rea
<Callout tone="critical"> <Callout tone="critical">
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund {criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf {criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
„unzureichend" — unabhängig von der Punktzahl. „unzureichend" — unabhaengig von der Punktzahl.
</Callout> </Callout>
) : null} ) : null}
{/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die {/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschränkung. */} Handlungsanweisung, nicht nur eine Einschraenkung. */}
{assessment.unchecked_areas.length > 0 ? ( {assessment.unchecked_areas.length > 0 ? (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
Nicht geprüft: {assessment.unchecked_areas.join(', ')} Nicht geprueft: {assessment.unchecked_areas.join(', ')}
{assessment.unchecked_areas.length >= 3 {assessment.unchecked_areas.length >= 3
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.' ? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
: ''} : ''}
@ -204,9 +204,9 @@ function ScoreHeader({ response }: { readonly response: SecurityResponse }): Rea
/** /**
* Zeigt die Befunde nach Schweregrad geordnet. * Zeigt die Befunde nach Schweregrad geordnet.
* *
* Jeder Befund trägt Schweregrad, Erklärung, betroffenes Objekt und * Jeder Befund traegt Schweregrad, Erklaerung, betroffenes Objekt und
* Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass * Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass
* etwas nicht stimmt, und lässt den Betreiber damit allein. * etwas nicht stimmt, und laesst den Betreiber damit allein.
*/ */
function FindingList({ function FindingList({
assessment, assessment,
@ -224,8 +224,8 @@ function FindingList({
return ( return (
<Card> <Card>
<EmptyState <EmptyState
title="Keine Befunde in den geprüften Bereichen" title="Keine Befunde in den geprueften Bereichen"
description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben." description="Das gilt ausschliesslich fuer die geprueften Bereiche — die ungeprueften stehen oben."
/> />
</Card> </Card>
); );
@ -257,10 +257,10 @@ function FindingList({
</div> </div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum {/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
der Befund überhaupt angezeigt wird. */} der Befund ueberhaupt angezeigt wird. */}
<div className="flex gap-2"> <div className="flex gap-2">
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle"> <dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
Nächster Schritt Naechster Schritt
</dt> </dt>
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd> <dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
</div> </div>
@ -273,7 +273,7 @@ function FindingList({
); );
} }
/** Zeigt die Ergebnisse je Prüfbereich. */ /** Zeigt die Ergebnisse je Pruefbereich. */
function AreaTable({ function AreaTable({
assessment, assessment,
}: { }: {
@ -293,7 +293,7 @@ function AreaTable({
<span className="text-sm text-fg">{areaResult.summary}</span> <span className="text-sm text-fg">{areaResult.summary}</span>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<StatusBadge status="neutral" label="nicht prüfbar" /> <StatusBadge status="neutral" label="nicht pruefbar" />
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span> <span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
</div> </div>
), ),
@ -308,7 +308,7 @@ function AreaTable({
) : ( ) : (
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder // Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
// positiv noch negativ. // positiv noch negativ.
<span className="text-xs text-fg-subtle">zählt nicht</span> <span className="text-xs text-fg-subtle">zaehlt nicht</span>
), ),
}, },
]; ];
@ -316,7 +316,7 @@ function AreaTable({
return ( return (
<Card> <Card>
<CardHeader <CardHeader
title="Geprüft wurde" title="Geprueft wurde"
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`} description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
/> />
<DataTable <DataTable

View File

@ -1,27 +1,27 @@
/** Kleine Helfer, die quer durch die Oberfläche gebraucht werden. */ /** Kleine Helfer, die quer durch die Oberflaeche gebraucht werden. */
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
/** /**
* Führt Klassennamen zusammen und löst Tailwind-Konflikte auf. * Fuehrt Klassennamen zusammen und loest Tailwind-Konflikte auf.
* *
* Ohne twMerge gewänne bei "p-2 p-4" die Reihenfolge im Stylesheet, nicht die * Ohne twMerge gewaenne 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, * im Aufruf — eine Komponente liesse sich dann von aussen nicht mehr anpassen,
* und das fällt erst auf, wenn jemand es versucht. * und das faellt erst auf, wenn jemand es versucht.
*/ */
export function cn(...classValues: ClassValue[]): string { export function cn(...classValues: ClassValue[]): string {
return twMerge(clsx(classValues)); return twMerge(clsx(classValues));
} }
/** /**
* Formatiert eine Byte-Zahl für die Anzeige. * Formatiert eine Byte-Zahl fuer die Anzeige.
* *
* Bewusst mit binären Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB * Bewusst mit binaeren Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB
* vermischt, erzeugt Rückfragen bei jeder Kapazitätsplanung. * vermischt, erzeugt Rueckfragen bei jeder Kapazitaetsplanung.
*/ */
export function formatBytes(byteCount: number | null | undefined): string { export function formatBytes(byteCount: number | null | undefined): string {
// Unbekannt ist nicht null. Eine 0 läse sich wie "nichts abgelegt" — // Unbekannt ist nicht null. Eine 0 laese sich wie "nichts abgelegt" —
// derselbe Grundsatz wie bei den Kennzahlen aus Phase 13. // derselbe Grundsatz wie bei den Kennzahlen aus Phase 13.
if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) { if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) {
return '—'; return '—';
@ -38,7 +38,7 @@ export function formatBytes(byteCount: number | null | undefined): string {
); );
const scaledValue = byteCount / Math.pow(1024, unitIndex); const scaledValue = byteCount / Math.pow(1024, unitIndex);
// Unter 10 eine Nachkommastelle, darüber keine: "1,4 GiB" ist nützlich, // Unter 10 eine Nachkommastelle, darueber keine: "1,4 GiB" ist nuetzlich,
// "847,3 GiB" tut nur so genau. // "847,3 GiB" tut nur so genau.
const decimalPlaces = unitIndex === 0 ? 0 : scaledValue < 10 ? 1 : 0; const decimalPlaces = unitIndex === 0 ? 0 : scaledValue < 10 ? 1 : 0;
@ -72,7 +72,7 @@ export function formatDateTime(isoTimestamp: string | null | undefined): string
/** /**
* Formatiert einen Zeitpunkt als Abstand zu jetzt ("vor 3 Stunden"). * Formatiert einen Zeitpunkt als Abstand zu jetzt ("vor 3 Stunden").
* *
* Für eine Betriebskonsole ist der Abstand oft die wichtigere Angabe: Ob die * Fuer 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, * letzte Sicherung um 02:15 lief, sagt wenig — dass sie vor drei Tagen lief,
* sagt alles. * sagt alles.
*/ */
@ -97,8 +97,8 @@ export function formatRelativeTime(isoTimestamp: string | null | undefined): str
return isInFuture ? 'gleich' : 'gerade eben'; return isInFuture ? 'gleich' : 'gerade eben';
} }
// Schwelle, Teiler und Einheit gehören zusammen und stehen deshalb in einem // Schwelle, Teiler und Einheit gehoeren zusammen und stehen deshalb in einem
// Eintrag. Drei parallele Arrays wären die Sorte Code, bei der ein // Eintrag. Drei parallele Arrays waeren die Sorte Code, bei der ein
// eingeschobener Eintrag still die Zuordnung verschiebt. // eingeschobener Eintrag still die Zuordnung verschiebt.
const timeScales = [ const timeScales = [
{ limit: 3600, divisor: 60, unit: 'minute' }, { limit: 3600, divisor: 60, unit: 'minute' },

View File

@ -1,4 +1,4 @@
/** Einstiegspunkt der Syncova-Oberfläche. */ /** Einstiegspunkt der Syncova-Oberflaeche. */
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@ -8,7 +8,7 @@ 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 würde nur eine leere Seite hinterlassen. // Ein stiller Abbruch wuerde nur eine leere Seite hinterlassen.
if (!rootElement) { if (!rootElement) {
throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.'); throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.');
} }

View File

@ -1,10 +1,10 @@
/** /**
* Seite für einen Bereich ohne Bedienoberfläche. * Seite fuer einen Bereich ohne Bedienoberflaeche.
* *
* Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft: * Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft:
* Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die * Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die
* nur „in Arbeit" sagt, lässt den Bediener ratlos zurück — und eine, die eine * nur „in Arbeit" sagt, laesst den Bediener ratlos zurueck — und eine, die eine
* leere Tabelle zeigt, lässt ihn glauben, es gäbe nichts zu sehen. * leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen.
*/ */
import { Construction } from 'lucide-react'; import { Construction } from 'lucide-react';
@ -39,7 +39,7 @@ export function UnavailablePage({
<p className="mt-5 text-xs text-fg-muted"> <p className="mt-5 text-xs text-fg-muted">
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten
gefüllt. Eine Maske mit erfundenen Zahlen ließe sich im Betrieb gefuellt. Eine Maske mit erfundenen Zahlen liesse sich im Betrieb
nicht von einer echten unterscheiden. nicht von einer echten unterscheiden.
</p> </p>
</div> </div>

View File

@ -1,17 +1,17 @@
/** /**
* Tests des Seitenverzeichnisses. * Tests des Seitenverzeichnisses.
* *
* Sie prüfen weniger die Technik als eine Zusage: Jede Seite aus dem * Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem
* Implementierungsplan erscheint, und jede unfertige erklärt sich. * Implementierungsplan erscheint, und jede unfertige erklaert sich.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ALL_PAGES, findPage, mayViewPage } from './pages'; import { ALL_PAGES, findPage, mayViewPage } from './pages';
describe('Seitenverzeichnis', () => { describe('Seitenverzeichnis', () => {
it('nennt zu jeder nicht verfügbaren Seite einen Grund', () => { it('nennt zu jeder nicht verfuegbaren Seite einen Grund', () => {
// Ein Bereich, der nur „nicht verfügbar" sagt, lässt den Bediener ratlos // Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos
// zurück. Der Grund ist der eigentliche Inhalt dieser Seiten. // zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten.
for (const pageDefinition of ALL_PAGES) { for (const pageDefinition of ALL_PAGES) {
if (!pageDefinition.available) { if (!pageDefinition.available) {
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy(); expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy();
@ -20,7 +20,7 @@ describe('Seitenverzeichnis', () => {
} }
}); });
it('gibt keiner verfügbaren Seite einen Nichtverfügbarkeitsgrund', () => { it('gibt keiner verfuegbaren Seite einen Nichtverfuegbarkeitsgrund', () => {
for (const pageDefinition of ALL_PAGES) { for (const pageDefinition of ALL_PAGES) {
if (pageDefinition.available) { if (pageDefinition.available) {
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined(); expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
@ -35,7 +35,7 @@ describe('Seitenverzeichnis', () => {
}); });
it('deckt die im Implementierungsplan genannten Bereiche ab', () => { it('deckt die im Implementierungsplan genannten Bereiche ab', () => {
// Der Plan (§14) nennt fünfzehn Seiten. Fehlt eine davon im Verzeichnis, // Der Plan (§14) nennt fuenfzehn Seiten. Fehlt eine davon im Verzeichnis,
// ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht // ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht
// es. // es.
const expectedIdentifiers = [ const expectedIdentifiers = [
@ -61,7 +61,7 @@ describe('Seitenverzeichnis', () => {
} }
}); });
it('verbirgt Seiten ohne die nötige Berechtigung', () => { it('verbirgt Seiten ohne die noetige Berechtigung', () => {
const usersPage = findPage('users'); const usersPage = findPage('users');
expect(usersPage).toBeDefined(); expect(usersPage).toBeDefined();

View File

@ -1,13 +1,13 @@
/** /**
* Seitenverzeichnis der Oberfläche. * Seitenverzeichnis der Oberflaeche.
* *
* Der Grundsatz aus PROMPT.md §139 gilt unverändert: Ein Menü, das nur die * Der Grundsatz aus PROMPT.md §139 gilt unveraendert: Ein Menue, das nur die
* fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Einträge * fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Eintraege
* in leere Masken führen, täuscht ihn vor. Jeder Eintrag erscheint, und ein * in leere Masken fuehren, taeuscht ihn vor. Jeder Eintrag erscheint, und ein
* noch nicht verfügbarer nennt, was fehlt und was stattdessen gilt. * noch nicht verfuegbarer nennt, was fehlt und was stattdessen gilt.
* *
* Die Gliederung folgt dem Weg durch die Anlage, nicht der Reihenfolge der * Die Gliederung folgt dem Weg durch die Anlage, nicht der Reihenfolge der
* Umsetzung: Was täglich beobachtet wird, steht oben; was einmal eingerichtet * Umsetzung: Was taeglich beobachtet wird, steht oben; was einmal eingerichtet
* und dann selten angefasst wird, unten. * und dann selten angefasst wird, unten.
*/ */
@ -50,28 +50,28 @@ export const SECTION_LABELS: ReadonlyArray<readonly [NavigationSection, string]>
['verwaltung', 'Verwaltung'], ['verwaltung', 'Verwaltung'],
]; ];
/** Beschreibung einer Seite der Oberfläche. */ /** Beschreibung einer Seite der Oberflaeche. */
export interface PageDefinition { export interface PageDefinition {
/** Stabiler Bezeichner; er steht auch im Pfad. */ /** Stabiler Bezeichner; er steht auch im Pfad. */
readonly id: string; readonly id: string;
/** Beschriftung im Menü. */ /** Beschriftung im Menue. */
readonly label: string; readonly label: string;
/** Bereich, unter dem die Seite erscheint. */ /** Bereich, unter dem die Seite erscheint. */
readonly section: NavigationSection; readonly section: NavigationSection;
/** Sinnbild im Menü. */ /** Sinnbild im Menue. */
readonly icon: LucideIcon; readonly icon: LucideIcon;
/** /**
* Berechtigung, die der Zugriff verlangt. * Berechtigung, die der Zugriff verlangt.
* *
* Rein zur Anzeige: Die Prüfung erfolgt immer serverseitig (PROMPT.md §42). * Rein zur Anzeige: Die Pruefung erfolgt immer serverseitig (PROMPT.md §42).
* Ein Menüpunkt, den der Server ohnehin ablehnt, führt sonst nur zu einer * Ein Menuepunkt, den der Server ohnehin ablehnt, fuehrt sonst nur zu einer
* Fehlermeldung statt zu einer verständlichen Oberfläche. * Fehlermeldung statt zu einer verstaendlichen Oberflaeche.
*/ */
readonly requiredPermission: string | null; readonly requiredPermission: string | null;
/** Meldet, ob hinter der Seite ein Backend steht. */ /** Meldet, ob hinter der Seite ein Backend steht. */
readonly available: boolean; readonly available: boolean;
/** /**
* Erklärt eine fehlende Seite. * Erklaert eine fehlende Seite.
* *
* Nicht „in Arbeit", sondern: welche Funktion fehlt und wie man heute an die * Nicht „in Arbeit", sondern: welche Funktion fehlt und wie man heute an die
* Auskunft kommt. * Auskunft kommt.
@ -81,10 +81,10 @@ export interface PageDefinition {
/** Alle Seiten in Anzeigereihenfolge. */ /** Alle Seiten in Anzeigereihenfolge. */
export const ALL_PAGES: readonly PageDefinition[] = [ export const ALL_PAGES: readonly PageDefinition[] = [
// --- Betrieb: was täglich beobachtet und bedient wird --- // --- Betrieb: was taeglich beobachtet und bedient wird ---
{ {
id: 'dashboard', id: 'dashboard',
label: 'Übersicht', label: 'Uebersicht',
section: 'betrieb', section: 'betrieb',
icon: LayoutDashboard, icon: LayoutDashboard,
requiredPermission: 'backups.read', requiredPermission: 'backups.read',
@ -92,7 +92,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
}, },
{ {
id: 'jobs', id: 'jobs',
label: 'Sicherungsaufträge', label: 'Sicherungsauftraege',
section: 'betrieb', section: 'betrieb',
icon: Clock, icon: Clock,
requiredPermission: 'jobs.read', requiredPermission: 'jobs.read',
@ -126,7 +126,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
}, },
{ {
id: 'verification', id: 'verification',
label: 'Prüfung', label: 'Pruefung',
section: 'daten', section: 'daten',
icon: BadgeCheck, icon: BadgeCheck,
requiredPermission: 'verification.read', requiredPermission: 'verification.read',
@ -168,7 +168,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
}, },
{ {
id: 'protected-systems', id: 'protected-systems',
label: 'Geschützte Systeme', label: 'Geschuetzte Systeme',
section: 'infrastruktur', section: 'infrastruktur',
icon: Activity, icon: Activity,
requiredPermission: 'providers.read', requiredPermission: 'providers.read',
@ -247,8 +247,8 @@ export function findPage(pageIdentifier: string): PageDefinition | undefined {
/** /**
* Meldet, ob ein Benutzer eine Seite sehen darf. * Meldet, ob ein Benutzer eine Seite sehen darf.
* *
* Ausschließlich zur Anzeige — sie verhindert Sackgassen im Menü. Die * Ausschliesslich zur Anzeige — sie verhindert Sackgassen im Menue. Die
* verbindliche Prüfung findet auf dem Server statt (PROMPT.md §42). * verbindliche Pruefung findet auf dem Server statt (PROMPT.md §42).
*/ */
export function mayViewPage( export function mayViewPage(
page: PageDefinition, page: PageDefinition,
@ -258,7 +258,7 @@ export function mayViewPage(
return true; return true;
} }
// Der Super-Administrator trägt einen Platzhalter statt aller Einzelrechte. // Der Super-Administrator traegt einen Platzhalter statt aller Einzelrechte.
if (grantedPermissions.includes('*')) { if (grantedPermissions.includes('*')) {
return true; return true;
} }

View File

@ -3,29 +3,29 @@
* *
* Warum keine? Eine Anwendung mit einer Ebene flacher Seiten braucht kein * Warum keine? Eine Anwendung mit einer Ebene flacher Seiten braucht kein
* Routing-Framework; sie braucht Adressen, die man kopieren kann, und einen * Routing-Framework; sie braucht Adressen, die man kopieren kann, und einen
* funktionierenden Zurück-Knopf. Beides leistet die History-API in etwa fünfzig * funktionierenden Zurueck-Knopf. Beides leistet die History-API in etwa fuenfzig
* Zeilen. Eine Abhängigkeit, die ein Vielfaches an Code mitbringt, wäre hier * Zeilen. Eine Abhaengigkeit, die ein Vielfaches an Code mitbringt, waere hier
* Aufwand ohne Nutzen — und müsste dauerhaft gepflegt werden. * Aufwand ohne Nutzen — und muesste dauerhaft gepflegt werden.
* *
* Sobald verschachtelte Routen mit eigenen Unterseiten entstehen, kehrt sich die * Sobald verschachtelte Routen mit eigenen Unterseiten entstehen, kehrt sich die
* Rechnung um. Dann ist der Wechsel fällig, und diese Datei ist der Ort dafür. * Rechnung um. Dann ist der Wechsel faellig, und diese Datei ist der Ort dafuer.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { DEFAULT_PAGE_ID, findPage } from './pages'; import { DEFAULT_PAGE_ID, findPage } from './pages';
/** Präfix aller Seitenpfade. */ /** Praefix aller Seitenpfade. */
const PAGE_PATH_PREFIX = '/'; const PAGE_PATH_PREFIX = '/';
/** Liest den Seitenbezeichner aus der aktuellen Adresse. */ /** Liest den Seitenbezeichner aus der aktuellen Adresse. */
function readPageIdentifierFromLocation(): string { function readPageIdentifierFromLocation(): string {
// noUncheckedIndexedAccess: Der erste Eintrag eines split-Ergebnisses ist für // noUncheckedIndexedAccess: Der erste Eintrag eines split-Ergebnisses ist fuer
// TypeScript möglicherweise undefined, auch wenn er es nie ist. // TypeScript moeglicherweise undefined, auch wenn er es nie ist.
const pathSegment = window.location.pathname.replace(PAGE_PATH_PREFIX, '').split('/')[0] ?? ''; const pathSegment = window.location.pathname.replace(PAGE_PATH_PREFIX, '').split('/')[0] ?? '';
if (pathSegment === '' || findPage(pathSegment) === undefined) { if (pathSegment === '' || findPage(pathSegment) === undefined) {
// Eine unbekannte Adresse führt zur Startseite statt zu einer leeren // Eine unbekannte Adresse fuehrt zur Startseite statt zu einer leeren
// Ansicht. Ein Tippfehler im Pfad ist kein Grund für eine kaputte Seite. // Ansicht. Ein Tippfehler im Pfad ist kein Grund fuer eine kaputte Seite.
return DEFAULT_PAGE_ID; return DEFAULT_PAGE_ID;
} }
@ -43,15 +43,15 @@ export interface CurrentPageNavigation {
/** /**
* Verbindet den angezeigten Bereich mit der Adresszeile. * Verbindet den angezeigten Bereich mit der Adresszeile.
* *
* Der Zustand ist die Quelle der Wahrheit für die Anzeige, die Adresse deren * Der Zustand ist die Quelle der Wahrheit fuer die Anzeige, die Adresse deren
* Abbild. Ohne diese Anbindung ginge beim Neuladen die Seite verloren, und ein * Abbild. Ohne diese Anbindung ginge beim Neuladen die Seite verloren, und ein
* Link ließe sich nicht weitergeben. * Link liesse sich nicht weitergeben.
*/ */
export function useCurrentPage(): CurrentPageNavigation { export function useCurrentPage(): CurrentPageNavigation {
const [currentPageId, setCurrentPageId] = useState<string>(readPageIdentifierFromLocation); const [currentPageId, setCurrentPageId] = useState<string>(readPageIdentifierFromLocation);
useEffect(() => { useEffect(() => {
/** Übernimmt eine Navigation über den Zurück-Knopf des Browsers. */ /** Uebernimmt eine Navigation ueber den Zurueck-Knopf des Browsers. */
function handlePopState(): void { function handlePopState(): void {
setCurrentPageId(readPageIdentifierFromLocation()); setCurrentPageId(readPageIdentifierFromLocation());
} }
@ -66,8 +66,8 @@ export function useCurrentPage(): CurrentPageNavigation {
return; return;
} }
// pushState statt einer Zuweisung an location: Ein vollständiges Neuladen // pushState statt einer Zuweisung an location: Ein vollstaendiges Neuladen
// würfe die Sitzung im Speicher weg und ließe die Anwendung neu anlaufen. // wuerfe die Sitzung im Speicher weg und liesse die Anwendung neu anlaufen.
window.history.pushState(null, '', PAGE_PATH_PREFIX + pageIdentifier); window.history.pushState(null, '', PAGE_PATH_PREFIX + pageIdentifier);
setCurrentPageId(pageIdentifier); setCurrentPageId(pageIdentifier);
}, []); }, []);

View File

@ -1,227 +1,163 @@
/* /*
* Design-System der Syncova-Oberfläche. * Design-System der Syncova-Oberflaeche (Tailwind v4 + shadcn/ui).
* *
* Die Farben, Radien, Schatten und die Schrift stammen aus dem Preset * Der Grundsatz aus PROMPT.md §106 gilt unveraendert und ist der Grund fuer den
* `b5vnF8SMi` (tweakcn). Es ist bewusst kantig (Radius 0), schattenlos und * Zuschnitt dieser Datei: Semantische Farben ausschliesslich fuer Status. Die
* durchgehend in einer dicktengleichen Schrift — ein technischer, ruhiger Look. * gesamte Oberflaeche ist neutral gehalten; wo Farbe erscheint, bedeutet sie
* etwas. Eine bunte Oberflaeche verschleiert, welche Information wirklich
* dringend ist — und in einer Backup-Konsole ist genau das die einzige Frage,
* die zaehlt.
* *
* Drei Stellen weichen ab, und zwar begründet: * Deshalb gibt es hier keine "Markenfarbe" als Flaeche. Der Akzent ist ein
* * gedecktes Blau und wird nur fuer Bedienelemente verwendet, nie fuer Zustaende.
* 1. **Die Statusfarben bleiben.** Das Preset kennt nur `destructive` und fünf
* Diagrammfarben, aber PROMPT §106 verlangt fünf Bedeutungen: Grün, Gelb,
* Orange, Rot, Blau. Ohne sie ließe sich ein Teilfehler nicht von einem
* Erfolg unterscheiden — die eine Aussage, auf die es in dieser Konsole
* ankommt. Sie sind an die Neutraltöne des Presets angepasst.
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der
* Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
* Schreibweisen werden unterstützt.
* 3. **Geist Mono liegt im Paket.** Sie wird vom eigenen Ursprung ausgeliefert,
* nicht von einem CDN: Das verlangt die Content-Security-Policy, und ein
* Backup-Server, dessen Oberfläche von der Erreichbarkeit eines fremden
* Hosts abhängt, wäre auch ohne CSP falsch.
*
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
* steht damit an genau einer Stelle, und ein Wechsel des Presets fasst keine
* einzige Komponente an.
*/ */
@import 'tailwindcss'; @import 'tailwindcss';
/* /* Dark Mode wird ueber ein Attribut geschaltet, nicht ueber die Medienabfrage
* Geist Mono liegt **im Paket**, nicht auf einem fremden Host. * allein: Der Betreiber soll die Wahl haben, und eine Konsole, die nachts von
* * selbst umschaltet, waehrend jemand an einer Stoerung arbeitet, ist laestig. */
* Vite bündelt die drei Schnitte mit und vergibt ihnen einen Hash; ausgeliefert @custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
* wird ausschließlich vom eigenen Ursprung. Das ist die einzige Form, die die
* Content-Security-Policy zulässt — und die einzige, die für einen
* Backup-Server vertretbar ist: Seine Oberfläche darf nicht davon abhängen,
* dass ein CDN erreichbar ist. Kosten: rund 128 KB für drei Schnitte.
*
* `font-display: swap` zeigt den Text sofort in der System-Monospace und
* tauscht die Schrift nach. Ein unsichtbarer Text, bis eine Schriftdatei da
* ist, wäre in einer Störungskonsole der falsche Kompromiss.
*
* Lizenz: SIL Open Font License 1.1, siehe assets/fonts/LICENSE.txt.
*/
@font-face {
font-family: 'Geist Mono';
src: url('../assets/fonts/GeistMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('../assets/fonts/GeistMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('../assets/fonts/GeistMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
* shadcn-übliche Form und kostet nichts. */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
@theme { @theme {
/* Geist Mono für alles — die Wahl des Presets. In einer Konsole voller /* --- Schrift --- */
* Zahlen, Pfade und Kennungen ist das kein Stilbruch, sondern hilfreich: --font-sans:
* Ziffern stehen untereinander, und ein Pfad bricht dort, wo er soll. */ system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-sans: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Radius 0 — das prägendste Merkmal des Presets. */ /* --- Radien --- */
--radius-sm: 0; --radius-sm: 0.25rem;
--radius-md: 0; --radius-md: 0.375rem;
--radius-lg: 0; --radius-lg: 0.5rem;
--radius-xl: 0; --radius-xl: 0.75rem;
--radius-2xl: 0;
/* Das Preset ist schattenlos. Tiefe entsteht über Flächenkontrast. */
--shadow-card: none;
--shadow-raised: none;
} }
/*
* Die Farben liegen als CSS-Variablen vor, damit dieselbe Komponente in beiden
* Themen funktioniert, ohne dass jede Klasse eine dark:-Variante braucht.
*/
:root, :root,
[data-theme='light'], [data-theme='light'] {
.light { /* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */
/* --- Flächen --- */ --surface-page: 0 0% 98%;
--surface-page: oklch(1 0 0); --surface-card: 0 0% 100%;
--surface-card: oklch(1 0 0); --surface-raised: 0 0% 100%;
--surface-raised: oklch(1 0 0); --surface-sunken: 240 5% 96%;
--surface-sunken: oklch(0.9702 0 0); --surface-hover: 240 5% 94%;
--surface-hover: oklch(0.9702 0 0);
/* Die Seitenleiste hat im Preset eine eigene, leicht abgesetzte Fläche. */
--surface-sidebar: oklch(0.9851 0 0);
--border-default: oklch(0.9219 0 0); --border-default: 240 6% 87%;
--border-strong: oklch(0.709 0 0); --border-strong: 240 5% 78%;
--text-primary: oklch(0.1448 0 0); --text-primary: 240 10% 10%;
--text-secondary: oklch(0.5555 0 0); --text-secondary: 240 4% 42%;
--text-muted: oklch(0.709 0 0); --text-muted: 240 4% 56%;
--text-inverted: oklch(0.9691 0.0161 293.7558); --text-inverted: 0 0% 100%;
/* Akzent = `primary` des Presets, nicht dessen `accent`. /* Akzent — Bedienelemente, niemals Zustand. */
* --accent: 221 70% 45%;
* In shadcn ist `accent` die dezente Hover-Fläche und `primary` die Farbe der --accent-hover: 221 70% 39%;
* Handlung. Die beiden zu verwechseln machte jede Schaltfläche grau. */ --accent-text: 0 0% 100%;
--accent: oklch(0.4907 0.2699 292.5633); --accent-subtle: 221 70% 96%;
--accent-hover: oklch(0.4316 0.232 292.7118);
--accent-text: oklch(0.9691 0.0161 293.7558);
--accent-subtle: oklch(0.9674 0.0013 286.3752);
/* --- Statusfarben (PROMPT §106) --- /* --- Statusfarben (PROMPT.md §106) ---
* Grün = Healthy, Gelb = Warning, Orange = High, Rot = Critical, * Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical,
* Blau/Neutral = Information. Rot stammt aus `destructive` des Presets. */ * Blau/Neutral = Information. Sonst nichts. */
--status-healthy: oklch(0.52 0.14 152); --status-healthy: 142 72% 29%;
--status-healthy-bg: oklch(0.96 0.03 152); --status-healthy-bg: 142 60% 95%;
--status-warning: oklch(0.55 0.12 85); --status-warning: 38 92% 33%;
--status-warning-bg: oklch(0.96 0.04 85); --status-warning-bg: 45 90% 94%;
--status-high: oklch(0.56 0.17 45); --status-high: 21 90% 40%;
--status-high-bg: oklch(0.96 0.04 45); --status-high-bg: 24 90% 95%;
--status-critical: oklch(0.583 0.2387 28.4765); --status-critical: 0 74% 42%;
--status-critical-bg: oklch(0.96 0.03 28); --status-critical-bg: 0 85% 96%;
--status-info: oklch(0.5405 0.2811 292.8523); --status-info: 221 83% 45%;
--status-info-bg: oklch(0.96 0.02 293); --status-info-bg: 214 95% 96%;
--status-neutral: oklch(0.5555 0 0); --status-neutral: 240 4% 42%;
--status-neutral-bg: oklch(0.9702 0 0); --status-neutral-bg: 240 5% 95%;
--focus-ring: oklch(0.4907 0.2699 292.5633); --focus-ring: 221 83% 53%;
} }
[data-theme='dark'], [data-theme='dark'] {
.dark { --surface-page: 240 8% 8%;
--surface-page: oklch(0.1448 0 0); --surface-card: 240 7% 11%;
--surface-card: oklch(0.2046 0 0); --surface-raised: 240 6% 14%;
--surface-raised: oklch(0.2046 0 0); --surface-sunken: 240 9% 6%;
--surface-sunken: oklch(0.2686 0 0); --surface-hover: 240 6% 17%;
--surface-hover: oklch(0.2686 0 0);
--surface-sidebar: oklch(0.2046 0 0);
/* Das Preset setzt im dunklen Thema reines Schwarz als Rand: Flächen trennen --border-default: 240 5% 22%;
* sich über ihre Helligkeit, nicht über Linien. Übernommen. */ --border-strong: 240 5% 32%;
--border-default: oklch(0 0 0);
--border-strong: oklch(0.3 0 0);
--text-primary: oklch(0.9851 0 0); --text-primary: 0 0% 96%;
--text-secondary: oklch(0.709 0 0); --text-secondary: 240 5% 68%;
--text-muted: oklch(0.5555 0 0); --text-muted: 240 4% 52%;
--text-inverted: oklch(0.9691 0.0161 293.7558); --text-inverted: 240 10% 8%;
--accent: oklch(0.6006 0.2425 293.8885); --accent: 217 85% 62%;
--accent-hover: oklch(0.8101 0.1055 293.6966); --accent-hover: 217 85% 70%;
--accent-text: oklch(0.1448 0 0); --accent-text: 240 10% 8%;
--accent-subtle: oklch(0.2739 0.0055 286.0326); --accent-subtle: 221 50% 18%;
/* Aufgehellt, sonst wären die Töne auf dunklem Grund nicht unterscheidbar — /* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des
* und ein nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */ * hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein
--status-healthy: oklch(0.75 0.16 152); * nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */
--status-healthy-bg: oklch(0.28 0.05 152); --status-healthy: 142 65% 55%;
--status-warning: oklch(0.82 0.15 85); --status-healthy-bg: 142 45% 15%;
--status-warning-bg: oklch(0.29 0.05 85); --status-warning: 45 90% 60%;
--status-high: oklch(0.75 0.16 45); --status-warning-bg: 40 50% 15%;
--status-high-bg: oklch(0.29 0.06 45); --status-high: 25 92% 60%;
--status-critical: oklch(0.7022 0.1892 22.2279); --status-high-bg: 22 55% 15%;
--status-critical-bg: oklch(0.29 0.07 22); --status-critical: 0 85% 65%;
--status-info: oklch(0.8101 0.1055 293.6966); --status-critical-bg: 0 50% 16%;
--status-info-bg: oklch(0.29 0.06 293); --status-info: 217 90% 65%;
--status-neutral: oklch(0.709 0 0); --status-info-bg: 220 50% 17%;
--status-neutral-bg: oklch(0.2686 0 0); --status-neutral: 240 5% 65%;
--status-neutral-bg: 240 5% 18%;
--focus-ring: oklch(0.6006 0.2425 293.8885); --focus-ring: 217 90% 62%;
} }
/* Die Variablen werden Tailwind als Farben bekannt gemacht, damit `bg-card`, /* Die Variablen werden Tailwind als Farben bekannt gemacht, damit
* `text-fg` und `text-critical` unmittelbar funktionieren. */ * bg-surface-card, text-status-critical usw. unmittelbar funktionieren. */
@theme inline { @theme inline {
--color-page: var(--surface-page); --color-page: hsl(var(--surface-page));
--color-card: var(--surface-card); --color-card: hsl(var(--surface-card));
--color-raised: var(--surface-raised); --color-raised: hsl(var(--surface-raised));
--color-sunken: var(--surface-sunken); --color-sunken: hsl(var(--surface-sunken));
--color-hover: var(--surface-hover); --color-hover: hsl(var(--surface-hover));
--color-sidebar: var(--surface-sidebar);
--color-line: var(--border-default); --color-line: hsl(var(--border-default));
--color-line-strong: var(--border-strong); --color-line-strong: hsl(var(--border-strong));
--color-fg: var(--text-primary); --color-fg: hsl(var(--text-primary));
--color-fg-muted: var(--text-secondary); --color-fg-muted: hsl(var(--text-secondary));
--color-fg-subtle: var(--text-muted); --color-fg-subtle: hsl(var(--text-muted));
--color-fg-inverted: var(--text-inverted); --color-fg-inverted: hsl(var(--text-inverted));
--color-accent: var(--accent); --color-accent: hsl(var(--accent));
--color-accent-hover: var(--accent-hover); --color-accent-hover: hsl(var(--accent-hover));
--color-accent-fg: var(--accent-text); --color-accent-fg: hsl(var(--accent-text));
--color-accent-subtle: var(--accent-subtle); --color-accent-subtle: hsl(var(--accent-subtle));
--color-healthy: var(--status-healthy); --color-healthy: hsl(var(--status-healthy));
--color-healthy-bg: var(--status-healthy-bg); --color-healthy-bg: hsl(var(--status-healthy-bg));
--color-warning: var(--status-warning); --color-warning: hsl(var(--status-warning));
--color-warning-bg: var(--status-warning-bg); --color-warning-bg: hsl(var(--status-warning-bg));
--color-high: var(--status-high); --color-high: hsl(var(--status-high));
--color-high-bg: var(--status-high-bg); --color-high-bg: hsl(var(--status-high-bg));
--color-critical: var(--status-critical); --color-critical: hsl(var(--status-critical));
--color-critical-bg: var(--status-critical-bg); --color-critical-bg: hsl(var(--status-critical-bg));
--color-info: var(--status-info); --color-info: hsl(var(--status-info));
--color-info-bg: var(--status-info-bg); --color-info-bg: hsl(var(--status-info-bg));
--color-neutral: var(--status-neutral); --color-neutral: hsl(var(--status-neutral));
--color-neutral-bg: var(--status-neutral-bg); --color-neutral-bg: hsl(var(--status-neutral-bg));
--color-ring: var(--focus-ring); --color-ring: hsl(var(--focus-ring));
} }
@layer base { @layer base {
* { * {
border-color: var(--border-default); border-color: hsl(var(--border-default));
} }
html { html {
@ -231,22 +167,21 @@
} }
body { body {
background-color: var(--surface-page); background-color: hsl(var(--surface-page));
color: var(--text-primary); color: hsl(var(--text-primary));
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole für /* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole fuer
* Tastaturbedienung unbrauchbar — und an einer Störung arbeitet niemand mit * Tastaturbedienung unbrauchbar — und an einer Stoerung arbeitet niemand mit
* der Maus. */ * der Maus. */
:focus-visible { :focus-visible {
outline: 2px solid var(--focus-ring); outline: 2px solid hsl(var(--focus-ring));
outline-offset: 2px; outline-offset: 2px;
} }
/* Bei einer dicktengleichen Schrift stehen Ziffern ohnehin untereinander; /* Zahlen in Tabellen sollen untereinander stehen. */
* die Klasse bleibt, damit die Aufrufstellen unverändert gelten. */
.tabular { .tabular {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }

View File

@ -1,4 +1,4 @@
/** Gemeinsame Testeinrichtung für alle Frontend-Tests. */ /** Gemeinsame Testeinrichtung fuer alle Frontend-Tests. */
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';

View File

@ -1,8 +1,8 @@
/** /**
* Typen des Syncova-API-Vertrags (SYNCOVA_API.md §1). * Typen des Syncova-API-Vertrags (SYNCOVA_API.md §1).
* *
* Die Struktur spiegelt exakt die Antworthülle des Backends wider, damit * Die Struktur spiegelt exakt die Antworthuelle des Backends wider, damit
* Abweichungen bereits beim Übersetzen auffallen. * Abweichungen bereits beim Uebersetzen auffallen.
*/ */
/** Kontextinformationen jeder Antwort. */ /** Kontextinformationen jeder Antwort. */
@ -11,13 +11,13 @@ export interface ResponseMeta {
request_id: string; request_id: string;
/** Aktuelle Seitennummer; nur bei paginierten Listen gesetzt. */ /** Aktuelle Seitennummer; nur bei paginierten Listen gesetzt. */
page?: number; page?: number;
/** Anzahl der Einträge pro Seite; nur bei paginierten Listen gesetzt. */ /** Anzahl der Eintraege pro Seite; nur bei paginierten Listen gesetzt. */
page_size?: number; page_size?: number;
/** Gesamtzahl verfügbarer Einträge; nur bei paginierten Listen gesetzt. */ /** Gesamtzahl verfuegbarer Eintraege; nur bei paginierten Listen gesetzt. */
total?: number; total?: number;
} }
/** Hülle einer erfolgreichen Antwort. */ /** Huelle einer erfolgreichen Antwort. */
export interface SuccessResponse<TPayload> { export interface SuccessResponse<TPayload> {
/** Die eigentliche Nutzlast. */ /** Die eigentliche Nutzlast. */
data: TPayload; data: TPayload;
@ -25,11 +25,11 @@ export interface SuccessResponse<TPayload> {
meta: ResponseMeta; meta: ResponseMeta;
} }
/** Fehlerkörper einer fehlgeschlagenen Antwort. */ /** Fehlerkoerper einer fehlgeschlagenen Antwort. */
export interface ErrorBody { export interface ErrorBody {
/** Stabiler maschinenlesbarer Fehlercode, z. B. REPOSITORY_UNAVAILABLE. */ /** Stabiler maschinenlesbarer Fehlercode, z. B. REPOSITORY_UNAVAILABLE. */
code: string; code: string;
/** Verständliche Erklärung des Fehlers. */ /** Verstaendliche Erklaerung des Fehlers. */
message: string; message: string;
/** Optionale unbedenkliche Zusatzinformationen. */ /** Optionale unbedenkliche Zusatzinformationen. */
details?: Record<string, unknown>; details?: Record<string, unknown>;
@ -37,7 +37,7 @@ export interface ErrorBody {
request_id: string; request_id: string;
} }
/** Hülle einer fehlgeschlagenen Antwort. */ /** Huelle einer fehlgeschlagenen Antwort. */
export interface ErrorResponse { export interface ErrorResponse {
/** Beschreibung des aufgetretenen Fehlers. */ /** Beschreibung des aufgetretenen Fehlers. */
error: ErrorBody; error: ErrorBody;
@ -50,25 +50,25 @@ export interface ErrorResponse {
*/ */
export type HealthStatus = 'healthy' | 'degraded' | 'warning' | 'critical' | 'offline'; export type HealthStatus = 'healthy' | 'degraded' | 'warning' | 'critical' | 'offline';
/** Ergebnis der Zustandsprüfung einer einzelnen Komponente. */ /** Ergebnis der Zustandspruefung einer einzelnen Komponente. */
export interface ComponentHealth { export interface ComponentHealth {
/** Ermittelter Zustand der Komponente. */ /** Ermittelter Zustand der Komponente. */
status: HealthStatus; status: HealthStatus;
/** Verständliche Erklärung des Zustands. */ /** Verstaendliche Erklaerung des Zustands. */
message?: string; message?: string;
/** Empfohlener nächster Schritt bei Problemen. */ /** Empfohlener naechster Schritt bei Problemen. */
recommended_action?: string; recommended_action?: string;
/** Dauer der Prüfung in Millisekunden. */ /** Dauer der Pruefung in Millisekunden. */
latency_ms: number; latency_ms: number;
/** Zeitpunkt der Prüfung in UTC (ISO 8601). */ /** Zeitpunkt der Pruefung in UTC (ISO 8601). */
checked_at: string; checked_at: string;
} }
/** Antwort von GET /api/v1/health. */ /** Antwort von GET /api/v1/health. */
export interface SystemHealth { export interface SystemHealth {
/** Schlechtester Zustand über alle Komponenten hinweg. */ /** Schlechtester Zustand ueber alle Komponenten hinweg. */
status: HealthStatus; status: HealthStatus;
/** Ergebnis je geprüfter Komponente. */ /** Ergebnis je geprueter Komponente. */
components: Record<string, ComponentHealth>; components: Record<string, ComponentHealth>;
/** Laufende Programmversion des Backends. */ /** Laufende Programmversion des Backends. */
version: string; version: string;

View File

@ -5,7 +5,7 @@ export type UserStatus = 'active' | 'disabled' | 'locked';
/** Ein Benutzerkonto. */ /** Ein Benutzerkonto. */
export interface CurrentUser { export interface CurrentUser {
/** Öffentlicher Bezeichner. */ /** Oeffentlicher Bezeichner. */
id: string; id: string;
/** Anmeldename. */ /** Anmeldename. */
username: string; username: string;
@ -13,7 +13,7 @@ export interface CurrentUser {
email?: string; email?: string;
/** Zustand des Kontos. */ /** Zustand des Kontos. */
status: UserStatus; status: UserStatus;
/** Meldet, ob ein bestätigter zweiter Faktor vorliegt. */ /** Meldet, ob ein bestaetigter zweiter Faktor vorliegt. */
mfa_enabled: boolean; mfa_enabled: boolean;
/** Zugewiesene Rollen. */ /** Zugewiesene Rollen. */
roles: string[]; roles: string[];
@ -27,7 +27,7 @@ export interface CurrentUser {
export interface TokenPair { export interface TokenPair {
/** Kurzlebiges Zugriffstoken. */ /** Kurzlebiges Zugriffstoken. */
access_token: string; access_token: string;
/** Länger gültiges Erneuerungstoken. */ /** Laenger gueltiges Erneuerungstoken. */
refresh_token: string; refresh_token: string;
/** Ablaufzeit des Zugriffstokens. */ /** Ablaufzeit des Zugriffstokens. */
access_expires_at: string; access_expires_at: string;
@ -42,7 +42,7 @@ export interface TokenPair {
* der zweite Faktor (mfa_required gesetzt). * der zweite Faktor (mfa_required gesetzt).
*/ */
export interface LoginResult { export interface LoginResult {
/** Meldet, dass noch ein zweiter Faktor nötig ist. */ /** Meldet, dass noch ein zweiter Faktor noetig ist. */
mfa_required: boolean; mfa_required: boolean;
/** Benennt die offene MFA-Herausforderung. */ /** Benennt die offene MFA-Herausforderung. */
challenge_id?: string; challenge_id?: string;

View File

@ -89,46 +89,6 @@ Wiederherstellung: 355 MiB/s, Ergebnis bitgenau identisch zur Quelle.
Ein früherer Messlauf zeigte 1021-fache Kompression. Diese Zahl war wertlos: die Testdaten waren periodisch erzeugt und damit unrealistisch gut komprimierbar. Belastbar sind nur Messungen mit inkompressiblen Daten. Ein früherer Messlauf zeigte 1021-fache Kompression. Diese Zahl war wertlos: die Testdaten waren periodisch erzeugt und damit unrealistisch gut komprimierbar. Belastbar sind nur Messungen mit inkompressiblen Daten.
## Sicherungsart je Auftrag
Der Standard ist **inkrementell**: Liegt ein Elternbackup vor, wird nur
Geändertes gelesen. Zwei Abweichungen lassen sich je Auftrag einstellen.
| Einstellung | Wirkung |
| --- | --- |
| `incremental` | Erster Lauf voll, danach inkrementell. Standard |
| `always_full` | Jeder Lauf liest die gesamte Quelle |
| `full_backup_weekday` | Zusätzlich an einem festen Wochentag voll |
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert.** Unveränderte
Blöcke werden dedupliziert und liegen weiterhin nur einmal im Repository. Was
steigt, ist die **Laufzeit**: Jeder Lauf liest, hasht, komprimiert und
verschlüsselt alles neu. Gemessen (Phase 6): 190,7 MiB in 1 815 ms gegen
4,8 MiB in 133 ms bei einer geänderten von 41 Dateien.
Wer das verwechselt, hält „immer voll" für teuer im Speicher und plant seinen
Nachtbetrieb falsch.
Wann eine Abweichung sinnvoll ist:
- **Immer voll**, wenn das Repository außer Haus geht oder auf einen
Datenträger geschrieben wird, der einzeln weggetragen wird.
- **Wöchentlich voll** als üblicher Kompromiss: unter der Woche schnell, an
einem festen Tag einmal vollständig.
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Ohne diese
Umrechnung liefe derselbe Auftrag auf zwei Servern an verschiedenen Tagen voll
— und ein Betreiber in Berlin bekäme seine Vollsicherung am Donnerstagabend.
Ein Widerspruch — „immer voll" **und** ein Wochentag — wird von der Datenbank
abgelehnt, nicht stillschweigend aufgelöst.
Zur Einordnung: Syncova-Manifeste sind vollständig (Phase 6). Eine
Zusatzsicherung trägt die Blockverweise des Elternbackups mit, ein Restore liest
genau **ein** Manifest, und das Löschen eines alten Backups kann ein neueres
nicht beschädigen. Der Unterschied liegt also in der Laufzeit, nicht in der
Wiederherstellbarkeit.
## Grenzen des aktuellen Stands ## Grenzen des aktuellen Stands
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte: **Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:

View File

@ -187,17 +187,12 @@ ExecStart=/opt/syncova/bin/syncova-api
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# Härtung: Der Dienst braucht Netz, sein Repository und eine Fläche für # Härtung: Der Dienst braucht Netz und sein Repository, sonst nichts.
# Wiederherstellungen — sonst nichts.
#
# ReadWritePaths ist die Stelle, an der eine Wiederherstellung scheitert, wenn
# man sie vergisst: Mit ProtectSystem=strict ist alles andere für den Dienst
# schreibgeschützt, und die Rechte des Zielverzeichnisses helfen dann nicht.
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
ReadWritePaths=/srv/syncova-repository /srv/syncova-restore ReadWritePaths=/srv/syncova-repository
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
@ -290,25 +285,6 @@ sudo firewall-cmd --permanent --add-service=https # firewalld
sudo firewall-cmd --reload sudo firewall-cmd --reload
``` ```
## 8b. Wohin Wiederherstellungen schreiben dürfen
`setup.sh` legt `/srv/syncova-restore` an und trägt es in `ReadWritePaths` der
systemd-Einheit ein. Ohne diesen Eintrag scheitert **jede** Wiederherstellung:
Der Dienst läuft mit `ProtectSystem=strict`, und die Rechte des
Zielverzeichnisses helfen dann nicht.
Weitere Ziele beim Einrichten nennen:
```bash
sudo ./setup.sh --wiederherstellungsziel /srv/wiederherstellung \
--wiederherstellungsziel /mnt/nas/restore
```
Nicht möglich sind `/tmp` (privater Namensraum des Dienstes) und die
Systemverzeichnisse `/etc`, `/usr`, `/var/lib`, `/root` — Letztere sperrt der
Zielschutz, weil eine Wiederherstellung dorthin das System überschriebe, auf dem
die Anlage läuft.
## 9. Erstes Repository ## 9. Erstes Repository
Ein Repository entsteht **auf einem Datenträger**, nicht in einer Ein Repository entsteht **auf einem Datenträger**, nicht in einer

View File

@ -51,44 +51,9 @@ Original nicht mehr.
## 1. Dateien und Ordner ## 1. Dateien und Ordner
### Wohin darf zurückgeschrieben werden?
**Die wichtigste Frage, und sie ist nicht offensichtlich.** Der Dienst läuft mit
`ProtectSystem=strict`: Außerhalb weniger Pfade ist das Dateisystem für ihn
schreibgeschützt, unabhängig von den Rechten des Verzeichnisses. Ein Ziel
außerhalb endet mit `mkdir: permission denied` — und zwar erst **nach** der
Vorabprüfung.
| Ort | Ergebnis |
| --- | --- |
| `/srv/syncova-restore` | ✓ von `setup.sh` angelegt und eingetragen |
| weitere aus `--wiederherstellungsziel` | ✓ |
| `/tmp/…` | ✗ landet im privaten `/tmp` des Dienstes und ist von außen unsichtbar |
| `/etc`, `/usr`, `/var/lib`, `/root` … | ✗ vom Zielschutz gesperrt |
| alles andere | ✗ schreibgeschützt durch `ProtectSystem=strict` |
Der Ordnerbaum in der Oberfläche beantwortet das direkt: Er meldet je
Verzeichnis, ob der Dienst dort schreiben darf — **gemessen** durch eine
Probedatei, nicht aus den Rechtebits abgeleitet.
Ein weiteres Ziel nachträglich freigeben:
```bash
sudo systemctl edit syncova-api # ReadWritePaths= ergänzen
sudo systemctl restart syncova-api
```
### Über die Oberfläche ### Über die Oberfläche
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen*. Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen* → Zielpfad angeben.
1. **Ziel** über den Ordnerbaum wählen. Beschreibbare Orte stehen oben als
Vorschlag; ein Unterverzeichnis lässt sich anlegen.
2. **Umfang** über den Backup-Browser wählen — das gesamte Backup, ein Ordner
oder eine einzelne Datei.
3. **Vorabprüfung** — sie schreibt nichts und stellt fest, ob jeder benötigte
Block noch da ist.
4. **Ausführen.**
### Über die API ### Über die API
@ -103,16 +68,7 @@ curl -X POST https://<server>/api/v1/restores \
}' }'
``` ```
`path_prefix` trifft **einen Ordner oder eine einzelne Datei**: Der Server `path_prefix` beschränkt auf einen Teilbaum. Ohne ihn kommt alles zurück.
vergleicht auf Gleichheit oder Präfix mit Verzeichnisgrenze — `dokumente`
trifft dabei nicht `dokumentation`. Ohne ihn kommt alles zurück.
Den Inhalt eines Backups durchsehen, ohne etwas zurückzuschreiben:
```bash
curl -s -H "Authorization: Bearer <token>" \
"https://<server>/api/v1/backups/<id>/contents?path=berichte" | jq '.data.entries'
```
Fortschritt: Fortschritt:

View File

@ -117,42 +117,12 @@ API-Token eines Proxmox-Verbunds wird nach dem Anlegen nie wieder ausgeliefert
das ist kein Mangel, sondern der Grund, warum ein Lesezugriff auf die das ist kein Mangel, sondern der Grund, warum ein Lesezugriff auf die
Konfiguration ungefährlich ist. Konfiguration ungefährlich ist.
## Aussehen
Farben, Radien, Schatten und Schrift stammen aus dem Preset `b5vnF8SMi`
(tweakcn): violett als Handlungsfarbe, **Radius 0**, schattenlos, durchgehend
Geist Mono. Kantig und ruhig.
Drei Abweichungen, begründet in `styles/theme.css`:
- **Die Statusfarben bleiben.** Das Preset kennt nur `destructive` und fünf
Diagrammfarben; §106 verlangt fünf Bedeutungen. Ohne sie ließe sich ein
Teilfehler nicht von einem Erfolg unterscheiden.
- **Das dunkle Thema hängt an `[data-theme='dark']`**, nicht an `.dark` — der
Umschalter setzt dieses Attribut. `.dark` funktioniert zusätzlich.
- **Geist Mono lädt nicht nach.** Sie steht zuerst im Stapel; liegt sie nicht
auf dem Gerät, greift die System-Monospace. Eine Schrift von einem fremden
Host zu holen verbietet die CSP — und ein Backup-Server, der für seine
Oberfläche ins Internet greift, wäre auch ohne CSP falsch.
Eine Zuordnung ist die Stolperstelle: In shadcn ist `accent` die dezente
Hover-Fläche und `primary` die Farbe der Handlung. Sie zu verwechseln macht jede
Schaltfläche grau.
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.
## Technik ## Technik
- **Tailwind v4 und Radix-Primitive** nach shadcn-Muster. Alles gebündelt; die - **Tailwind v4 und Radix-Primitive** nach shadcn-Muster. Alles gebündelt; die
CSP der Auslieferung lässt externe Ressourcen ohnehin nicht zu. CSP der Auslieferung lässt externe Ressourcen ohnehin nicht zu.
- **Farben als CSS-Variablen**, damit dieselbe Komponente in beiden Themen - **Farben als CSS-Variablen**, damit dieselbe Komponente in beiden Themen
funktioniert, ohne dass jede Klasse eine `dark:`-Variante braucht. funktioniert, ohne dass jede Klasse eine `dark:`-Variante braucht.
- **Fehlergrenze um den Seiteninhalt.** Ohne sie reißt ein Fehler in einer
Komponente den gesamten Baum ab; übrig bleibt eine leere Seite — im dunklen
Thema ein schwarzer Bildschirm ohne Hinweis. Menü und Kopfzeile bleiben
stehen, der Fehlertext ist lesbar.
- **Dark Mode über ein Attribut am Wurzelelement**, nicht allein über die - **Dark Mode über ein Attribut am Wurzelelement**, nicht allein über die
Medienabfrage: Eine Konsole, die nachts während einer Störung von selbst Medienabfrage: Eine Konsole, die nachts während einer Störung von selbst
umschaltet, ist lästig. Das Attribut sitzt am Wurzelelement, weil ein Dialog im umschaltet, ist lästig. Das Attribut sitzt am Wurzelelement, weil ein Dialog im
@ -168,27 +138,6 @@ Presets fasst keine einzige Komponente an.
Fehlercode. Sie kennt den Einzelfall, und diese Genauigkeit ist mehr wert. Fehlercode. Sie kennt den Einzelfall, und diese Genauigkeit ist mehr wert.
- **Berechtigungen im Menü sind Anzeige, keine Sicherung.** Sie verhindern - **Berechtigungen im Menü sind Anzeige, keine Sicherung.** Sie verhindern
Sackgassen; geprüft wird auf dem Server. Sackgassen; geprüft wird auf dem Server.
### Sitzung
Sie überlebt ein Neuladen. Die Tokens liegen im `sessionStorage` des Tabs —
nicht im `localStorage`, der ein Schließen des Browsers überstünde. Begrenzt
wird sie durch zwei Uhren:
| | |
| --- | --- |
| **Harte Obergrenze** | 30 Minuten ab Anmeldung, durch keine Interaktion verschiebbar |
| **Untätigkeitsgrenze** | 30 Minuten ohne Eingabe |
Maßgeblich ist die frühere der beiden. Die verbleibende Zeit steht neben
„Abmelden" und wird unter fünf Minuten auffällig.
Dass die Tokens überhaupt abgelegt werden, kehrt eine frühere Entscheidung um:
Vorher lagen sie nur im Arbeitsspeicher, und jedes Neuladen warf den Betreiber
auf die Anmeldemaske. Mitten in einer Störung ist das kein Sicherheitsgewinn,
sondern ein Hindernis. Was den Rest trägt, ist nicht der Speicherort, sondern
der **sofortige serverseitige Widerruf**: Die Tokens sind opak, kein JWT, und
genau dafür wurden sie gewählt.
- **Jede Fehleranzeige nennt `request_id`**, kopierbar. Ohne sie bleibt „es hat - **Jede Fehleranzeige nennt `request_id`**, kopierbar. Ohne sie bleibt „es hat
nicht funktioniert". nicht funktioniert".
@ -222,8 +171,6 @@ Konsole macht:
Listen. Listen.
- **Die Suche im Ereignisprotokoll filtert im Browser** über die letzten 100 - **Die Suche im Ereignisprotokoll filtert im Browser** über die letzten 100
Einträge. Bei größeren Beständen gehört sie auf den Server. Einträge. Bei größeren Beständen gehört sie auf den Server.
- **Geist Mono wird nicht mitgeliefert.** Ohne die Schrift auf dem Gerät sieht
die Oberfläche in der System-Monospace anders aus als im Preset.
- **Kein Live-Fortschritt.** `/api/v1/events/stream` steht in `SYNCOVA_API.md`, - **Kein Live-Fortschritt.** `/api/v1/events/stream` steht in `SYNCOVA_API.md`,
ist aber **auch serverseitig nicht umgesetzt** — es fehlt nicht nur die ist aber **auch serverseitig nicht umgesetzt** — es fehlt nicht nur die
Anbindung. Laufende Sicherungen und Wiederherstellungen aktualisieren sich Anbindung. Laufende Sicherungen und Wiederherstellungen aktualisieren sich

View File

@ -1,8 +0,0 @@
ALTER TABLE backup_jobs
DROP CONSTRAINT IF EXISTS backup_jobs_full_weekday_only_incremental,
DROP CONSTRAINT IF EXISTS backup_jobs_full_backup_weekday_valid,
DROP CONSTRAINT IF EXISTS backup_jobs_backup_mode_valid;
ALTER TABLE backup_jobs
DROP COLUMN IF EXISTS full_backup_weekday,
DROP COLUMN IF EXISTS backup_mode;

View File

@ -1,48 +0,0 @@
-- Sicherungsart je Auftrag.
--
-- Bisher entschied der Executor allein: Liegt ein Elternbackup vor, wird
-- inkrementell gesichert. Das ist der richtige Standard — der Gewinn ist
-- Lesezeit, und die ist nach dem ersten Lauf der begrenzende Faktor.
--
-- Es gibt aber zwei Gruende, davon abzuweichen, und beide sind betrieblich:
--
-- 1. **Immer voll.** Wer sein Backup ausser Haus gibt oder auf einen
-- Datentraeger schreibt, der einzeln weggetragen wird, will nicht, dass
-- ein Wiederherstellungspunkt an einem frueheren haengt.
-- 2. **Wöchentlich voll.** Der uebliche Kompromiss: unter der Woche schnell,
-- am Wochenende einmal vollstaendig.
--
-- Hinweis zur Einordnung: Syncova-Manifeste sind **vollstaendig** (Phase 6).
-- Eine Zusatzsicherung traegt die Blockverweise des Elternbackups mit, ein
-- Restore liest genau ein Manifest, und das Loeschen eines alten Backups kann
-- ein neueres nicht beschaedigen. Der Unterschied liegt also in der Laufzeit,
-- nicht in der Wiederherstellbarkeit.
ALTER TABLE backup_jobs
-- 'incremental' = nach dem ersten Lauf inkrementell (Standard, bisheriges
-- Verhalten). 'always_full' = jeder Lauf liest die Quelle vollstaendig.
ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'incremental',
-- Wochentag, an dem zusaetzlich eine Vollsicherung erzwungen wird.
-- 0 = Sonntag … 6 = Samstag, NULL = keiner. Gerechnet in der Zeitzone des
-- Zeitplans; ohne Angabe in UTC — sonst liefe derselbe Auftrag auf zwei
-- Servern an verschiedenen Tagen voll.
ADD COLUMN full_backup_weekday SMALLINT;
ALTER TABLE backup_jobs
ADD CONSTRAINT backup_jobs_backup_mode_valid
CHECK (backup_mode IN ('incremental', 'always_full')),
ADD CONSTRAINT backup_jobs_full_backup_weekday_valid
CHECK (full_backup_weekday IS NULL OR full_backup_weekday BETWEEN 0 AND 6),
-- Ein Wochentag bei 'always_full' ist widersprüchlich: Dann ist ohnehin
-- jeder Lauf voll. Die Regel steht in der Datenbank, weil im Code jede
-- Stelle sie einhalten muesste — und eine vergisst es.
ADD CONSTRAINT backup_jobs_full_weekday_only_incremental
CHECK (backup_mode = 'incremental' OR full_backup_weekday IS NULL);
COMMENT ON COLUMN backup_jobs.backup_mode IS
'incremental = nach dem ersten Lauf inkrementell; always_full = jeder Lauf vollstaendig';
COMMENT ON COLUMN backup_jobs.full_backup_weekday IS
'Wochentag einer erzwungenen Vollsicherung (0=Sonntag), NULL = keiner';

View File

@ -37,5 +37,3 @@ e559f3d4443b9057a3f40a685141d92f3e05817da725857d2f0cba231aef3035 000005_restore
e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql
f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql
ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql
f25274d148996038f5716f315470c4f9371629ad734a03f0ee49ee760ea4c2d5 000014_backup_mode.up.sql
9a48a9de411d6af07461c236967525469c73d820a4d282fab8a61ed6eb56f9f3 000014_backup_mode.down.sql

View File

@ -1,91 +0,0 @@
package backupexecutor
import (
"testing"
"time"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/scheduler"
)
// TestAlwaysFullForcesEveryRun haelt die dauerhafte Vollsicherung fest.
func TestAlwaysFullForcesEveryRun(testInstance *testing.T) {
fullJob := &jobs.Job{BackupMode: jobs.BackupModeAlwaysFull}
// An jedem beliebigen Tag.
for dayOffset := 0; dayOffset < 7; dayOffset++ {
runTime := time.Date(2026, 8, 17, 2, 0, 0, 0, time.UTC).AddDate(0, 0, dayOffset)
if !shouldForceFullBackup(fullJob, runTime) {
testInstance.Errorf("am %s wurde keine Vollsicherung erzwungen", runTime.Weekday())
}
}
}
// TestIncrementalIsTheDefault haelt fest, dass ohne Angabe nichts erzwungen wird.
//
// Der Standard ist das bisherige Verhalten: Liegt ein Elternbackup vor, wird
// inkrementell gesichert. Ein Auftrag aus der Zeit vor dieser Einstellung darf
// sich nicht ploetzlich anders verhalten.
func TestIncrementalIsTheDefault(testInstance *testing.T) {
if shouldForceFullBackup(&jobs.Job{}, time.Now()) {
testInstance.Error("ohne Angabe wurde eine Vollsicherung erzwungen")
}
if shouldForceFullBackup(nil, time.Now()) {
testInstance.Error("ohne Auftrag wurde eine Vollsicherung erzwungen")
}
}
// TestFullBackupWeekdayUsesScheduleTimeZone ist der eigentliche Punkt.
//
// Der Wochentag muss in der Zeitzone des Zeitplans bestimmt werden. Rechnete
// der Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
// Donnerstagabend — und wunderte sich, warum sie freitags fehlt.
func TestFullBackupWeekdayUsesScheduleTimeZone(testInstance *testing.T) {
friday := time.Friday
berlinJob := &jobs.Job{
FullBackupWeekday: &friday,
Schedule: scheduler.Schedule{TimeZone: "Europe/Berlin"},
}
// Donnerstag, 23:30 UTC — in Berlin ist es bereits Freitag, 01:30.
thursdayNightUTC := time.Date(2026, 8, 20, 23, 30, 0, 0, time.UTC)
if thursdayNightUTC.Weekday() != time.Thursday {
testInstance.Fatalf("Testvoraussetzung falsch: %s", thursdayNightUTC.Weekday())
}
if !shouldForceFullBackup(berlinJob, thursdayNightUTC) {
testInstance.Error("die Zeitzone des Zeitplans wird nicht beachtet: " +
"in Berlin ist Freitag, in UTC noch Donnerstag")
}
// Und umgekehrt: Freitag 23:30 UTC ist in Berlin schon Samstag.
fridayNightUTC := time.Date(2026, 8, 21, 23, 30, 0, 0, time.UTC)
if shouldForceFullBackup(berlinJob, fridayNightUTC) {
testInstance.Error("es wurde am Samstag (Berliner Zeit) voll gesichert")
}
}
// TestFullBackupWeekdayFallsBackToUTC haelt den Fall ohne Zeitzone fest.
//
// Ohne Angabe gilt UTC, nicht die Ortszeit des Servers — sonst liefe dieselbe
// Konfiguration auf zwei Servern an verschiedenen Tagen voll.
func TestFullBackupWeekdayFallsBackToUTC(testInstance *testing.T) {
sunday := time.Sunday
utcJob := &jobs.Job{FullBackupWeekday: &sunday}
sundayUTC := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC)
if sundayUTC.Weekday() != time.Sunday {
testInstance.Fatalf("Testvoraussetzung falsch: %s", sundayUTC.Weekday())
}
if !shouldForceFullBackup(utcJob, sundayUTC) {
testInstance.Error("am Sonntag wurde keine Vollsicherung erzwungen")
}
}

View File

@ -423,13 +423,6 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
return jobs.ExecutionResult{}, parentError return jobs.ExecutionResult{}, parentError
} }
// Der Auftrag kann davon abweichen — dauerhaft oder an einem Wochentag.
//
// Der Platzbedarf steigt dadurch nicht nennenswert: Unveraenderte Bloecke
// werden dedupliziert und liegen weiterhin nur einmal im Repository. Was
// steigt, ist die Laufzeit.
forceFullBackup := shouldForceFullBackup(backupRequest.Job, time.Now())
backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source) backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source)
startTime := time.Now().UTC() startTime := time.Now().UTC()
@ -446,7 +439,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
EncryptionEnabled: executor.options.SecretStore != nil, EncryptionEnabled: executor.options.SecretStore != nil,
ChainID: chainIdentifier.String(), ChainID: chainIdentifier.String(),
CreatedByVersion: executor.options.CreatedByVersion, CreatedByVersion: executor.options.CreatedByVersion,
Incremental: parentBackupInRepository != "" && !forceFullBackup, Incremental: parentBackupInRepository != "",
ParentBackupID: parentBackupInRepository, ParentBackupID: parentBackupInRepository,
BandwidthLimiter: backupRequest.Limiter, BandwidthLimiter: backupRequest.Limiter,
} }
@ -698,41 +691,3 @@ func hasServerSideSource(executionJob *jobs.Job) bool {
return false return false
} }
// shouldForceFullBackup meldet, ob dieser Lauf die Quelle vollstaendig lesen soll.
//
// Zwei Gruende, beide betrieblich:
//
// - **Immer voll.** Wer sein Backup ausser Haus gibt oder auf einen
// Datentraeger schreibt, der einzeln weggetragen wird, will nicht, dass ein
// Wiederherstellungspunkt an einem frueheren haengt.
// - **Woechentlich voll.** Der uebliche Kompromiss: unter der Woche schnell,
// an einem festen Tag einmal vollstaendig.
//
// Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Ohne diese
// Umrechnung liefe derselbe Auftrag auf zwei Servern an verschiedenen Tagen
// voll — und ein Betreiber in Berlin bekaeme seine Vollsicherung am
// Donnerstagabend, weil der Server in UTC rechnet.
func shouldForceFullBackup(executionJob *jobs.Job, currentTime time.Time) bool {
if executionJob == nil {
return false
}
if executionJob.BackupMode == jobs.BackupModeAlwaysFull {
return true
}
if executionJob.FullBackupWeekday == nil {
return false
}
scheduleLocation := time.UTC
if executionJob.Schedule.TimeZone != "" {
if loadedLocation, loadError := time.LoadLocation(executionJob.Schedule.TimeZone); loadError == nil {
scheduleLocation = loadedLocation
}
}
return currentTime.In(scheduleLocation).Weekday() == *executionJob.FullBackupWeekday
}

View File

@ -82,22 +82,6 @@ type JobSource struct {
ExcludePatterns []string `json:"exclude_patterns,omitempty"` ExcludePatterns []string `json:"exclude_patterns,omitempty"`
} }
// BackupMode benennt die Sicherungsart eines Auftrags.
type BackupMode string
const (
// BackupModeIncremental sichert nach dem ersten Lauf inkrementell.
BackupModeIncremental BackupMode = "incremental"
// BackupModeAlwaysFull liest bei jedem Lauf die gesamte Quelle.
//
// Der Platzbedarf steigt dadurch **nicht** nennenswert: Unveraenderte
// Bloecke werden dedupliziert und liegen weiterhin nur einmal im
// Repository. Was steigt, ist die Laufzeit — jeder Lauf liest, hasht,
// komprimiert und verschluesselt alles neu. Wer das verwechselt, plant
// seinen Nachtbetrieb falsch.
BackupModeAlwaysFull BackupMode = "always_full"
)
// Job ist ein Sicherungsauftrag. // Job ist ein Sicherungsauftrag.
type Job struct { type Job struct {
// ID ist der öffentliche Bezeichner. // ID ist der öffentliche Bezeichner.
@ -126,18 +110,6 @@ type Job struct {
RecoveryTimeObjective time.Duration `json:"rto,omitempty"` RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt. // BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
// BackupMode bestimmt, ob nach dem ersten Lauf inkrementell gesichert wird.
//
// Leer bedeutet `incremental` — das bisherige Verhalten und der richtige
// Standard: Der Gewinn ist Lesezeit, und die ist nach dem ersten Lauf der
// begrenzende Faktor.
BackupMode BackupMode `json:"backup_mode,omitempty"`
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
//
// nil bedeutet: keiner. Gerechnet in der Zeitzone des Zeitplans; ohne
// Angabe in UTC — sonst liefe derselbe Auftrag auf zwei Servern an
// verschiedenen Tagen voll.
FullBackupWeekday *time.Weekday `json:"full_backup_weekday,omitempty"`
// MaximumConcurrency begrenzt gleichzeitige Läufe dieses Auftrags. // MaximumConcurrency begrenzt gleichzeitige Läufe dieses Auftrags.
MaximumConcurrency int `json:"max_concurrency"` MaximumConcurrency int `json:"max_concurrency"`
// RetryPolicy beschreibt das Wiederholungsverhalten. // RetryPolicy beschreibt das Wiederholungsverhalten.

Some files were not shown because too many files have changed in this diff Show More