Compare commits
No commits in common. "main" and "v1.0.0-rc6" have entirely different histories.
main
...
v1.0.0-rc6
149
CHANGELOG.md
149
CHANGELOG.md
@ -1,154 +1,5 @@
|
||||
# Änderungen
|
||||
|
||||
## V1 — Release Candidate 9, 18. August 2026
|
||||
|
||||
### Sicherungsart je Auftrag
|
||||
|
||||
Bisher entschied die Anlage allein: Liegt ein Elternbackup vor, wird
|
||||
inkrementell gesichert. Jetzt wählbar —
|
||||
|
||||
- **inkrementell** (Standard, bisheriges Verhalten),
|
||||
- **immer voll**, oder
|
||||
- inkrementell **mit festem Volltag**, etwa „immer freitags".
|
||||
|
||||
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
|
||||
Server in UTC, bekäme ein Betreiber in Berlin seine Vollsicherung am
|
||||
Donnerstagabend und wunderte sich, warum sie freitags fehlt.
|
||||
|
||||
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert** — unveränderte
|
||||
Blöcke werden dedupliziert. Was steigt, ist die Laufzeit. Das steht so in der
|
||||
Maske, weil es die häufigste Verwechslung ist.
|
||||
|
||||
Migration 000014 mit drei CHECKs. Der dritte lehnt „immer voll" zusammen mit
|
||||
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Das Aufnahme-Token eines Agenten zeigte „undefined".** Das Feld heißt
|
||||
`token`, nicht `enrollment_token` — Letzteres ist der Name im *Anfrage*körper
|
||||
der Registrierung. Der dritte Formfehler dieser Art; alle konsumierten
|
||||
Endpunkte sind jetzt gegen den laufenden Dienst abgeglichen statt aus der
|
||||
Struktur abgeleitet.
|
||||
|
||||
### Aufnahmedialog mit Anleitung
|
||||
|
||||
Vollständige Anleitung für **Linux und Windows**, umschaltbar, mit fertig
|
||||
ausgefüllten Befehlen — Serveradresse und Token eingesetzt, jeder Schritt
|
||||
einzeln kopierbar. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
||||
jemand `<token>` wörtlich einsetzt.
|
||||
|
||||
Dazu die beiden Stolperstellen: `--state` erwartet eine **Datei**, und der
|
||||
Agent braucht Schreibzugriff auf das Repository.
|
||||
|
||||
### update.sh rüstet die Wiederherstellungsfläche nach
|
||||
|
||||
Sie kam mit rc8 dazu; eine Anlage aus einer älteren Fassung hat sie nicht. Ohne
|
||||
sie scheitert jede Wiederherstellung an `ProtectSystem=strict`. `update.sh`
|
||||
legt sie jetzt an und trägt sie in `ReadWritePaths` ein — ein Schritt, den man
|
||||
von Hand ausführen muss, wird übersehen und fällt erst im Ernstfall auf.
|
||||
|
||||
## V1 — Release Candidate 8, 18. August 2026
|
||||
|
||||
Wiederherstellung von Dateien und Ordnern mit Auswahl statt Textfeld — und die
|
||||
Erklärung, warum vorher gar keine Wiederherstellung funktionierte.
|
||||
|
||||
### Warum keine Wiederherstellung ging
|
||||
|
||||
Nicht die Rechte des Zielverzeichnisses, sondern die Härtung des Dienstes: Er
|
||||
läuft mit `ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
|
||||
Sicherungsordner. Jedes Ziel außerhalb endete mit `mkdir: permission denied` —
|
||||
und zwar **nach** der Vorabprüfung, an der unangenehmsten Stelle. `/tmp`
|
||||
scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein eigenes `/tmp`, und
|
||||
was dort landet, ist von außen unsichtbar.
|
||||
|
||||
`setup.sh` legt jetzt `/srv/syncova-restore` an und trägt es ein;
|
||||
`--wiederherstellungsziel` ergänzt weitere. Der Ort liegt unter `/srv`, weil
|
||||
`/var/lib` auf der Sperrliste des Zielschutzes steht — beide Regeln zugleich zu
|
||||
erfüllen lässt genau `/srv` übrig.
|
||||
|
||||
### Auswahl statt Textfeld
|
||||
|
||||
- **Ordnerbaum für das Ziel.** Er meldet je Verzeichnis, ob der Dienst dort
|
||||
schreiben darf — **gemessen** durch eine Probedatei, nicht aus den Rechtebits
|
||||
abgeleitet. Gesperrte Orte werden gezeigt, nicht versteckt: Sonst bliebe
|
||||
offen, warum ein Pfad fehlt.
|
||||
- **Browser für den Backup-Inhalt.** Ordner **und** einzelne Dateien lassen
|
||||
sich zurückholen. Der Baum entsteht aus den Pfaden, nicht aus
|
||||
Verzeichniseinträgen — ein Manifest kann eine Datei enthalten, deren
|
||||
Elternordner nicht als eigener Eintrag vorliegt.
|
||||
|
||||
Zwei neue Endpunkte, der eingefrorene Vertrag ist entsprechend erweitert:
|
||||
`GET /filesystem/browse` und `GET /backups/{id}/contents`.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **„can't access property toLocaleString, chunks_checked is undefined"** beim
|
||||
Integritätslauf. Die Ergebnisse liegen unter `details`, und die Felder heißen
|
||||
`missing_chunks`/`corrupted_chunks`. Betrifft alle vier Prüfendpunkte — sie
|
||||
tragen dieselbe Hülle. Derselbe Fehler wie zuvor bei `/retention-policies`:
|
||||
die Antwortform angenommen statt geprüft. Alle konsumierten Endpunkte sind
|
||||
jetzt gegen den laufenden Dienst abgeglichen.
|
||||
|
||||
### Geist Mono liegt im Paket
|
||||
|
||||
Drei Schnitte, 128 KB, OFL-Lizenz dabei. Ausgeliefert vom eigenen Ursprung —
|
||||
das verlangt die CSP, und ein Backup-Server, dessen Oberfläche von der
|
||||
Erreichbarkeit eines CDN abhängt, wäre auch ohne CSP falsch.
|
||||
|
||||
### Bekannte Grenze
|
||||
|
||||
**Eine Auswahl je Lauf**, kein Mehrfachhaken. Eine Liste ausgewählter Pfade
|
||||
kennt die API nicht; mehrere Läufe hintereinander ergäben mehrere Ausgänge, und
|
||||
ein „teilweise fehlgeschlagen" ließe sich dann nicht mehr erklären.
|
||||
|
||||
## V1 — Release Candidate 7, 18. August 2026
|
||||
|
||||
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **`/retention` zeigte einen schwarzen Bildschirm.** `GET /retention-policies`
|
||||
liefert ein Objekt `{policies, predefined}` — als einziger von zehn geprüften
|
||||
Listenendpunkten. Die Oberfläche behandelte es als Array; `map` gibt es auf
|
||||
einem Objekt nicht, React hängte den ganzen Baum aus. Der Regressionstest
|
||||
füttert jetzt die **echte** Antwortform; ein Test mit einem Array hätte den
|
||||
Fehler nie gefunden, und genau das war passiert.
|
||||
- **Jedes Neuladen führte zurück zur Anmeldung.** Die Tokens lagen nur im
|
||||
Arbeitsspeicher.
|
||||
- **Ein Fehler in einer Komponente schwärzte die ganze Konsole.** Jetzt sitzt
|
||||
eine Fehlergrenze um den Seiteninhalt: Menü und Kopfzeile bleiben stehen, der
|
||||
Fehlertext ist lesbar und kopierbar.
|
||||
|
||||
### Sitzung
|
||||
|
||||
Sie überlebt ein Neuladen und endet nach **30 Minuten** — gerechnet als frühere
|
||||
von zwei Grenzen: einer harten Obergrenze ab Anmeldung, die keine Interaktion
|
||||
verschiebt, und einer Untätigkeitsgrenze. Die verbleibende Zeit läuft neben
|
||||
„Abmelden" und wird unter fünf Minuten auffällig.
|
||||
|
||||
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
|
||||
bestätigt: Wer beim Vermerken einer Interaktion die Obergrenze mitverschiebt,
|
||||
macht aus „30 Minuten" ein „unbegrenzt, solange die Maus wackelt".
|
||||
|
||||
### Aussehen
|
||||
|
||||
Farben, Radien, Schatten und Schrift aus dem Preset `b5vnF8SMi`: violett als
|
||||
Handlungsfarbe, Radius 0, schattenlos, durchgehend Geist Mono. Die
|
||||
**Statusfarben bleiben** — das Preset kennt keine, und ohne sie ließe sich ein
|
||||
Teilfehler nicht von einem Erfolg unterscheiden.
|
||||
|
||||
Dazu **Umlaute auf allen Seiten** (vorher durchgehend ae/oe/ue/ss), **33
|
||||
Erläuterungen von Absätzen auf einen Satz gekürzt** und neue Module: acht
|
||||
Schnellzugriffe auf der Übersicht sowie sechs mitgelieferte
|
||||
Aufbewahrungsvorlagen als Kacheln — die lieferte der Server schon immer mit,
|
||||
die Oberfläche warf sie bisher weg.
|
||||
|
||||
### Unverändert offen
|
||||
|
||||
Windows-Dienst, systemd-Einheit des Agenten und der Proxmox-Bootmeilenstein sind
|
||||
gebaut, aber nie auf echter Hardware gefahren. Geist Mono wird nicht
|
||||
mitgeliefert; ohne die Schrift auf dem Gerät greift die System-Monospace.
|
||||
|
||||
## V1 — Release Candidate 6, 18. August 2026
|
||||
|
||||
**Die Weboberfläche ist eine vollständige Verwaltungskonsole geworden.**
|
||||
|
||||
@ -1,422 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/syncova/syncova/packages/platform/logging"
|
||||
"github.com/syncova/syncova/packages/recovery"
|
||||
"github.com/syncova/syncova/packages/repository"
|
||||
)
|
||||
|
||||
// browseHandler bedient die beiden Blätterendpunkte.
|
||||
//
|
||||
// Sie sind für eine Wiederherstellung gebaut und lösen zwei Probleme, die sich
|
||||
// mit einem Textfeld nicht lösen lassen:
|
||||
//
|
||||
// 1. **Wohin darf zurückgeschrieben werden?** Der Dienst läuft mit
|
||||
// `ProtectSystem=strict`; außerhalb weniger Pfade ist das Dateisystem für
|
||||
// ihn schreibgeschützt. Ein Betreiber tippt „/opt/test", bekommt
|
||||
// „permission denied" und hat keine Möglichkeit zu erkennen, welcher Ort
|
||||
// überhaupt in Frage kommt. `GET /filesystem/browse` beantwortet genau das
|
||||
// — es meldet je Verzeichnis, ob der **Dienst** dort schreiben kann,
|
||||
// geprüft durch einen tatsächlichen Schreibversuch.
|
||||
//
|
||||
// 2. **Was steckt in dem Backup?** Ohne Inhaltsverzeichnis lässt sich weder
|
||||
// eine einzelne Datei noch ein Unterordner gezielt zurückholen. `GET
|
||||
// /backups/{id}/contents` liefert das Manifest als Ebene eines Baums.
|
||||
//
|
||||
// Beide lesen nur.
|
||||
type browseHandler struct {
|
||||
restoreHandlerReference *restoreHandler
|
||||
targetGuard *recovery.TargetGuard
|
||||
}
|
||||
|
||||
// filesystemEntry ist ein Eintrag des Dateisystems.
|
||||
type filesystemEntry struct {
|
||||
// Name ist der letzte Pfadbestandteil.
|
||||
Name string `json:"name"`
|
||||
// Path ist der vollständige absolute Pfad.
|
||||
Path string `json:"path"`
|
||||
// IsDirectory unterscheidet Verzeichnis von Datei.
|
||||
IsDirectory bool `json:"is_directory"`
|
||||
// SizeBytes ist die Größe bei Dateien.
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
// IsWritable meldet, ob der Dienst hier anlegen darf.
|
||||
//
|
||||
// Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten:
|
||||
// Unter `ProtectSystem=strict` sagen die Bits nichts über das aus, was der
|
||||
// Namensraum zulässt.
|
||||
IsWritable bool `json:"is_writable"`
|
||||
// ForbiddenReason nennt den Grund, wenn der Zielschutz den Ort ausschließt.
|
||||
ForbiddenReason string `json:"forbidden_reason,omitempty"`
|
||||
}
|
||||
|
||||
// browseFilesystemResponse ist die Antwort auf das Blättern im Dateisystem.
|
||||
type browseFilesystemResponse struct {
|
||||
// Path ist das aufgelistete Verzeichnis.
|
||||
Path string `json:"path"`
|
||||
// ParentPath ist das übergeordnete Verzeichnis; leer bei der Wurzel.
|
||||
ParentPath string `json:"parent_path,omitempty"`
|
||||
// Entries sind die enthaltenen Verzeichnisse.
|
||||
Entries []filesystemEntry `json:"entries"`
|
||||
// SuggestedPaths sind Orte, an denen der Dienst nachweislich schreiben darf.
|
||||
//
|
||||
// Sie stehen in der Antwort, damit die Oberfläche einen brauchbaren
|
||||
// Startpunkt anbieten kann, statt den Betreiber suchen zu lassen.
|
||||
SuggestedPaths []string `json:"suggested_paths,omitempty"`
|
||||
}
|
||||
|
||||
// backupContentEntry ist ein Eintrag im Inhaltsverzeichnis eines Backups.
|
||||
type backupContentEntry struct {
|
||||
Name string `json:"name"`
|
||||
// Path ist der Pfad im Manifest — genau der Wert, den eine
|
||||
// Wiederherstellung als `path_prefix` erwartet.
|
||||
Path string `json:"path"`
|
||||
IsDirectory bool `json:"is_directory"`
|
||||
EntryType string `json:"entry_type"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
ModifiedAt string `json:"modified_at,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// ChildCount ist die Zahl der Einträge unterhalb eines Verzeichnisses.
|
||||
ChildCount int `json:"child_count,omitempty"`
|
||||
// TotalBytes ist die Datenmenge unterhalb eines Verzeichnisses.
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// browseBackupResponse ist die Antwort auf das Blättern im Backup.
|
||||
type browseBackupResponse struct {
|
||||
BackupID string `json:"backup_id"`
|
||||
Path string `json:"path"`
|
||||
ParentPath string `json:"parent_path,omitempty"`
|
||||
// Entries sind die Einträge auf dieser Ebene.
|
||||
Entries []backupContentEntry `json:"entries"`
|
||||
// TotalEntryCount ist die Zahl aller Einträge im Backup.
|
||||
TotalEntryCount int `json:"total_entry_count"`
|
||||
}
|
||||
|
||||
// handleBrowseFilesystem bedient GET /filesystem/browse.
|
||||
func (handler *browseHandler) handleBrowseFilesystem(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
||||
|
||||
requestedPath := strings.TrimSpace(request.URL.Query().Get("path"))
|
||||
if requestedPath == "" {
|
||||
requestedPath = "/"
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(requestedPath) {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Der Pfad muss absolut sein."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Symlinks werden aufgelöst, bevor gelesen wird: Sonst ließe sich über
|
||||
// einen Verweis an jeder Prüfung vorbei in ein fremdes Verzeichnis sehen.
|
||||
resolvedPath, resolveError := filepath.EvalSymlinks(filepath.Clean(requestedPath))
|
||||
if resolveError != nil {
|
||||
resolvedPath = filepath.Clean(requestedPath)
|
||||
}
|
||||
|
||||
directoryEntries, readError := os.ReadDir(resolvedPath)
|
||||
if readError != nil {
|
||||
if errors.Is(readError, os.ErrNotExist) {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewNotFoundError("Das Verzeichnis wurde nicht gefunden."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Das Verzeichnis lässt sich nicht lesen: "+readError.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entries := make([]filesystemEntry, 0, len(directoryEntries))
|
||||
|
||||
for _, directoryEntry := range directoryEntries {
|
||||
// Nur Verzeichnisse: Ein Wiederherstellungsziel ist immer ein
|
||||
// Verzeichnis, und die Dateien daneben wären nur Rauschen.
|
||||
if !directoryEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Versteckte Verzeichnisse bleiben draußen. Wer eines braucht, tippt
|
||||
// den Pfad — die Liste soll den Normalfall zeigen.
|
||||
if strings.HasPrefix(directoryEntry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
childPath := filepath.Join(resolvedPath, directoryEntry.Name())
|
||||
|
||||
entry := filesystemEntry{
|
||||
Name: directoryEntry.Name(),
|
||||
Path: childPath,
|
||||
IsDirectory: true,
|
||||
IsWritable: directoryIsWritable(childPath),
|
||||
}
|
||||
|
||||
if handler.targetGuard != nil {
|
||||
if guardError := handler.targetGuard.Validate(childPath); guardError != nil {
|
||||
entry.ForbiddenReason = guardError.Error()
|
||||
// Ein gesperrter Ort ist nie ein zulässiges Ziel, auch wenn das
|
||||
// Dateisystem ihn zuließe.
|
||||
entry.IsWritable = false
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
||||
})
|
||||
|
||||
response := browseFilesystemResponse{
|
||||
Path: resolvedPath,
|
||||
Entries: entries,
|
||||
SuggestedPaths: writableSuggestions(handler.targetGuard),
|
||||
}
|
||||
|
||||
if resolvedPath != "/" {
|
||||
response.ParentPath = filepath.Dir(resolvedPath)
|
||||
}
|
||||
|
||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleBrowseBackupContents bedient GET /backups/{id}/contents.
|
||||
func (handler *browseHandler) handleBrowseBackupContents(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
||||
|
||||
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||
if parseError != nil {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Die Backup-Kennung ist keine gültige UUID."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
repositoryPath, backupIDInRepository, resolveError := handler.restoreHandlerReference.resolveBackup(
|
||||
request.Context(), backupIdentifier)
|
||||
if resolveError != nil {
|
||||
WriteError(responseWriter, request, requestLogger, resolveError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Schreibgeschützt: Ein Inhaltsverzeichnis liest nur und soll neben einer
|
||||
// laufenden Sicherung erstellt werden können.
|
||||
openedRepository, openError := repository.Open(request.Context(), repositoryPath,
|
||||
repository.OpenOptions{ReadOnly: true}, handler.restoreHandlerReference.logger)
|
||||
if openError != nil {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewServiceUnavailableError("Das Repository des Backups ist derzeit nicht erreichbar."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = openedRepository.Close() }()
|
||||
|
||||
backupManifest, manifestError := openedRepository.ReadManifest(request.Context(), backupIDInRepository)
|
||||
if manifestError != nil {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Das Manifest des Backups lässt sich nicht lesen: "+manifestError.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
currentPath := strings.Trim(strings.TrimSpace(request.URL.Query().Get("path")), "/")
|
||||
|
||||
response := browseBackupResponse{
|
||||
BackupID: backupIdentifier.String(),
|
||||
Path: currentPath,
|
||||
Entries: collectLevel(backupManifest.Entries, currentPath),
|
||||
TotalEntryCount: len(backupManifest.Entries),
|
||||
}
|
||||
|
||||
if currentPath != "" {
|
||||
parentPath := path.Dir(currentPath)
|
||||
if parentPath == "." {
|
||||
parentPath = ""
|
||||
}
|
||||
|
||||
response.ParentPath = parentPath
|
||||
}
|
||||
|
||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// collectLevel bildet aus einem flachen Manifest eine Verzeichnisebene.
|
||||
//
|
||||
// Das Manifest kennt keine Baumstruktur, sondern eine flache Liste von Pfaden.
|
||||
// Der Baum entsteht hier — und zwar **ohne** sich auf Verzeichniseinträge zu
|
||||
// verlassen: Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis
|
||||
// nicht als eigener Eintrag vorliegt. Wer nur die Einträge vom Typ `directory`
|
||||
// auflistet, verliert dann ganze Teilbäume.
|
||||
func collectLevel(manifestEntries []repository.ManifestEntry, currentPath string) []backupContentEntry {
|
||||
prefix := ""
|
||||
if currentPath != "" {
|
||||
prefix = currentPath + "/"
|
||||
}
|
||||
|
||||
// Verzeichnisse werden über ihre Kinder erkannt und dabei gleich
|
||||
// aufsummiert: Ein Betreiber will vor dem Zurückholen wissen, wie viel an
|
||||
// einem Ordner hängt.
|
||||
directories := make(map[string]*backupContentEntry)
|
||||
files := make([]backupContentEntry, 0, 32)
|
||||
|
||||
for _, manifestEntry := range manifestEntries {
|
||||
entryPath := strings.Trim(manifestEntry.Path, "/")
|
||||
|
||||
if prefix != "" && !strings.HasPrefix(entryPath, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
remainder := strings.TrimPrefix(entryPath, prefix)
|
||||
if remainder == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
separatorIndex := strings.Index(remainder, "/")
|
||||
|
||||
if separatorIndex < 0 {
|
||||
// Direktes Kind dieser Ebene.
|
||||
if manifestEntry.EntryType == "directory" {
|
||||
directoryPath := prefix + remainder
|
||||
|
||||
if _, exists := directories[remainder]; !exists {
|
||||
directories[remainder] = &backupContentEntry{
|
||||
Name: remainder,
|
||||
Path: directoryPath,
|
||||
IsDirectory: true,
|
||||
EntryType: "directory",
|
||||
Mode: manifestEntry.Mode,
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, backupContentEntry{
|
||||
Name: remainder,
|
||||
Path: entryPath,
|
||||
IsDirectory: false,
|
||||
EntryType: manifestEntry.EntryType,
|
||||
SizeBytes: manifestEntry.SizeBytes,
|
||||
ModifiedAt: formatOptionalTime(manifestEntry),
|
||||
Mode: manifestEntry.Mode,
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Ein Nachfahre: Er belegt, dass es das Verzeichnis gibt, auch wenn
|
||||
// kein eigener Eintrag dafür existiert.
|
||||
directoryName := remainder[:separatorIndex]
|
||||
|
||||
existing, exists := directories[directoryName]
|
||||
if !exists {
|
||||
existing = &backupContentEntry{
|
||||
Name: directoryName,
|
||||
Path: prefix + directoryName,
|
||||
IsDirectory: true,
|
||||
EntryType: "directory",
|
||||
}
|
||||
directories[directoryName] = existing
|
||||
}
|
||||
|
||||
existing.ChildCount++
|
||||
existing.TotalBytes += manifestEntry.SizeBytes
|
||||
}
|
||||
|
||||
entries := make([]backupContentEntry, 0, len(directories)+len(files))
|
||||
|
||||
for _, directoryEntry := range directories {
|
||||
entries = append(entries, *directoryEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, files...)
|
||||
|
||||
// Verzeichnisse zuerst, dann alphabetisch — die Reihenfolge, die jeder
|
||||
// Dateimanager verwendet.
|
||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
||||
if entries[firstIndex].IsDirectory != entries[secondIndex].IsDirectory {
|
||||
return entries[firstIndex].IsDirectory
|
||||
}
|
||||
|
||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// formatOptionalTime gibt einen Zeitstempel aus, sofern gesetzt.
|
||||
func formatOptionalTime(manifestEntry repository.ManifestEntry) string {
|
||||
if manifestEntry.ModifiedAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return manifestEntry.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
// directoryIsWritable prüft durch einen echten Schreibversuch.
|
||||
//
|
||||
// Die Rechtebits zu lesen genügt nicht: Unter `ProtectSystem=strict` ist das
|
||||
// Dateisystem für den Dienst außerhalb weniger Pfade schreibgeschützt, und
|
||||
// davon steht nichts im Modus. Genau diese Lücke hat dazu geführt, dass eine
|
||||
// Wiederherstellung nach `/opt/test` mit „permission denied" endete, obwohl
|
||||
// das Verzeichnis dem Anschein nach beschreibbar war.
|
||||
func directoryIsWritable(directoryPath string) bool {
|
||||
probeFile, createError := os.CreateTemp(directoryPath, ".syncova-schreibprobe-*")
|
||||
if createError != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
probeName := probeFile.Name()
|
||||
|
||||
_ = probeFile.Close()
|
||||
_ = os.Remove(probeName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// writableSuggestions nennt Orte, an denen der Dienst nachweislich schreiben darf.
|
||||
//
|
||||
// Ohne diese Liste sucht ein Betreiber im Blindflug: Die meisten Verzeichnisse
|
||||
// eines gehärteten Systems scheiden aus, und welche übrig bleiben, hängt an der
|
||||
// systemd-Einheit — nicht an etwas, das man dem Dateisystem ansieht.
|
||||
func writableSuggestions(targetGuard *recovery.TargetGuard) []string {
|
||||
// Reihenfolge ist Absicht: Zuerst die Flaeche, die `setup.sh` anlegt und
|
||||
// in ReadWritePaths eintraegt. `/var/lib` steht bewusst nicht dabei — es
|
||||
// ist im Zielschutz gesperrt.
|
||||
candidates := []string{
|
||||
"/srv/syncova-restore",
|
||||
"/srv",
|
||||
"/var/tmp",
|
||||
"/home",
|
||||
}
|
||||
|
||||
suggestions := make([]string, 0, len(candidates))
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if targetGuard != nil {
|
||||
if guardError := targetGuard.Validate(candidate); guardError != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if directoryIsWritable(candidate) {
|
||||
suggestions = append(suggestions, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/syncova/syncova/packages/repository"
|
||||
)
|
||||
|
||||
// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum
|
||||
// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen.
|
||||
//
|
||||
// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als
|
||||
// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer
|
||||
// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze
|
||||
// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar.
|
||||
func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) {
|
||||
manifestEntries := []repository.ManifestEntry{
|
||||
// Kein Eintrag fuer "berichte" selbst.
|
||||
{Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900},
|
||||
{Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100},
|
||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 6},
|
||||
}
|
||||
|
||||
rootLevel := collectLevel(manifestEntries, "")
|
||||
|
||||
if len(rootLevel) != 2 {
|
||||
testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel))
|
||||
}
|
||||
|
||||
// Verzeichnisse stehen vorn.
|
||||
if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" {
|
||||
testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0])
|
||||
}
|
||||
|
||||
// Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem
|
||||
// Zurueckholen wissen, wie viel an einem Ordner haengt.
|
||||
if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 {
|
||||
testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte",
|
||||
rootLevel[0].ChildCount, rootLevel[0].TotalBytes)
|
||||
}
|
||||
|
||||
// Und eine Ebene tiefer erscheinen die Jahresordner.
|
||||
deeperLevel := collectLevel(manifestEntries, "berichte")
|
||||
|
||||
if len(deeperLevel) != 2 {
|
||||
testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest.
|
||||
//
|
||||
// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine
|
||||
// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat.
|
||||
func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) {
|
||||
manifestEntries := []repository.ManifestEntry{
|
||||
{Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1},
|
||||
{Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1},
|
||||
}
|
||||
|
||||
level := collectLevel(manifestEntries, "dokumente")
|
||||
|
||||
if len(level) != 1 || level[0].Name != "a.txt" {
|
||||
testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei.
|
||||
func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) {
|
||||
modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
level := collectLevel([]repository.ManifestEntry{
|
||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime},
|
||||
}, "")
|
||||
|
||||
if len(level) != 1 {
|
||||
testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level))
|
||||
}
|
||||
|
||||
// Der Pfad ist genau der Wert, den eine Wiederherstellung als
|
||||
// `path_prefix` erwartet — eine Abweichung faellt sonst erst beim
|
||||
// Zurueckschreiben auf.
|
||||
if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" {
|
||||
testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0])
|
||||
}
|
||||
|
||||
if level[0].ModifiedAt != "2026-08-18T10:00:00Z" {
|
||||
testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt)
|
||||
}
|
||||
}
|
||||
@ -122,5 +122,3 @@ POST /api/v1/users users.write
|
||||
POST /api/v1/users/{id}/mfa/disable users.write
|
||||
POST /api/v1/verification verification.write
|
||||
POST /api/v1/verification/{id}/cancel verification.write
|
||||
GET /api/v1/filesystem/browse restores.read
|
||||
GET /api/v1/backups/{id}/contents restores.read
|
||||
|
||||
@ -89,14 +89,6 @@ type jobRequest struct {
|
||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||
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 int `json:"max_concurrency,omitempty"`
|
||||
}
|
||||
@ -135,14 +127,6 @@ type jobResponse struct {
|
||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||
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 int `json:"max_concurrency"`
|
||||
// NextRunAt ist der nächste Zeitpunkt in UTC.
|
||||
|
||||
@ -57,8 +57,6 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job,
|
||||
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
|
||||
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
||||
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
||||
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
|
||||
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
|
||||
MaximumConcurrency: maximumConcurrency,
|
||||
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
||||
CreatedBy: &creatorID,
|
||||
@ -154,8 +152,6 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse {
|
||||
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
||||
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
||||
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
||||
BackupMode: string(sourceJob.BackupMode),
|
||||
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
|
||||
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
||||
NextRunAt: sourceJob.NextRunAt,
|
||||
LastRunAt: sourceJob.LastRunAt,
|
||||
@ -194,33 +190,3 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
|
||||
|
||||
return scheduleData
|
||||
}
|
||||
|
||||
// weekdayFromPayload uebersetzt einen Wochentag aus der Anfrage.
|
||||
//
|
||||
// Ein Wert ausserhalb von 0..6 wird verworfen statt gekappt: Ein
|
||||
// stillschweigend auf Sonntag gesetzter Montag waere ein Fehler, den niemand
|
||||
// bemerkt — die Vollsicherung liefe dann am falschen Tag.
|
||||
func weekdayFromPayload(requestedWeekday *int) *time.Weekday {
|
||||
if requestedWeekday == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if *requestedWeekday < 0 || *requestedWeekday > 6 {
|
||||
return nil
|
||||
}
|
||||
|
||||
convertedWeekday := time.Weekday(*requestedWeekday)
|
||||
|
||||
return &convertedWeekday
|
||||
}
|
||||
|
||||
// weekdayToPayload uebersetzt einen Wochentag fuer die Antwort.
|
||||
func weekdayToPayload(storedWeekday *time.Weekday) *int {
|
||||
if storedWeekday == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
convertedValue := int(*storedWeekday)
|
||||
|
||||
return &convertedValue
|
||||
}
|
||||
|
||||
@ -377,19 +377,6 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
|
||||
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
||||
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).
|
||||
|
||||
@ -1,26 +1,19 @@
|
||||
/**
|
||||
* Wurzelkomponente der Syncova-Oberfläche.
|
||||
* Wurzelkomponente der Syncova-Oberflaeche.
|
||||
*
|
||||
* Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt
|
||||
* letztere auf die Bereiche aus PROMPT.md §28.
|
||||
*
|
||||
* 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 —
|
||||
* eine Router-Bibliothek wäre hier mehr Abhängigkeit als Nutzen.
|
||||
* (`jobs/<id>`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen —
|
||||
* 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 { AppShell } from './components/layout/AppShell';
|
||||
import { ErrorBoundary } from './components/layout/ErrorBoundary';
|
||||
import { LoadingState, ToastProvider } from './components/ui';
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
getAccessToken,
|
||||
hasResumableSession,
|
||||
logout,
|
||||
} from './features/auth/authApi';
|
||||
import { useSession } from './features/auth/useSession';
|
||||
import { ToastProvider } from './components/ui';
|
||||
import { getAccessToken, logout } from './features/auth/authApi';
|
||||
import { LoginPage } from './features/auth/LoginPage';
|
||||
import { AlertsPage } from './features/alerts/AlertsPage';
|
||||
import { DashboardPage } from './features/dashboard/DashboardPage';
|
||||
@ -49,64 +42,16 @@ import { useCurrentPage } from './navigation/useCurrentPage';
|
||||
import type { CurrentUser } from './types/auth';
|
||||
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);
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
|
||||
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);
|
||||
|
||||
// Beim Start wird geprüft, ob eine Sitzung fortgesetzt werden kann. Solange
|
||||
// das läuft, erscheint weder Anmeldemaske noch Inhalt — sonst blitzte nach
|
||||
// jedem Neuladen kurz die Anmeldung auf.
|
||||
const [isResuming, setIsResuming] = useState(() => hasResumableSession());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResuming) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
void fetchCurrentUser()
|
||||
.then((resumedUser) => {
|
||||
if (!isCancelled) {
|
||||
setAuthenticatedUser(resumedUser);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Der Server hat das Token abgelehnt — etwa nach einer Kontosperre.
|
||||
// Dann gilt die Sitzung als beendet, unabhängig von der lokalen Uhr.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsResuming(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [isResuming]);
|
||||
|
||||
const handleSessionExpired = useCallback(() => {
|
||||
setAuthenticatedUser(null);
|
||||
void logout();
|
||||
}, []);
|
||||
|
||||
const sessionClock = useSession(authenticatedUser !== null, handleSessionExpired);
|
||||
|
||||
if (isResuming) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-page">
|
||||
<LoadingState label="Sitzung wird fortgesetzt …" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authenticatedUser === null) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
@ -122,7 +67,7 @@ export function App(): React.JSX.Element {
|
||||
|
||||
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 {
|
||||
setOpenObjectIdentifier(null);
|
||||
navigateToPage(pageIdentifier);
|
||||
@ -135,41 +80,33 @@ export function App(): React.JSX.Element {
|
||||
onNavigate={handleNavigate}
|
||||
grantedPermissions={grantedPermissions}
|
||||
userName={authenticatedUser.username}
|
||||
sessionClock={sessionClock}
|
||||
onLogout={() => void handleLogout()}
|
||||
>
|
||||
{/* Die Grenze sitzt um den Inhalt: Ein Fehler auf einer Seite lässt
|
||||
Menü und Kopfzeile stehen, statt die Konsole zu schwärzen. */}
|
||||
<ErrorBoundary resetKey={`${currentPageId}|${openObjectIdentifier ?? ''}`}>
|
||||
<CurrentPageContent
|
||||
currentPageId={currentPageId}
|
||||
grantedPermissions={grantedPermissions}
|
||||
currentUser={authenticatedUser}
|
||||
openObjectIdentifier={openObjectIdentifier}
|
||||
onOpenObject={setOpenObjectIdentifier}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<CurrentPageContent
|
||||
currentPageId={currentPageId}
|
||||
grantedPermissions={grantedPermissions}
|
||||
currentUser={authenticatedUser}
|
||||
openObjectIdentifier={openObjectIdentifier}
|
||||
onOpenObject={setOpenObjectIdentifier}
|
||||
/>
|
||||
</AppShell>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeigt den Inhalt der gewählten Seite. */
|
||||
/** Zeigt den Inhalt der gewaehlten Seite. */
|
||||
function CurrentPageContent({
|
||||
currentPageId,
|
||||
grantedPermissions,
|
||||
currentUser,
|
||||
openObjectIdentifier,
|
||||
onOpenObject,
|
||||
onNavigate,
|
||||
}: {
|
||||
readonly currentPageId: string;
|
||||
readonly grantedPermissions: readonly string[];
|
||||
readonly currentUser: CurrentUser;
|
||||
readonly openObjectIdentifier: string | null;
|
||||
readonly onOpenObject: (objectIdentifier: string | null) => void;
|
||||
readonly onNavigate: (pageIdentifier: string) => void;
|
||||
}): React.JSX.Element {
|
||||
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
|
||||
|
||||
@ -177,14 +114,14 @@ function CurrentPageContent({
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
// Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne
|
||||
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche
|
||||
// Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne
|
||||
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche
|
||||
// Auskunft (PROMPT.md §42).
|
||||
if (!mayViewPage(pageDefinition, grantedPermissions)) {
|
||||
return (
|
||||
<UnavailablePage
|
||||
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 (
|
||||
<UnavailablePage
|
||||
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':
|
||||
return (
|
||||
<>
|
||||
<DashboardPage
|
||||
grantedPermissions={grantedPermissions}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<DashboardPage />
|
||||
<div className="mt-4">
|
||||
<SystemHealthPanel />
|
||||
</div>
|
||||
@ -304,7 +238,7 @@ function CurrentPageContent({
|
||||
return (
|
||||
<UnavailablePage
|
||||
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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
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 {
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status: statusCode,
|
||||
@ -14,7 +14,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
@ -35,7 +35,7 @@ describe('requestApi', () => {
|
||||
|
||||
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];
|
||||
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')));
|
||||
|
||||
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
|
||||
@ -79,24 +79,24 @@ describe('requestApi', () => {
|
||||
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
|
||||
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).
|
||||
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)));
|
||||
|
||||
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(
|
||||
'fetch',
|
||||
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(
|
||||
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
|
||||
);
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* HTTP-Client für die Syncova-API.
|
||||
* HTTP-Client fuer die Syncova-API.
|
||||
*
|
||||
* Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als
|
||||
* ApiError. Aufrufer müssen sich damit nicht mit HTTP-Details befassen und es
|
||||
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
|
||||
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
|
||||
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
|
||||
* (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). */
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Doppelklick oder ein wiederholter Versuch nach einer Zeitüberschreitung zwei
|
||||
* Aufträge — und bei einer Wiederherstellung zwei gleichzeitige Läufe in
|
||||
* Er gehoert an alle anlegenden und zerstoerenden Aufrufe. Ohne ihn erzeugt ein
|
||||
* Doppelklick oder ein wiederholter Versuch nach einer Zeitueberschreitung zwei
|
||||
* Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in
|
||||
* dasselbe Ziel.
|
||||
*/
|
||||
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
@ -28,8 +28,8 @@ const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
/**
|
||||
* Fehler einer API-Anfrage.
|
||||
*
|
||||
* Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
|
||||
* einen Vorfall gegenüber dem Betreiber eindeutig benennen kann.
|
||||
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
|
||||
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
/** Stabiler maschinenlesbarer Fehlercode. */
|
||||
@ -60,8 +60,8 @@ export class ApiError extends Error {
|
||||
/**
|
||||
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
|
||||
*
|
||||
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhält sie über
|
||||
* diese Funktion. Andernfalls entstünde ein Zirkelbezug zwischen dem Client und
|
||||
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
|
||||
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
|
||||
* dem Anmeldemodul, das seinerseits den Client verwendet.
|
||||
*/
|
||||
let accessTokenProvider: () => string | null = () => null;
|
||||
@ -71,24 +71,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void
|
||||
accessTokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
/** Fehlercode für eine nicht erreichbare API. */
|
||||
/** Fehlercode fuer eine nicht erreichbare API. */
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Rückfall deckt ältere Testumgebungen ab.
|
||||
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
|
||||
* Rueckfall deckt aeltere Testumgebungen ab.
|
||||
*/
|
||||
function createCorrelationId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
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.
|
||||
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
|
||||
}
|
||||
@ -97,22 +97,22 @@ function createCorrelationId(): string {
|
||||
export interface RequestOptions {
|
||||
/** HTTP-Methode; Standard ist GET. */
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
/** Optionaler Anfragekörper, der als JSON gesendet wird. */
|
||||
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
|
||||
body?: unknown;
|
||||
/** Signal zum Abbrechen der Anfrage. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Schlüssel für Idempotenz.
|
||||
* Schluessel fuer Idempotenz.
|
||||
*
|
||||
* `true` erzeugt einen zufälligen Schlüssel; eine Zeichenkette wird
|
||||
* unverändert verwendet, damit ein Wiederholungsversuch derselben Handlung
|
||||
* denselben Schlüssel trägt.
|
||||
* `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird
|
||||
* unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung
|
||||
* denselben Schluessel traegt.
|
||||
*/
|
||||
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".
|
||||
* @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
|
||||
// unauthentifiziert - der Server entscheidet dann über den Zugriff.
|
||||
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
|
||||
const accessToken = accessTokenProvider();
|
||||
if (accessToken !== null) {
|
||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
@ -154,7 +154,7 @@ export async function requestApi<TPayload>(
|
||||
method: requestMethod,
|
||||
headers: requestHeaders,
|
||||
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',
|
||||
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
||||
});
|
||||
@ -166,13 +166,13 @@ export async function requestApi<TPayload>(
|
||||
|
||||
throw new ApiError({
|
||||
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,
|
||||
requestId: correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
// 204 trägt per Definition keinen Körper.
|
||||
// 204 traegt per Definition keinen Koerper.
|
||||
if (httpResponse.status === 204) {
|
||||
return undefined as TPayload;
|
||||
}
|
||||
@ -183,19 +183,19 @@ export async function requestApi<TPayload>(
|
||||
} catch {
|
||||
throw new ApiError({
|
||||
code: MALFORMED_RESPONSE_CODE,
|
||||
message: 'Die Antwort des Servers war unverständlich.',
|
||||
message: 'Die Antwort des Servers war unverstaendlich.',
|
||||
statusCode: httpResponse.status,
|
||||
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
|
||||
// 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
|
||||
// aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden
|
||||
// Status außerhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
|
||||
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
|
||||
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
|
||||
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
|
||||
const errorResponse = parsedBody as Partial<ErrorResponse>;
|
||||
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**
|
||||
* Antworthülle, sondern ist der Inhalt selbst. Würde man sie durch requestApi
|
||||
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
|
||||
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
|
||||
* 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,
|
||||
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, bevor die
|
||||
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
|
||||
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
|
||||
* 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.
|
||||
*/
|
||||
export async function downloadApiFile(
|
||||
@ -272,7 +272,7 @@ export async function downloadApiFile(
|
||||
|
||||
throw new ApiError({
|
||||
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,
|
||||
requestId: correlationId,
|
||||
});
|
||||
@ -310,8 +310,8 @@ export async function downloadApiFile(
|
||||
/**
|
||||
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
|
||||
*
|
||||
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer wählt einen —
|
||||
* ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner.
|
||||
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
|
||||
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
|
||||
*/
|
||||
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
|
||||
if (dispositionHeader === null) {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Allgemeiner Lade-Hook für API-Ressourcen.
|
||||
* Allgemeiner Lade-Hook fuer API-Ressourcen.
|
||||
*
|
||||
* Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis
|
||||
* vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein
|
||||
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe
|
||||
* vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein
|
||||
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse
|
||||
* (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
|
||||
* 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';
|
||||
@ -25,17 +25,17 @@ export interface UseApiResourceResult<TPayload> {
|
||||
readonly data: TPayload | null;
|
||||
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */
|
||||
readonly loadError: ApiError | null;
|
||||
/** Lädt die Ressource erneut. */
|
||||
/** Laedt die Ressource erneut. */
|
||||
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 dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner
|
||||
* Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge
|
||||
* ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt bei jedem
|
||||
* @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal.
|
||||
* @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner
|
||||
* Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge
|
||||
* ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem
|
||||
* Rendern neu.
|
||||
*/
|
||||
export function useApiResource<TPayload>(
|
||||
@ -45,11 +45,11 @@ export function useApiResource<TPayload>(
|
||||
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
|
||||
const [reloadCounter, setReloadCounter] = useState(0);
|
||||
|
||||
// Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus
|
||||
// lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
|
||||
// Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein
|
||||
// setState im Effektkörper löste dagegen eine zweite Renderrunde aus,
|
||||
// bevor überhaupt etwas geladen wurde.
|
||||
// Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus
|
||||
// laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
|
||||
// Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein
|
||||
// setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus,
|
||||
// bevor ueberhaupt etwas geladen wurde.
|
||||
const [loadResult, setLoadResult] = useState<{
|
||||
key: string;
|
||||
data: TPayload | null;
|
||||
@ -71,7 +71,7 @@ export function useApiResource<TPayload>(
|
||||
|
||||
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
|
||||
} 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') {
|
||||
return;
|
||||
}
|
||||
@ -95,14 +95,14 @@ export function useApiResource<TPayload>(
|
||||
void loadFromApi();
|
||||
|
||||
return () => abortController.abort();
|
||||
// loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern
|
||||
// neu gebildete Funktion löste sonst eine Endlosschleife aus. Der
|
||||
// effectiveKey steuert das Neuladen ausdrücklich.
|
||||
// loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern
|
||||
// neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der
|
||||
// effectiveKey steuert das Neuladen ausdruecklich.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveKey]);
|
||||
|
||||
// Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die
|
||||
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die
|
||||
// Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die
|
||||
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die
|
||||
// Tabelle also nicht aufblitzen.
|
||||
if (loadResult === null || loadResult.key !== effectiveKey) {
|
||||
return {
|
||||
|
||||
@ -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
|
||||
* Oberfläche kaum etwas schreiben konnte. Dieser Haken trägt die drei Dinge,
|
||||
* die eine verändernde Handlung in einer Betriebskonsole braucht:
|
||||
* Oberflaeche kaum etwas schreiben konnte. Dieser Haken traegt die drei Dinge,
|
||||
* die eine veraendernde Handlung in einer Betriebskonsole braucht:
|
||||
*
|
||||
* 1. **Doppelklickschutz.** Solange die Anfrage läuft, wird keine zweite
|
||||
* ausgelöst. Bei einem Sicherungslauf wäre die zweite ein 409, bei einer
|
||||
* Löschung schlimmstenfalls mehr als beabsichtigt.
|
||||
* 1. **Doppelklickschutz.** Solange die Anfrage laeuft, wird keine zweite
|
||||
* ausgeloest. Bei einem Sicherungslauf waere die zweite ein 409, bei einer
|
||||
* Loeschung schlimmstenfalls mehr als beabsichtigt.
|
||||
* 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit
|
||||
* der sich ein Vorfall später im Serverprotokoll wiederfinden lässt.
|
||||
* 3. **Kein `setState` nach dem Aushängen.** Ein Dialog, der sich nach dem
|
||||
* Absenden schließt, ist weg, bevor die Antwort da ist.
|
||||
* der sich ein Vorfall spaeter im Serverprotokoll wiederfinden laesst.
|
||||
* 3. **Kein `setState` nach dem Aushaengen.** Ein Dialog, der sich nach dem
|
||||
* Absenden schliesst, ist weg, bevor die Antwort da ist.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError } from './client';
|
||||
|
||||
export interface MutationState {
|
||||
/** Läuft die Anfrage gerade? */
|
||||
/** Laeuft die Anfrage gerade? */
|
||||
readonly isRunning: boolean;
|
||||
/** Fehler des letzten Versuchs, sonst null. */
|
||||
readonly error: ApiError | null;
|
||||
@ -27,17 +27,17 @@ export interface MutationState {
|
||||
export interface MutationResult<TArguments extends unknown[], TResult> {
|
||||
readonly isRunning: boolean;
|
||||
readonly error: ApiError | null;
|
||||
/** Führt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */
|
||||
/** Fuehrt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */
|
||||
readonly run: (...callArguments: TArguments) => Promise<TResult | null>;
|
||||
/** Verwirft eine stehende Fehlermeldung. */
|
||||
readonly reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut einen Aufrufer für eine verändernde API-Handlung.
|
||||
* Baut einen Aufrufer fuer eine veraendernde API-Handlung.
|
||||
*
|
||||
* @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>(
|
||||
performRequest: (...callArguments: TArguments) => Promise<TResult>,
|
||||
@ -51,14 +51,14 @@ export function useMutation<TArguments extends unknown[], TResult>(
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Die Rückmeldungen liegen in einer Referenz, damit `run` seine Identität
|
||||
// behält. Sonst löste jede Renderrunde einen neuen Effekt aus, der `run`
|
||||
// in seiner Abhängigkeitsliste hat.
|
||||
// Die Rueckmeldungen liegen in einer Referenz, damit `run` seine Identitaet
|
||||
// behaelt. Sonst loeste jede Renderrunde einen neuen Effekt aus, der `run`
|
||||
// in seiner Abhaengigkeitsliste hat.
|
||||
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
|
||||
// darf ein Rendern verwerfen oder zweimal ausführen.
|
||||
// darf ein Rendern verwerfen oder zweimal ausfuehren.
|
||||
useEffect(() => {
|
||||
callbacksReference.current = callbacks;
|
||||
});
|
||||
@ -84,8 +84,8 @@ export function useMutation<TArguments extends unknown[], TResult>(
|
||||
setMutationState({ isRunning: false, error: null });
|
||||
}
|
||||
|
||||
// Die Rückmeldung läuft auch dann, wenn die Komponente weg ist: Ein
|
||||
// Dialog schließt sich nach dem Absenden, und die Liste dahinter soll
|
||||
// Die Rueckmeldung laeuft auch dann, wenn die Komponente weg ist: Ein
|
||||
// Dialog schliesst sich nach dem Absenden, und die Liste dahinter soll
|
||||
// sich trotzdem aktualisieren.
|
||||
callbacksReference.current.onSuccess?.(result);
|
||||
|
||||
@ -139,33 +139,33 @@ export function useMutation<TArguments extends unknown[], TResult>(
|
||||
|
||||
/** Bekannte Lagen samt Abhilfe. */
|
||||
const errorCodeExplanations: Readonly<Record<string, string>> = {
|
||||
// Diese Lage entsteht bei einem zweiten Anstoß und ist kein Fehler des
|
||||
// Anwenders — der Auftrag läuft bereits.
|
||||
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
|
||||
// Anwenders — der Auftrag laeuft bereits.
|
||||
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:
|
||||
'In dieses Ziel läuft bereits eine Wiederherstellung. Zwei gleichzeitige würden sich gegenseitig überschreiben.',
|
||||
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprüft.',
|
||||
'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.',
|
||||
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.',
|
||||
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:
|
||||
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurückgeschrieben.',
|
||||
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.',
|
||||
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:
|
||||
'Mitgelieferte Rollen sind unveränderlich. Eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.',
|
||||
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Aufträge oder Sicherungen.',
|
||||
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
|
||||
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
|
||||
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:
|
||||
'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:
|
||||
'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:
|
||||
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
|
||||
SERVICE_UNAVAILABLE:
|
||||
'Der Dienst ist derzeit nicht vollständig verfügbar. Das ist kein Problem Ihrer Sitzung.',
|
||||
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung für diese Handlung.',
|
||||
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
|
||||
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,
|
||||
* 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.
|
||||
*
|
||||
* Das war zunächst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
|
||||
* `SERVICE_UNAVAILABLE` mit der Meldung „Für diesen Bericht ist keine
|
||||
* Sicherheitsprüfung eingerichtet." wurde durch den allgemeinen Satz „Der
|
||||
* Dienst ist derzeit nicht vollständig verfügbar" ersetzt. Der Betreiber
|
||||
* hätte den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
|
||||
* Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
|
||||
* `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine
|
||||
* Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der
|
||||
* Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber
|
||||
* haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
|
||||
* einen Berichts.
|
||||
*/
|
||||
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
|
||||
* eine bekannte Abhilfe hat, lässt sie sich zusätzlich anzeigen.
|
||||
* Gedacht als **Ergaenzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage
|
||||
* eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen.
|
||||
*/
|
||||
export function explainApiErrorCode(errorCode: string): string | undefined {
|
||||
return errorCodeExplanations[errorCode];
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,92 +0,0 @@
|
||||
Copyright (c) 2023 Vercel, in collaboration with basement.studio
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@ -1,20 +1,19 @@
|
||||
/**
|
||||
* Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt.
|
||||
*
|
||||
* Die Seitenleiste lässt sich einklappen und merkt sich das. Der Grund ist
|
||||
* nicht Ästhetik: Wer eine Störung bearbeitet, hat oft eine breite Tabelle mit
|
||||
* Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menü sind dann im Weg.
|
||||
* Die Seitenleiste laesst sich einklappen und merkt sich das. Der Grund ist
|
||||
* nicht Aesthetik: Wer eine Stoerung bearbeitet, hat oft eine breite Tabelle mit
|
||||
* 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
|
||||
* 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 { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui';
|
||||
import { formatSessionClock, type SessionClock } from '@/features/auth/useSession';
|
||||
import {
|
||||
ALL_PAGES,
|
||||
SECTION_LABELS,
|
||||
@ -44,7 +43,6 @@ export function AppShell({
|
||||
onNavigate,
|
||||
grantedPermissions,
|
||||
userName,
|
||||
sessionClock,
|
||||
onLogout,
|
||||
children,
|
||||
}: {
|
||||
@ -52,8 +50,6 @@ export function AppShell({
|
||||
readonly onNavigate: (pageIdentifier: string) => void;
|
||||
readonly grantedPermissions: readonly string[];
|
||||
readonly userName: string;
|
||||
/** Verbleibende Sitzungszeit für die Anzeige. */
|
||||
readonly sessionClock: SessionClock;
|
||||
readonly onLogout: () => void;
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
@ -64,7 +60,7 @@ export function AppShell({
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort
|
||||
// hängen — und weil ein Dialog im Portal sonst im falschen Thema erschiene.
|
||||
// haengen — und weil ein Dialog im Portal sonst im falschen Thema erschiene.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', themePreference);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||
@ -75,19 +71,19 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-page">
|
||||
{/* Abdunklung hinter der Schublade auf schmalen Geräten. */}
|
||||
{/* Abdunklung hinter der Schublade auf schmalen Geraeten. */}
|
||||
{isMobileMenuOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
aria-label="Menü schließen"
|
||||
aria-label="Menue schliessen"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
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',
|
||||
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||
)}
|
||||
@ -103,7 +99,7 @@ export function AppShell({
|
||||
type="button"
|
||||
className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
aria-label="Menü schließen"
|
||||
aria-label="Menue schliessen"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
@ -132,7 +128,7 @@ export function AppShell({
|
||||
isActive={page.id === currentPageId}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
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
|
||||
// ist die Handlung, nicht der neue Zustand.
|
||||
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"
|
||||
title={isSidebarCollapsed ? 'Menü ausklappen' : 'Menü einklappen'}
|
||||
title={isSidebarCollapsed ? 'Menue ausklappen' : 'Menue einklappen'}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeft className="size-4 shrink-0" />
|
||||
@ -184,7 +180,7 @@ export function AppShell({
|
||||
type="button"
|
||||
className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
aria-label="Menü öffnen"
|
||||
aria-label="Menue oeffnen"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
@ -212,23 +208,6 @@ export function AppShell({
|
||||
{userName}
|
||||
</span>
|
||||
|
||||
{/* Die Uhr steht neben „Abmelden", weil dort auch die Handlung
|
||||
sitzt, die sie ankündigt. Unter fünf Minuten wird sie auffällig —
|
||||
früher zu warnen erzeugt Gewöhnung, später bleibt keine Zeit. */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs tabular',
|
||||
sessionClock.isExpiringSoon
|
||||
? 'bg-warning-bg font-medium text-warning'
|
||||
: 'text-fg-subtle',
|
||||
)}
|
||||
title={`Die Sitzung endet in ${formatSessionClock(sessionClock.remainingSeconds)}. Jede Eingabe verlängert sie, bis die harte Obergrenze von 30 Minuten erreicht ist.`}
|
||||
>
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">Verbleibende Sitzungszeit: </span>
|
||||
{formatSessionClock(sessionClock.remainingSeconds)}
|
||||
</span>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={onLogout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
@ -265,8 +244,8 @@ function NavigationItem({
|
||||
isActive
|
||||
? 'bg-accent-subtle font-medium text-accent'
|
||||
: 'text-fg-muted hover:bg-hover hover:text-fg',
|
||||
// Ein noch nicht verfügbarer Bereich bleibt anklickbar — die Zielseite
|
||||
// erklärt, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
|
||||
// Ein noch nicht verfuegbarer Bereich bleibt anklickbar — die Zielseite
|
||||
// erklaert, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
|
||||
!page.available && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Fehlergrenze um den Seiteninhalt.
|
||||
*
|
||||
* Ohne sie reißt ein einziger Fehler in einer Komponente den gesamten Baum ab —
|
||||
* React hängt alles aus, und übrig bleibt eine leere Seite. Im dunklen Thema ist
|
||||
* das ein schwarzer Bildschirm ohne jeden Hinweis, und ein Betreiber hat keine
|
||||
* Möglichkeit zu melden, was schiefging.
|
||||
*
|
||||
* Die Grenze sitzt **um den Inhalt**, nicht um die ganze Anwendung: Seitenleiste
|
||||
* und Kopfzeile bleiben stehen, und man kann auf eine andere Seite wechseln,
|
||||
* statt neu laden zu müssen.
|
||||
*
|
||||
* Klassenkomponente, weil React für Fehlergrenzen keine Entsprechung als Haken
|
||||
* anbietet.
|
||||
*/
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
/** Wechselt der Wert, wird der Fehler verworfen — etwa beim Seitenwechsel. */
|
||||
readonly resetKey: string;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
readonly caughtError: Error | null;
|
||||
readonly componentStack: string;
|
||||
readonly resetKey: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
public constructor(properties: ErrorBoundaryProps) {
|
||||
super(properties);
|
||||
|
||||
this.state = { caughtError: null, componentStack: '', resetKey: properties.resetKey };
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(caughtError: Error): Partial<ErrorBoundaryState> {
|
||||
return { caughtError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft den Fehler beim Seitenwechsel.
|
||||
*
|
||||
* Abgeleitet statt in einem Effekt gesetzt: Sonst zeigte die neue Seite noch
|
||||
* einen Wimpernschlag lang den Fehler der alten.
|
||||
*/
|
||||
public static getDerivedStateFromProps(
|
||||
nextProperties: ErrorBoundaryProps,
|
||||
currentState: ErrorBoundaryState,
|
||||
): Partial<ErrorBoundaryState> | null {
|
||||
if (nextProperties.resetKey !== currentState.resetKey) {
|
||||
return { caughtError: null, componentStack: '', resetKey: nextProperties.resetKey };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override componentDidCatch(caughtError: Error, errorInfo: ErrorInfo): void {
|
||||
this.setState({ componentStack: errorInfo.componentStack ?? '' });
|
||||
|
||||
// Die Meldung gehört in die Entwicklerkonsole, damit sie sich kopieren
|
||||
// lässt. Sie hier zu verschlucken wäre der stille Fehler, den die
|
||||
// Entwicklungsregeln ausdrücklich verbieten.
|
||||
console.error('Unbehandelter Fehler in der Oberfläche:', caughtError, errorInfo);
|
||||
}
|
||||
|
||||
public override render(): ReactNode {
|
||||
const { caughtError, componentStack } = this.state;
|
||||
|
||||
if (caughtError === null) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-critical/30 bg-card p-6" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-fg">
|
||||
Diese Seite konnte nicht angezeigt werden
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
Die übrige Konsole arbeitet weiter. Wechseln Sie den Bereich oder
|
||||
laden Sie neu.
|
||||
</p>
|
||||
|
||||
<pre className="mt-4 max-h-40 overflow-auto rounded-md bg-sunken p-3 text-xs text-fg">
|
||||
{caughtError.message || caughtError.name}
|
||||
{componentStack ? `\n${componentStack.trim().split('\n').slice(0, 6).join('\n')}` : ''}
|
||||
</pre>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => this.setState({ caughtError: null, componentStack: '' })}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
|
||||
Seite neu laden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
/** Kopfbereich einer Seite: Titel, Erläuterung, Handlungen. */
|
||||
/** Kopfbereich einer Seite: Titel, Erlaeuterung, Handlungen. */
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
@ -10,7 +10,7 @@ export function PageHeader({
|
||||
description,
|
||||
actions,
|
||||
onBack,
|
||||
backLabel = 'Zurück',
|
||||
backLabel = 'Zurueck',
|
||||
className,
|
||||
}: {
|
||||
readonly title: ReactNode;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Schaltfläche.
|
||||
* Schaltflaeche.
|
||||
*
|
||||
* Die Ausprägung "destructive" ist bewusst eigenständig und nicht nur eine
|
||||
* rote Farbe: Löschende Handlungen sollen sich auch dann unterscheiden, wenn
|
||||
* jemand die Oberfläche in Graustufen betrachtet oder farbfehlsichtig ist.
|
||||
* Deshalb trägt sie zusätzlich einen kräftigen Rand.
|
||||
* Die Auspraegung "destructive" ist bewusst eigenstaendig und nicht nur eine
|
||||
* rote Farbe: Loeschende Handlungen sollen sich auch dann unterscheiden, wenn
|
||||
* jemand die Oberflaeche in Graustufen betrachtet oder farbfehlsichtig ist.
|
||||
* Deshalb traegt sie zusaetzlich einen kraeftigen Rand.
|
||||
*/
|
||||
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
@ -14,9 +14,9 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
@ -50,7 +50,7 @@ export interface ButtonProps
|
||||
VariantProps<typeof buttonVariants> {
|
||||
/** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */
|
||||
readonly asChild?: boolean | undefined;
|
||||
/** Zeigt einen Ladeanzeiger und sperrt die Schaltfläche. */
|
||||
/** Zeigt einen Ladeanzeiger und sperrt die Schaltflaeche. */
|
||||
readonly isLoading?: boolean | undefined;
|
||||
}
|
||||
|
||||
@ -64,9 +64,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
<RenderedComponent
|
||||
ref={forwardedReference}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
// Während eine Handlung läuft, darf sie nicht ein zweites Mal ausgelöst
|
||||
// werden. Bei einem Sicherungslauf wäre der zweite Klick ein 409, bei
|
||||
// einer Löschung schlimmstenfalls mehr als beabsichtigt.
|
||||
// Waehrend eine Handlung laeuft, darf sie nicht ein zweites Mal ausgeloest
|
||||
// werden. Bei einem Sicherungslauf waere der zweite Klick ein 409, bei
|
||||
// einer Loeschung schlimmstenfalls mehr als beabsichtigt.
|
||||
disabled={disabled || isLoading}
|
||||
{...restProps}
|
||||
>
|
||||
|
||||
@ -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 { cn } from '@/lib/utils';
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
asChild = false,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* Rendert die Kindkomponente statt eines <div>.
|
||||
*
|
||||
* Fuer anklickbare Kacheln: Eine Schaltflaeche ist mit der Tastatur
|
||||
* erreichbar, ein div mit Klick-Handler nicht.
|
||||
*/
|
||||
readonly asChild?: boolean | undefined;
|
||||
}) {
|
||||
const RenderedComponent = asChild ? Slot : 'div';
|
||||
|
||||
export function Card({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<RenderedComponent
|
||||
className={cn('border border-line bg-card', className)}
|
||||
<div
|
||||
className={cn('rounded-lg border border-line bg-card', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
@ -41,7 +26,7 @@ export function CardHeader({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Tabelle.
|
||||
*
|
||||
* Eine Betriebskonsole besteht zu großen Teilen aus Tabellen, deshalb steckt
|
||||
* die Mühe hier und nicht in Effekten:
|
||||
* Eine Betriebskonsole besteht zu grossen Teilen aus Tabellen, deshalb steckt
|
||||
* die Muehe hier und nicht in Effekten:
|
||||
*
|
||||
* - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter
|
||||
* trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte;
|
||||
* die erste verlangt eine Schaltfläche, die zweite nicht.
|
||||
* - **Während des Nachladens bleibt die alte Tabelle stehen** und wird nur
|
||||
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen lässt die Seite bei
|
||||
* die erste verlangt eine Schaltflaeche, die zweite nicht.
|
||||
* - **Waehrend des Nachladens bleibt die alte Tabelle stehen** und wird nur
|
||||
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen laesst die Seite bei
|
||||
* jedem Filterwechsel aufblitzen.
|
||||
* - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite.
|
||||
*/
|
||||
@ -20,7 +20,7 @@ export interface TableColumn<RowType> {
|
||||
readonly key: string;
|
||||
readonly header: ReactNode;
|
||||
readonly render: (row: RowType) => ReactNode;
|
||||
/** Rechtsbündig — für Zahlen. */
|
||||
/** Rechtsbuendig — fuer Zahlen. */
|
||||
readonly numeric?: boolean | undefined;
|
||||
readonly className?: string | undefined;
|
||||
}
|
||||
@ -31,7 +31,7 @@ export function DataTable<RowType>({
|
||||
getRowKey,
|
||||
onRowClick,
|
||||
isLoading = false,
|
||||
emptyTitle = 'Keine Einträge',
|
||||
emptyTitle = 'Keine Eintraege',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
className,
|
||||
@ -62,13 +62,13 @@ export function DataTable<RowType>({
|
||||
<div className={cn('w-full overflow-x-auto', className)}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-sunken/50">
|
||||
<tr className="border-b border-line">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-4 py-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle',
|
||||
'whitespace-nowrap px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-fg-subtle',
|
||||
column.numeric ? 'text-right' : 'text-left',
|
||||
column.className,
|
||||
)}
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
*
|
||||
* Zwei Bauarten, und der Unterschied ist fachlich:
|
||||
*
|
||||
* - `Dialog` für Eingaben (anlegen, bearbeiten). Er lässt sich mit Escape und
|
||||
* einem Klick daneben schließen.
|
||||
* - `ConfirmDialog` für Handlungen, die etwas verändern. Er verlangt eine
|
||||
* ausdrückliche Bestätigung. Bei zerstörenden Handlungen kann er zusätzlich
|
||||
* ein wörtlich einzutippendes Wort verlangen — dasselbe Mittel, das
|
||||
* `uninstall.sh` und die Wiederherstellung mit Überschreiben verwenden. Ein
|
||||
* - `Dialog` fuer Eingaben (anlegen, bearbeiten). Er laesst sich mit Escape und
|
||||
* einem Klick daneben schliessen.
|
||||
* - `ConfirmDialog` fuer Handlungen, die etwas veraendern. Er verlangt eine
|
||||
* ausdrueckliche Bestaetigung. Bei zerstoerenden Handlungen kann er zusaetzlich
|
||||
* ein woertlich einzutippendes Wort verlangen — dasselbe Mittel, das
|
||||
* `uninstall.sh` und die Wiederherstellung mit Ueberschreiben verwenden. Ein
|
||||
* versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus,
|
||||
* und ein Klick aus Gewohnheit auch nicht.
|
||||
*/
|
||||
@ -46,7 +46,7 @@ export function DialogContent({
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2',
|
||||
'max-h-[calc(100vh-4rem)] overflow-y-auto',
|
||||
'border border-line-strong bg-card',
|
||||
'rounded-lg border border-line bg-card shadow-xl',
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
@ -54,7 +54,7 @@ export function DialogContent({
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-fg-muted transition-colors hover:bg-hover hover:text-fg"
|
||||
aria-label="Schließen"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</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
|
||||
* es wörtlich eintippen. Das gehört ausschließlich an Handlungen, die Daten
|
||||
* unwiederbringlich entfernen oder überschreiben — ein Ritual ohne Anlass
|
||||
* gewöhnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
|
||||
* `confirmationWord` ist die zweite Huerde: Wird es gesetzt, muss der Betreiber
|
||||
* es woertlich eintippen. Das gehoert ausschliesslich an Handlungen, die Daten
|
||||
* unwiederbringlich entfernen oder ueberschreiben — ein Ritual ohne Anlass
|
||||
* gewoehnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Bestätigen',
|
||||
confirmLabel = 'Bestaetigen',
|
||||
cancelLabel = 'Abbrechen',
|
||||
confirmationWord,
|
||||
confirmationHint,
|
||||
@ -130,7 +130,7 @@ export function ConfirmDialog({
|
||||
readonly description?: ReactNode | undefined;
|
||||
readonly confirmLabel?: 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 confirmationHint?: ReactNode | undefined;
|
||||
readonly isDestructive?: boolean | undefined;
|
||||
@ -141,10 +141,10 @@ export function ConfirmDialog({
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="sm">
|
||||
{/* Der Inhalt hängt im Portal und wird beim Schließen ausgehängt.
|
||||
Damit verschwindet die eingetippte Bestätigung von selbst — ein
|
||||
Zurücksetzen im Effekt braucht es nicht, und die Hürde steht beim
|
||||
nächsten Öffnen wieder. */}
|
||||
{/* Der Inhalt haengt im Portal und wird beim Schliessen ausgehaengt.
|
||||
Damit verschwindet die eingetippte Bestaetigung von selbst — ein
|
||||
Zuruecksetzen im Effekt braucht es nicht, und die Huerde steht beim
|
||||
naechsten Oeffnen wieder. */}
|
||||
<ConfirmDialogBody
|
||||
title={title}
|
||||
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({
|
||||
title,
|
||||
description,
|
||||
@ -208,7 +208,7 @@ function ConfirmDialogBody({
|
||||
>
|
||||
{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">
|
||||
{confirmationWord}
|
||||
</code>
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
|
||||
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
|
||||
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
|
||||
* Begründung, nie als "0" — eine Null liest sich wie "geprüft und in
|
||||
* Ordnung", und genau das wäre falsch.
|
||||
* Begruendung, nie als "0" — eine Null liest sich wie "geprueft und in
|
||||
* Ordnung", und genau das waere falsch.
|
||||
*/
|
||||
|
||||
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
|
||||
* Handlungsanweisung.
|
||||
*/
|
||||
@ -99,7 +99,7 @@ export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Auffälliger Hinweiskasten. */
|
||||
/** Auffaelliger Hinweiskasten. */
|
||||
export function Callout({
|
||||
tone = 'info',
|
||||
title,
|
||||
@ -114,9 +114,9 @@ export function Callout({
|
||||
/**
|
||||
* ARIA-Rolle.
|
||||
*
|
||||
* Standard ist `note` — die meisten Hinweise erklären etwas und stehen schon
|
||||
* beim Öffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
|
||||
* erscheint, gehört dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
|
||||
* Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon
|
||||
* beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
|
||||
* erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
|
||||
* statt ihn im Seiteninhalt untergehen zu lassen.
|
||||
*/
|
||||
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({
|
||||
label,
|
||||
children,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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
|
||||
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" über
|
||||
* Datenverlust, und ein Betreiber soll nicht raten müssen, was die Anlage mit
|
||||
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" ueber
|
||||
* Datenverlust, und ein Betreiber soll nicht raten muessen, was die Anlage mit
|
||||
* seiner Eingabe tut.
|
||||
*/
|
||||
|
||||
@ -12,7 +12,7 @@ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type Selec
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const fieldClasses = [
|
||||
'w-full rounded-lg border border-line-strong bg-card px-3 py-2 text-sm text-fg transition-colors',
|
||||
'w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg',
|
||||
'placeholder:text-fg-subtle',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
].join(' ');
|
||||
@ -150,7 +150,7 @@ export const SelectInput = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
/** Kontrollkästchen mit Beschriftung und Erläuterung. */
|
||||
/** Kontrollkaestchen mit Beschriftung und Erlaeuterung. */
|
||||
export function CheckboxField({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
@ -177,7 +177,7 @@ export function CheckboxField({
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)}
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent)]"
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[hsl(var(--accent))]"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
|
||||
@ -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
|
||||
* 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
|
||||
* niemand sieht es, weil die Oberfläche einwandfrei aussieht.
|
||||
* niemand sieht es, weil die Oberflaeche einwandfrei aussieht.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveStatus } from './StatusBadge';
|
||||
|
||||
describe('Statuszuordnung', () => {
|
||||
it('färbt einen Teilfehler niemals grün', () => {
|
||||
// Entwicklungsregel 1: Teilfehler heißen PARTIAL FAILURE, nie SUCCESS.
|
||||
it('faerbt einen Teilfehler niemals gruen', () => {
|
||||
// Entwicklungsregel 1: Teilfehler heissen PARTIAL FAILURE, nie SUCCESS.
|
||||
expect(resolveStatus('partial_failure').tone).not.toBe('healthy');
|
||||
expect(resolveStatus('partial_failure').tone).toBe('warning');
|
||||
});
|
||||
|
||||
it('färbt ein gesichertes, aber ungepruftes Backup nicht grün', () => {
|
||||
// `successful` heißt "der Lauf ist durchgelaufen" — nicht
|
||||
it('faerbt ein gesichertes, aber ungepruftes Backup nicht gruen', () => {
|
||||
// `successful` heisst "der Lauf ist durchgelaufen" — nicht
|
||||
// "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf
|
||||
// `recoverable`.
|
||||
expect(resolveStatus('successful').tone).toBe('neutral');
|
||||
expect(resolveStatus('recoverable').tone).toBe('healthy');
|
||||
});
|
||||
|
||||
it('behandelt ein beschädigtes Backup als kritisch, nicht als Warnung', () => {
|
||||
// Ein Backup mit einem beschädigten Block ist nicht zu 70 Prozent
|
||||
it('behandelt ein beschaedigtes Backup als kritisch, nicht als Warnung', () => {
|
||||
// Ein Backup mit einem beschaedigten Block ist nicht zu 70 Prozent
|
||||
// wiederherstellbar, sondern gar nicht.
|
||||
expect(resolveStatus('corrupted').tone).toBe('critical');
|
||||
});
|
||||
|
||||
it('färbt einen nur softwareseitig durchgesetzten Löschschutz als Warnung', () => {
|
||||
it('faerbt einen nur softwareseitig durchgesetzten Loeschschutz als Warnung', () => {
|
||||
// `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups
|
||||
// löschen. Das als "in Ordnung" zu zeigen wäre die gefährlichste
|
||||
// Beschönigung der Anlage.
|
||||
// loeschen. Das als "in Ordnung" zu zeigen waere die gefaehrlichste
|
||||
// Beschoenigung der Anlage.
|
||||
expect(resolveStatus('advisory').tone).toBe('warning');
|
||||
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
|
||||
// als "in Ordnung" durchgehen. Grün wäre der bequeme Standard und der
|
||||
// gefährlichste.
|
||||
// als "in Ordnung" durchgehen. Gruen waere der bequeme Standard und der
|
||||
// gefaehrlichste.
|
||||
const unknownStatus = resolveStatus('ein_neuer_serverzustand');
|
||||
|
||||
expect(unknownStatus.tone).toBe('neutral');
|
||||
@ -57,7 +57,7 @@ describe('Statuszuordnung', () => {
|
||||
it('kennt jede Einstufung, die der Server vergeben kann', () => {
|
||||
// Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort
|
||||
// eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der
|
||||
// Oberfläche als englischer Rohwert.
|
||||
// Oberflaeche als englischer Rohwert.
|
||||
for (const classification of [
|
||||
'failed',
|
||||
'corrupted',
|
||||
@ -67,7 +67,7 @@ describe('Statuszuordnung', () => {
|
||||
]) {
|
||||
expect(
|
||||
resolveStatus(classification).label,
|
||||
`Einstufung ${classification} ist nicht übersetzt`,
|
||||
`Einstufung ${classification} ist nicht uebersetzt`,
|
||||
).not.toBe(classification);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
/**
|
||||
* 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
|
||||
* eigentliche Punkt: Wäre sie über die Seiten verteilt, würde früher oder
|
||||
* später irgendwo "partial_failure" grün erscheinen — und ein Betreiber hält
|
||||
* einen Teilfehler dann für einen Erfolg.
|
||||
* eigentliche Punkt: Waere sie ueber die Seiten verteilt, wuerde frueher oder
|
||||
* spaeter irgendwo "partial_failure" gruen erscheinen — und ein Betreiber haelt
|
||||
* einen Teilfehler dann fuer einen Erfolg.
|
||||
*
|
||||
* Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den
|
||||
* Entscheidungen der Phasen:
|
||||
*
|
||||
* - "partial_failure" ist **niemals** grün (Entwicklungsregel 1).
|
||||
* - "unverified" ist neutral, nicht grün: Ohne Wiederherstellungstest ist die
|
||||
* Wiederherstellbarkeit unbekannt, und Unbekanntes zählt nie als gut
|
||||
* - "partial_failure" ist **niemals** gruen (Entwicklungsregel 1).
|
||||
* - "unverified" ist neutral, nicht gruen: Ohne Wiederherstellungstest ist die
|
||||
* Wiederherstellbarkeit unbekannt, und Unbekanntes zaehlt nie als gut
|
||||
* (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.
|
||||
* - "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).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
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';
|
||||
|
||||
interface StatusDefinition {
|
||||
@ -34,21 +34,21 @@ interface StatusDefinition {
|
||||
/**
|
||||
* Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung.
|
||||
*
|
||||
* Ein unbekannter Schlüssel wird bewusst **neutral** dargestellt und mit dem
|
||||
* Rohwert beschriftet — nicht grün. Ein neuer Serverzustand, den diese Tabelle
|
||||
* Ein unbekannter Schluessel wird bewusst **neutral** dargestellt und mit dem
|
||||
* Rohwert beschriftet — nicht gruen. Ein neuer Serverzustand, den diese Tabelle
|
||||
* noch nicht kennt, darf nicht als "in Ordnung" durchgehen.
|
||||
*/
|
||||
const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
||||
// --- Läufe ---
|
||||
// --- Laeufe ---
|
||||
succeeded: { tone: 'healthy', label: 'Erfolgreich' },
|
||||
running: { tone: 'info', label: 'Läuft' },
|
||||
running: { tone: 'info', label: 'Laeuft' },
|
||||
queued: { tone: 'info', label: 'Eingereiht' },
|
||||
pending: { tone: 'neutral', label: 'Ausstehend' },
|
||||
partial_failure: { tone: 'warning', label: 'Teilfehler' },
|
||||
failed: { tone: 'critical', label: 'Fehlgeschlagen' },
|
||||
cancelled: { tone: 'neutral', label: 'Abgebrochen' },
|
||||
|
||||
// --- Aufträge ---
|
||||
// --- Auftraege ---
|
||||
enabled: { tone: 'healthy', label: 'Aktiv' },
|
||||
paused: { tone: 'warning', label: 'Angehalten' },
|
||||
disabled: { tone: 'neutral', label: 'Deaktiviert' },
|
||||
@ -58,17 +58,17 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
||||
// Das Vokabular lautet: failed, successful, verified, recoverable, corrupted.
|
||||
//
|
||||
// `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht
|
||||
// grün: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
|
||||
// sich daraus etwas wiederherstellen lässt. Ein grünes Abzeichen läse sich
|
||||
// hier als "geprüft und in Ordnung", und genau diese Verwechslung soll die
|
||||
// gruen: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
|
||||
// sich daraus etwas wiederherstellen laesst. Ein gruenes Abzeichen laese sich
|
||||
// hier als "geprueft und in Ordnung", und genau diese Verwechslung soll die
|
||||
// ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf
|
||||
// `recoverable`; alles davor ist ein Indiz.
|
||||
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
|
||||
verified: { tone: 'info', label: 'Geprüft' },
|
||||
successful: { tone: 'neutral', label: 'Gesichert, ungeprüft' },
|
||||
corrupted: { tone: 'critical', label: 'Beschädigt' },
|
||||
verified: { tone: 'info', label: 'Geprueft' },
|
||||
successful: { tone: 'neutral', label: 'Gesichert, ungeprueft' },
|
||||
corrupted: { tone: 'critical', label: 'Beschaedigt' },
|
||||
|
||||
// --- Prüfergebnisse ---
|
||||
// --- Pruefergebnisse ---
|
||||
clean: { tone: 'healthy', label: 'Ohne Befund' },
|
||||
findings: { tone: 'critical', label: 'Mit Befund' },
|
||||
|
||||
@ -78,7 +78,7 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
||||
unavailable: { tone: 'critical', label: 'Nicht erreichbar' },
|
||||
maintenance: { tone: 'warning', label: 'Wartung' },
|
||||
|
||||
// --- Durchsetzungsstufe des Löschschutzes (Phase 11) ---
|
||||
// --- Durchsetzungsstufe des Loeschschutzes (Phase 11) ---
|
||||
filesystem: { tone: 'healthy', label: 'Dateisystem' },
|
||||
storage: { tone: 'healthy', label: 'Speichersystem' },
|
||||
advisory: { tone: 'warning', label: 'Nur Software' },
|
||||
@ -86,15 +86,15 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
||||
|
||||
// --- Gesundheit ---
|
||||
healthy: { tone: 'healthy', label: 'Fehlerfrei' },
|
||||
degraded: { tone: 'warning', label: 'Eingeschränkt' },
|
||||
degraded: { tone: 'warning', label: 'Eingeschraenkt' },
|
||||
critical: { tone: 'critical', label: 'Kritisch' },
|
||||
unknown: { tone: 'neutral', label: 'Unbekannt' },
|
||||
|
||||
// --- Meldungen ---
|
||||
open: { tone: 'critical', label: 'Offen' },
|
||||
acknowledged: { tone: 'warning', label: 'Bestätigt' },
|
||||
acknowledged: { tone: 'warning', label: 'Bestaetigt' },
|
||||
resolved: { tone: 'healthy', label: 'Erledigt' },
|
||||
suppressed: { tone: 'neutral', label: 'Unterdrückt' },
|
||||
suppressed: { tone: 'neutral', label: 'Unterdrueckt' },
|
||||
|
||||
// --- Schweregrade ---
|
||||
info: { tone: 'info', label: 'Information' },
|
||||
@ -110,8 +110,8 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
||||
revoked: { tone: 'neutral', label: 'Gesperrt' },
|
||||
|
||||
// --- Ransomware-Einstufung (Phase 16) ---
|
||||
elevated: { tone: 'warning', label: 'Auffällig' },
|
||||
// "unknown" oben deckt den Fall unter fünf Vergleichsläufen mit ab.
|
||||
elevated: { tone: 'warning', label: 'Auffaellig' },
|
||||
// "unknown" oben deckt den Fall unter fuenf Vergleichslaeufen mit ab.
|
||||
};
|
||||
|
||||
const toneClasses: Readonly<Record<StatusTone, string>> = {
|
||||
@ -133,8 +133,8 @@ export function resolveStatus(rawStatus: string | null | undefined): StatusDefin
|
||||
|
||||
return (
|
||||
statusDefinitions[normalizedStatus] ?? {
|
||||
// Unbekannt bleibt neutral. Grün wäre hier der bequeme und gefährliche
|
||||
// Standard: Ein neuer Fehlerzustand des Servers sähe aus wie Erfolg.
|
||||
// Unbekannt bleibt neutral. Gruen waere hier der bequeme und gefaehrliche
|
||||
// Standard: Ein neuer Fehlerzustand des Servers saehe aus wie Erfolg.
|
||||
tone: 'neutral',
|
||||
label: rawStatus,
|
||||
}
|
||||
@ -148,7 +148,7 @@ export function StatusBadge({
|
||||
showDot = true,
|
||||
}: {
|
||||
readonly status: string | null | undefined;
|
||||
/** Überschreibt die Beschriftung, der Ton bleibt. */
|
||||
/** Ueberschreibt die Beschriftung, der Ton bleibt. */
|
||||
readonly label?: ReactNode | undefined;
|
||||
readonly className?: string | undefined;
|
||||
readonly showDot?: boolean | undefined;
|
||||
@ -158,7 +158,7 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
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],
|
||||
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({
|
||||
status,
|
||||
className,
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
*
|
||||
* Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die
|
||||
* `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht
|
||||
* funktioniert" — und damit ist weder ihm noch einer späteren Fehlersuche
|
||||
* geholfen. Die Kennung lässt sich mit einem Klick kopieren, weil sie sonst
|
||||
* funktioniert" — und damit ist weder ihm noch einer spaeteren Fehlersuche
|
||||
* geholfen. Die Kennung laesst sich mit einem Klick kopieren, weil sie sonst
|
||||
* abgetippt wird und dabei ein Zeichen verlorengeht.
|
||||
*
|
||||
* Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer
|
||||
* gerade wegsieht, während eine Löschung fehlschlägt, hätte sonst keine
|
||||
* gerade wegsieht, waehrend eine Loeschung fehlschlaegt, haette sonst keine
|
||||
* Chance, es zu bemerken.
|
||||
*/
|
||||
|
||||
@ -44,7 +44,7 @@ export function useToast(): ToastContextValue {
|
||||
const contextValue = useContext(ToastContext);
|
||||
|
||||
if (!contextValue) {
|
||||
throw new Error('useToast benötigt einen ToastProvider im Elternbaum.');
|
||||
throw new Error('useToast benoetigt einen ToastProvider im Elternbaum.');
|
||||
}
|
||||
|
||||
return contextValue;
|
||||
@ -68,8 +68,8 @@ export function ToastProvider({ children }: { readonly children: ReactNode }) {
|
||||
{ ...message, id: messageId },
|
||||
]);
|
||||
|
||||
// Fehler bleiben stehen, bis sie jemand schließt. Alles andere
|
||||
// verschwindet nach fünf Sekunden.
|
||||
// Fehler bleiben stehen, bis sie jemand schliesst. Alles andere
|
||||
// verschwindet nach fuenf Sekunden.
|
||||
if (message.tone !== 'error') {
|
||||
window.setTimeout(() => removeMessage(messageId), 5000);
|
||||
}
|
||||
@ -140,7 +140,7 @@ function ToastCard({
|
||||
return (
|
||||
<div
|
||||
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],
|
||||
)}
|
||||
>
|
||||
@ -166,7 +166,7 @@ function ToastCard({
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
* Ereignisprotokoll.
|
||||
*
|
||||
* Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per
|
||||
* Anwendungslogik. Es lässt sich weder aus der Oberfläche noch über die API
|
||||
* ändern oder löschen — ein Protokoll, das sich bereinigen ließe, wäre
|
||||
* Anwendungslogik. Es laesst sich weder aus der Oberflaeche noch ueber die API
|
||||
* aendern oder loeschen — ein Protokoll, das sich bereinigen liesse, waere
|
||||
* keines. Deshalb gibt es hier keine Handlungen, nur eine Suche.
|
||||
*
|
||||
* Die Adresse in der Spalte „Herkunft" ist die der tatsächlichen Verbindung.
|
||||
* Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind fälschbar, und
|
||||
* im Audit stünde sonst eine beliebige Adresse. Hinter einem Reverse Proxy —
|
||||
* Die Adresse in der Spalte „Herkunft" ist die der tatsaechlichen Verbindung.
|
||||
* Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind faelschbar, und
|
||||
* im Audit stuende sonst eine beliebige Adresse. Hinter einem Reverse Proxy —
|
||||
* also im Normalfall dieser Anlage — steht dort dessen Adresse.
|
||||
*/
|
||||
|
||||
@ -43,7 +43,7 @@ interface AuditEvent {
|
||||
readonly correlation_id?: string;
|
||||
}
|
||||
|
||||
/** Lädt die letzten Ereignisse. */
|
||||
/** Laedt die letzten Ereignisse. */
|
||||
async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> {
|
||||
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', {
|
||||
signal: abortSignal,
|
||||
@ -60,8 +60,8 @@ export function EventsPage() {
|
||||
/**
|
||||
* Filterung im Browser.
|
||||
*
|
||||
* Die Liste umfasst höchstens hundert Einträge; eine serverseitige Suche
|
||||
* wäre hier mehr Vertrag als Nutzen. Bei größeren Beständen gehört sie
|
||||
* Die Liste umfasst hoechstens hundert Eintraege; eine serverseitige Suche
|
||||
* waere hier mehr Vertrag als Nutzen. Bei groesseren Bestaenden gehoert sie
|
||||
* auf den Server — das ist eine bewusste Grenze, keine Auslassung.
|
||||
*/
|
||||
const filteredEvents = useMemo(() => {
|
||||
@ -180,7 +180,9 @@ export function EventsPage() {
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
@ -211,7 +213,7 @@ export function EventsPage() {
|
||||
emptyDescription={
|
||||
searchTerm
|
||||
? '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>
|
||||
|
||||
@ -2,16 +2,16 @@
|
||||
* Rollen und Berechtigungen.
|
||||
*
|
||||
* Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste
|
||||
* mit 34 Einträgen dazu führt, dass jemand „alles" ankreuzt. Zwei Paare sind
|
||||
* bewusst getrennt und werden hier ausdrücklich erklärt:
|
||||
* mit 34 Eintraegen dazu fuehrt, dass jemand „alles" ankreuzt. Zwei Paare sind
|
||||
* bewusst getrennt und werden hier ausdruecklich erklaert:
|
||||
*
|
||||
* - `restores.execute` enthält **nicht** `restores.overwrite`. Wiederherstellen
|
||||
* ist etwas anderes als vorhandene Daten überschreiben.
|
||||
* - `backups.delete` enthält **nicht** `immutability.manage`. Wer aufräumen
|
||||
* darf, darf keinen Löschschutz aufheben — das ist der Schritt, der einem
|
||||
* Angreifer den Weg öffnet.
|
||||
* - `restores.execute` enthaelt **nicht** `restores.overwrite`. Wiederherstellen
|
||||
* ist etwas anderes als vorhandene Daten ueberschreiben.
|
||||
* - `backups.delete` enthaelt **nicht** `immutability.manage`. Wer aufraeumen
|
||||
* darf, darf keinen Loeschschutz aufheben — das ist der Schritt, der einem
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -46,16 +46,16 @@ import {
|
||||
type Role,
|
||||
} 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>> = {
|
||||
'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':
|
||||
'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':
|
||||
'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':
|
||||
'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({
|
||||
@ -92,17 +92,17 @@ export function RolesPage({
|
||||
rolesResource.reload();
|
||||
},
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess('Rolle gelöscht');
|
||||
toast.showSuccess('Rolle geloescht');
|
||||
setRolePendingDeletion(null);
|
||||
rolesResource.reload();
|
||||
},
|
||||
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 ?? [];
|
||||
@ -110,7 +110,7 @@ export function RolesPage({
|
||||
/**
|
||||
* 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
|
||||
* Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als
|
||||
* Muster trotzdem falsch.
|
||||
@ -119,8 +119,8 @@ export function RolesPage({
|
||||
const grouped = new Map<string, Permission[]>();
|
||||
|
||||
for (const permission of permissionsResource.data ?? []) {
|
||||
// Ohne ausdrückliche Kategorie dient der Teil vor dem Punkt als Bereich —
|
||||
// "backups.read" gehört zu "backups".
|
||||
// Ohne ausdrueckliche Kategorie dient der Teil vor dem Punkt als Bereich —
|
||||
// "backups.read" gehoert zu "backups".
|
||||
const categoryName =
|
||||
permission.category ?? permission.name.split('.')[0] ?? 'sonstige';
|
||||
const existing = grouped.get(categoryName) ?? [];
|
||||
@ -166,7 +166,7 @@ export function RolesPage({
|
||||
header: 'Art',
|
||||
render: (role) => (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{role.is_builtin ? 'Mitgeliefert (unveränderlich)' : 'Selbst angelegt'}
|
||||
{role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@ -179,8 +179,8 @@ export function RolesPage({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Löschen"
|
||||
aria-label={`Rolle ${role.name} löschen`}
|
||||
title="Loeschen"
|
||||
aria-label={`Rolle ${role.name} loeschen`}
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
setRolePendingDeletion(role);
|
||||
@ -211,7 +211,7 @@ export function RolesPage({
|
||||
<>
|
||||
<PageHeader
|
||||
title="Rollen"
|
||||
description="Wer was darf. Geprüft wird immer auf dem Server."
|
||||
description="Wer was darf. Geprueft wird immer auf dem Server."
|
||||
actions={
|
||||
mayWrite ? (
|
||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
||||
@ -243,13 +243,16 @@ export function RolesPage({
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
{selectedRole.is_builtin ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@ -286,7 +289,7 @@ export function RolesPage({
|
||||
label="Beschreibung"
|
||||
value={draftDescription}
|
||||
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">
|
||||
@ -345,9 +348,9 @@ export function RolesPage({
|
||||
<ConfirmDialog
|
||||
open={rolePendingDeletion !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)}
|
||||
title="Rolle löschen"
|
||||
title="Rolle loeschen"
|
||||
description={rolePendingDeletion?.name}
|
||||
confirmLabel="Löschen"
|
||||
confirmLabel="Loeschen"
|
||||
isDestructive
|
||||
isLoading={deleteMutation.isRunning}
|
||||
onConfirm={() => {
|
||||
@ -357,7 +360,8 @@ export function RolesPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
|
||||
@ -84,7 +84,7 @@ export function SettingsPage({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Der Weg ließ sich nicht anlegen',
|
||||
'Der Weg liess sich nicht anlegen',
|
||||
describeApiError(apiError),
|
||||
apiError.requestId,
|
||||
),
|
||||
@ -104,7 +104,7 @@ export function SettingsPage({
|
||||
onSuccess: (enrollment) => setMfaEnrollment(enrollment),
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Die Einrichtung ließ sich nicht beginnen',
|
||||
'Die Einrichtung liess sich nicht beginnen',
|
||||
describeApiError(apiError),
|
||||
apiError.requestId,
|
||||
),
|
||||
@ -114,7 +114,7 @@ export function SettingsPage({
|
||||
onSuccess: () => {
|
||||
toast.showSuccess(
|
||||
'Zweiter Faktor eingerichtet',
|
||||
'Ab der nächsten Anmeldung wird er verlangt.',
|
||||
'Ab der naechsten Anmeldung wird er verlangt.',
|
||||
);
|
||||
setIsMfaConfirmed(true);
|
||||
setMfaEnrollment(null);
|
||||
@ -123,7 +123,7 @@ export function SettingsPage({
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Der Code wurde nicht anerkannt',
|
||||
`${describeApiError(apiError)} — prüfen Sie die Uhrzeit des Geräts; TOTP hängt an der Zeit.`,
|
||||
`${describeApiError(apiError)} — pruefen Sie die Uhrzeit des Geraets; TOTP haengt an der Zeit.`,
|
||||
apiError.requestId,
|
||||
),
|
||||
});
|
||||
@ -182,7 +182,7 @@ export function SettingsPage({
|
||||
<Card className="mb-4">
|
||||
<CardHeader
|
||||
title="Zweiter Faktor"
|
||||
description="Für das eigene Konto."
|
||||
description="Fuer das eigene Konto."
|
||||
action={
|
||||
mfaIsActive ? (
|
||||
<StatusBadge status="healthy" label="Eingerichtet" />
|
||||
@ -202,9 +202,9 @@ export function SettingsPage({
|
||||
<div className="space-y-4">
|
||||
<Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt">
|
||||
Tragen Sie es in Ihre Authentifizierungs-App ein und
|
||||
bestätigen Sie mit dem angezeigten Code. Erst dann wird der
|
||||
zweite Faktor scharf — sonst hätten Sie ein Geheimnis
|
||||
hinterlegt, dessen App Sie nie geprüft haben.
|
||||
bestaetigen Sie mit dem angezeigten Code. Erst dann wird der
|
||||
zweite Faktor scharf — sonst haetten Sie ein Geheimnis
|
||||
hinterlegt, dessen App Sie nie geprueft haben.
|
||||
</Callout>
|
||||
|
||||
<div className="rounded-md border border-line bg-sunken p-3">
|
||||
@ -231,16 +231,16 @@ export function SettingsPage({
|
||||
disabled={mfaCode.trim().length !== 6}
|
||||
onClick={() => void confirmMfaMutation.run(mfaCode.trim())}
|
||||
>
|
||||
Bestätigen
|
||||
Bestaetigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-fg-muted">
|
||||
Ihr Konto ist nur durch sein Passwort geschützt. Bei einem Konto
|
||||
mit Löschrecht ist das der Punkt, an dem eine Übernahme zum
|
||||
Datenverlust führt.
|
||||
Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto
|
||||
mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum
|
||||
Datenverlust fuehrt.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@ -283,7 +283,7 @@ export function SettingsPage({
|
||||
getRowKey={(channel) => channel.id}
|
||||
isLoading={channelsResource.loadState === 'loading'}
|
||||
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={
|
||||
mayWriteSettings ? (
|
||||
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
|
||||
@ -329,7 +329,7 @@ export function SettingsPage({
|
||||
}
|
||||
error={
|
||||
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
|
||||
}
|
||||
/>
|
||||
@ -347,12 +347,19 @@ export function SettingsPage({
|
||||
</SelectInput>
|
||||
|
||||
<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>
|
||||
|
||||
{draftType === 'webhook' ? (
|
||||
<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>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
@ -397,7 +404,9 @@ export function SettingsPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Benutzer.
|
||||
*
|
||||
* Der Schutz des letzten Administrators liegt beim Server — die Oberfläche
|
||||
* kann ihn nicht durchsetzen, weil sie nicht weiß, wer sonst noch Administrator
|
||||
* ist. Was sie tut: den Serverfehler in einen verständlichen Satz übersetzen,
|
||||
* Der Schutz des letzten Administrators liegt beim Server — die Oberflaeche
|
||||
* kann ihn nicht durchsetzen, weil sie nicht weiss, wer sonst noch Administrator
|
||||
* ist. Was sie tut: den Serverfehler in einen verstaendlichen Satz uebersetzen,
|
||||
* statt „LAST_ADMINISTRATOR" anzuzeigen.
|
||||
*
|
||||
* Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein
|
||||
* Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffällt.
|
||||
* Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffaellt.
|
||||
*/
|
||||
|
||||
import { Plus, ShieldOff, Trash2, UserCog } from 'lucide-react';
|
||||
@ -74,7 +74,7 @@ export function UsersPage({
|
||||
onSuccess: (user) => {
|
||||
toast.showSuccess(
|
||||
'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);
|
||||
setDraftUsername('');
|
||||
@ -84,34 +84,34 @@ export function UsersPage({
|
||||
usersResource.reload();
|
||||
},
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess('Konto geändert');
|
||||
toast.showSuccess('Konto geaendert');
|
||||
setUserBeingEdited(null);
|
||||
usersResource.reload();
|
||||
},
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess('Konto gelöscht');
|
||||
toast.showSuccess('Konto geloescht');
|
||||
setUserPendingDeletion(null);
|
||||
usersResource.reload();
|
||||
},
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess(
|
||||
'Zweiter Faktor zurückgesetzt',
|
||||
'Das Konto ist jetzt nur noch durch sein Passwort geschützt.',
|
||||
'Zweiter Faktor zurueckgesetzt',
|
||||
'Das Konto ist jetzt nur noch durch sein Passwort geschuetzt.',
|
||||
);
|
||||
setUserPendingMfaReset(null);
|
||||
usersResource.reload();
|
||||
@ -197,8 +197,8 @@ export function UsersPage({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Zweiten Faktor zurücksetzen"
|
||||
aria-label={`Zweiten Faktor von ${user.username} zurücksetzen`}
|
||||
title="Zweiten Faktor zuruecksetzen"
|
||||
aria-label={`Zweiten Faktor von ${user.username} zuruecksetzen`}
|
||||
onClick={() => setUserPendingMfaReset(user)}
|
||||
>
|
||||
<ShieldOff />
|
||||
@ -207,8 +207,8 @@ export function UsersPage({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Löschen"
|
||||
aria-label={`${user.username} löschen`}
|
||||
title="Loeschen"
|
||||
aria-label={`${user.username} loeschen`}
|
||||
onClick={() => setUserPendingDeletion(user)}
|
||||
>
|
||||
<Trash2 />
|
||||
@ -253,8 +253,8 @@ export function UsersPage({
|
||||
{usersWithoutMfa > 0 ? (
|
||||
<Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt">
|
||||
{usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort
|
||||
geschützt. Bei Konten mit Löschrecht ist das der Punkt, an dem ein
|
||||
übernommenes Konto zum Datenverlust führt.
|
||||
geschuetzt. Bei Konten mit Loeschrecht ist das der Punkt, an dem ein
|
||||
uebernommenes Konto zum Datenverlust fuehrt.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
@ -293,7 +293,7 @@ export function UsersPage({
|
||||
autoComplete="new-password"
|
||||
value={draftPassword}
|
||||
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
|
||||
hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlässt diese Maske nur im Anfragekörper."
|
||||
hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlaesst diese Maske nur im Anfragekoerper."
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -382,7 +382,9 @@ export function UsersPage({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
@ -413,9 +415,9 @@ export function UsersPage({
|
||||
<ConfirmDialog
|
||||
open={userPendingDeletion !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)}
|
||||
title="Konto löschen"
|
||||
title="Konto loeschen"
|
||||
description={userPendingDeletion?.username}
|
||||
confirmLabel="Löschen"
|
||||
confirmLabel="Loeschen"
|
||||
isDestructive
|
||||
isLoading={deleteMutation.isRunning}
|
||||
confirmationWord={userPendingDeletion?.username}
|
||||
@ -426,16 +428,18 @@ export function UsersPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={userPendingMfaReset !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)}
|
||||
title="Zweiten Faktor zurücksetzen"
|
||||
title="Zweiten Faktor zuruecksetzen"
|
||||
description={userPendingMfaReset?.username}
|
||||
confirmLabel="Zurücksetzen"
|
||||
confirmLabel="Zuruecksetzen"
|
||||
isDestructive
|
||||
isLoading={mfaResetMutation.isRunning}
|
||||
onConfirm={() => {
|
||||
@ -445,7 +449,10 @@ export function UsersPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
/**
|
||||
* API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA.
|
||||
*
|
||||
* Diese vier gehören zusammen, weil sie dieselbe Frage betreffen: **Wer darf
|
||||
* was, und wer erfährt davon?** Zwei Schutzmechanismen prägen die Masken:
|
||||
* Diese vier gehoeren zusammen, weil sie dieselbe Frage betreffen: **Wer darf
|
||||
* was, und wer erfaehrt davon?** Zwei Schutzmechanismen praegen die Masken:
|
||||
*
|
||||
* - **Der letzte Administrator ist geschützt** gegen Löschung, Deaktivierung
|
||||
* und Rollenentzug. Sonst wäre die Anlage nicht mehr verwaltbar.
|
||||
* - **Mitgelieferte Rollen sind unveränderlich.** Eine Änderung verschöbe die
|
||||
* - **Der letzte Administrator ist geschuetzt** gegen Loeschung, Deaktivierung
|
||||
* und Rollenentzug. Sonst waere die Anlage nicht mehr verwaltbar.
|
||||
* - **Mitgelieferte Rollen sind unveraenderlich.** Eine Aenderung verschoebe die
|
||||
* Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas
|
||||
* anderes bedeutet als bei ihrer Vergabe.
|
||||
*/
|
||||
@ -28,11 +28,11 @@ export interface User {
|
||||
mfa_enabled: boolean;
|
||||
last_login_at?: string;
|
||||
created_at?: string;
|
||||
/** Zähler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */
|
||||
/** Zaehler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */
|
||||
failed_login_count?: number;
|
||||
}
|
||||
|
||||
/** Lädt die Benutzer. */
|
||||
/** Laedt die Benutzer. */
|
||||
export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
|
||||
return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {});
|
||||
}
|
||||
@ -40,8 +40,8 @@ export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
|
||||
/**
|
||||
* Legt ein Benutzerkonto an.
|
||||
*
|
||||
* Das Passwort geht ausschließlich über den Anfragekörper — niemals als
|
||||
* Abfrageparameter, sonst stünde es in jedem Zugriffsprotokoll.
|
||||
* Das Passwort geht ausschliesslich ueber den Anfragekoerper — niemals als
|
||||
* Abfrageparameter, sonst stuende es in jedem Zugriffsprotokoll.
|
||||
*/
|
||||
export async function createUser(userInput: {
|
||||
username: string;
|
||||
@ -56,7 +56,7 @@ export async function createUser(userInput: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ändert ein Benutzerkonto. */
|
||||
/** Aendert ein Benutzerkonto. */
|
||||
export async function updateUser(
|
||||
userIdentifier: string,
|
||||
changes: {
|
||||
@ -72,7 +72,7 @@ export async function updateUser(
|
||||
});
|
||||
}
|
||||
|
||||
/** Löscht ein Benutzerkonto. */
|
||||
/** Loescht ein Benutzerkonto. */
|
||||
export async function deleteUser(userIdentifier: string): Promise<void> {
|
||||
return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, {
|
||||
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
|
||||
* sicherheitsrelevante Handlung und wird auditiert: Danach genügt das
|
||||
* Der Weg fuer den Fall, dass jemand sein Geraet verloren hat. Es ist eine
|
||||
* sicherheitsrelevante Handlung und wird auditiert: Danach genuegt das
|
||||
* Passwort allein.
|
||||
*/
|
||||
export async function disableUserMfa(userIdentifier: string): Promise<User> {
|
||||
@ -104,7 +104,7 @@ export interface Role {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
/** Mitgelieferte Rollen sind unveränderlich. */
|
||||
/** Mitgelieferte Rollen sind unveraenderlich. */
|
||||
is_builtin?: boolean;
|
||||
user_count?: number;
|
||||
}
|
||||
@ -117,12 +117,12 @@ export interface Permission {
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** Lädt die Rollen. */
|
||||
/** Laedt die Rollen. */
|
||||
export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> {
|
||||
return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {});
|
||||
}
|
||||
|
||||
/** Lädt die vergebbaren Berechtigungen. */
|
||||
/** Laedt die vergebbaren Berechtigungen. */
|
||||
export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> {
|
||||
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(
|
||||
roleIdentifier: 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> {
|
||||
return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
|
||||
method: 'DELETE',
|
||||
@ -163,7 +163,7 @@ export async function deleteRole(roleIdentifier: string): Promise<void> {
|
||||
// Benachrichtigungswege
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Ein Zustellweg für Meldungen. */
|
||||
/** Ein Zustellweg fuer Meldungen. */
|
||||
export interface NotificationChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -179,15 +179,15 @@ export interface NotificationChannel {
|
||||
/**
|
||||
* Konfiguration ohne Geheimnisse.
|
||||
*
|
||||
* Bei einem Webhook steht die Adresse hier, und die trägt oft ein Token im
|
||||
* Pfad — der Server liefert sie deshalb geschwärzt oder gar nicht aus.
|
||||
* Bei einem Webhook steht die Adresse hier, und die traegt oft ein Token im
|
||||
* Pfad — der Server liefert sie deshalb geschwaerzt oder gar nicht aus.
|
||||
*/
|
||||
configuration?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** Lädt die Benachrichtigungswege. */
|
||||
/** Laedt die Benachrichtigungswege. */
|
||||
export async function listNotificationChannels(
|
||||
abortSignal?: AbortSignal,
|
||||
): 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> {
|
||||
return requestApi<void>(
|
||||
`/notification-channels/${encodeURIComponent(channelIdentifier)}`,
|
||||
@ -228,7 +228,7 @@ export async function deleteNotificationChannel(channelIdentifier: string): Prom
|
||||
export interface MfaEnrollment {
|
||||
/** Das gemeinsame Geheimnis in Base32 — einmalig. */
|
||||
secret: string;
|
||||
/** URI für die Authentifizierungs-App. */
|
||||
/** URI fuer die Authentifizierungs-App. */
|
||||
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
|
||||
* ein Geheimnis hinterlegt, dessen App er nie geprüft hat — und sich beim
|
||||
* nächsten Anmelden ausgesperrt.
|
||||
* Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn haette jemand
|
||||
* ein Geheimnis hinterlegt, dessen App er nie geprueft hat — und sich beim
|
||||
* naechsten Anmelden ausgesperrt.
|
||||
*/
|
||||
export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> {
|
||||
return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', {
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
* Meldungen.
|
||||
*
|
||||
* 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ß,
|
||||
* was überhaupt geprüft wird.** Ohne die Liste bliebe offen, ob „keine
|
||||
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprüft".
|
||||
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss,
|
||||
* was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine
|
||||
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft".
|
||||
*
|
||||
* „Zur Kenntnis nehmen" schließt eine Meldung nicht. Sie bleibt offen und in
|
||||
* der Liste, bis ihre Ursache verschwindet — sonst verschwände der Zustand aus
|
||||
* der Übersicht, obwohl er weiterbesteht.
|
||||
* „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in
|
||||
* der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus
|
||||
* der Uebersicht, obwohl er weiterbesteht.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
@ -92,7 +92,7 @@ export function AlertsPage(): React.JSX.Element {
|
||||
title="Meldungen"
|
||||
description={
|
||||
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
|
||||
}
|
||||
/>
|
||||
@ -123,7 +123,7 @@ export function AlertsPage(): React.JSX.Element {
|
||||
{loadState === 'loaded' && data !== null && data.length === 0 ? (
|
||||
<EmptyState
|
||||
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}
|
||||
|
||||
@ -166,8 +166,8 @@ function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.J
|
||||
<p
|
||||
className={cn(
|
||||
'text-2xl font-semibold tabular',
|
||||
// Farbe nur, wenn der Wert eine Aussage trägt: Null kritische
|
||||
// Meldungen sind neutral, nicht grün — grün hieße "geprüft
|
||||
// Farbe nur, wenn der Wert eine Aussage traegt: Null kritische
|
||||
// Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft
|
||||
// und in Ordnung", und das sagt diese Zahl allein nicht.
|
||||
figure.tone && figure.value > 0
|
||||
? figure.tone === 'critical'
|
||||
@ -247,7 +247,7 @@ function AlertRow({
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@ -259,9 +259,9 @@ function AlertRow({
|
||||
/**
|
||||
* Zeigt, worauf geachtet wird.
|
||||
*
|
||||
* Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt
|
||||
* den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten
|
||||
* Regel ausdrücklich, dass sie nicht greift, und warum.
|
||||
* Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt
|
||||
* den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten
|
||||
* Regel ausdruecklich, dass sie nicht greift, und warum.
|
||||
*/
|
||||
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
|
||||
return (
|
||||
@ -285,7 +285,7 @@ function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX
|
||||
{rule.available ? (
|
||||
<StatusBadge status={rule.severity} />
|
||||
) : (
|
||||
<StatusBadge status="neutral" label="wird nicht geprüft" />
|
||||
<StatusBadge status="neutral" label="wird nicht geprueft" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Zugriff auf Meldungen und Benachrichtigungskanäle.
|
||||
* Zugriff auf Meldungen und Benachrichtigungskanaele.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
@ -12,17 +12,17 @@ export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
|
||||
|
||||
/** Eine Meldung. */
|
||||
export interface Alert {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
/** Oeffentlicher Bezeichner. */
|
||||
readonly id: string;
|
||||
/** Auslösende Regel. */
|
||||
/** Ausloesende Regel. */
|
||||
readonly rule_name: string;
|
||||
/** Schweregrad. */
|
||||
readonly severity: AlertSeverity;
|
||||
/** Bearbeitungszustand. */
|
||||
readonly status: AlertStatus;
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Befund und nächste Handlung. */
|
||||
/** Befund und naechste Handlung. */
|
||||
readonly message: string;
|
||||
/** Art des betroffenen Gegenstands. */
|
||||
readonly entity_type?: string;
|
||||
@ -41,11 +41,11 @@ export interface Alert {
|
||||
readonly last_seen_at: string;
|
||||
/** Zeitpunkt der Kenntnisnahme in UTC. */
|
||||
readonly acknowledged_at?: string;
|
||||
/** Bemerkung des Bestätigenden. */
|
||||
/** Bemerkung des Bestaetigenden. */
|
||||
readonly acknowledgement_note?: string;
|
||||
/** Zeitpunkt der Auflösung in UTC. */
|
||||
/** Zeitpunkt der Aufloesung in UTC. */
|
||||
readonly resolved_at?: string;
|
||||
/** Begründung der Auflösung. */
|
||||
/** Begruendung der Aufloesung. */
|
||||
readonly resolution_note?: string;
|
||||
}
|
||||
|
||||
@ -55,13 +55,13 @@ export interface AlertRule {
|
||||
readonly name: string;
|
||||
/** Bezeichnung. */
|
||||
readonly title: string;
|
||||
/** Erklärung. */
|
||||
/** Erklaerung. */
|
||||
readonly description: string;
|
||||
/** Schweregrad ausgelöster Meldungen. */
|
||||
/** Schweregrad ausgeloester Meldungen. */
|
||||
readonly severity: AlertSeverity;
|
||||
/** Meldet, ob die Regel auslösen kann. */
|
||||
/** Meldet, ob die Regel ausloesen kann. */
|
||||
readonly available: boolean;
|
||||
/** Erklärt eine Regel ohne Datengrundlage. */
|
||||
/** Erklaert eine Regel ohne Datengrundlage. */
|
||||
readonly unavailable_reason?: string;
|
||||
}
|
||||
|
||||
@ -79,17 +79,17 @@ export interface AlertSummary {
|
||||
readonly resolved_last_day: number;
|
||||
}
|
||||
|
||||
/** Antwort der Meldungsübersicht. */
|
||||
/** Antwort der Meldungsuebersicht. */
|
||||
export interface AlertOverview {
|
||||
/** Die Meldungslage. */
|
||||
readonly summary: AlertSummary;
|
||||
/** Alle Regeln. */
|
||||
readonly rules: readonly AlertRule[];
|
||||
/** Zahl der auslösbaren Regeln. */
|
||||
/** Zahl der ausloesbaren Regeln. */
|
||||
readonly available_rule_count: number;
|
||||
}
|
||||
|
||||
/** Lädt die Meldungen. */
|
||||
/** Laedt die Meldungen. */
|
||||
export async function fetchAlerts(
|
||||
onlyActive: boolean,
|
||||
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> {
|
||||
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> {
|
||||
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
|
||||
method: 'POST',
|
||||
|
||||
@ -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 = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'admin',
|
||||
@ -22,7 +22,7 @@ const testUser = {
|
||||
permissions: ['users.read'],
|
||||
};
|
||||
|
||||
/** Ein Tokenpaar, wie es die API zurückgibt. */
|
||||
/** Ein Tokenpaar, wie es die API zurueckgibt. */
|
||||
const testTokens = {
|
||||
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
|
||||
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
|
||||
@ -85,7 +85,7 @@ describe('LoginPage', () => {
|
||||
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
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
@ -112,14 +112,14 @@ describe('LoginPage', () => {
|
||||
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
||||
|
||||
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(() => {
|
||||
expect(handleAuthenticated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('zeigt die Fehlermeldung des Servers verständlich an', async () => {
|
||||
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
@ -166,14 +166,14 @@ describe('LoginPage', () => {
|
||||
await userEvent.type(passwordField, 'falsch');
|
||||
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.
|
||||
await waitFor(() => {
|
||||
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
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
@ -205,9 +205,9 @@ describe('LoginPage', () => {
|
||||
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
||||
|
||||
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(() => {
|
||||
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -29,7 +29,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
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 {
|
||||
if (caughtError instanceof ApiError) {
|
||||
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.
|
||||
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
|
||||
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
|
||||
} catch (caughtError) {
|
||||
setErrorMessage(describeError(caughtError));
|
||||
// Das Passwort wird nach einem Fehlversuch geleert.
|
||||
@ -88,7 +88,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
|
||||
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
|
||||
} catch (caughtError) {
|
||||
setErrorMessage(describeError(caughtError));
|
||||
setMfaCode('');
|
||||
@ -106,7 +106,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{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}>
|
||||
{isSubmitting ? 'Anmeldung läuft …' : 'Anmelden'}
|
||||
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
|
||||
</button>
|
||||
</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.5 text-xs text-fg-muted">
|
||||
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
|
||||
Wiederherstellungscode möglich.
|
||||
Wiederherstellungscode moeglich.
|
||||
</p>
|
||||
|
||||
<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"
|
||||
value={mfaCode}
|
||||
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"
|
||||
inputMode="text"
|
||||
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}>
|
||||
{isSubmitting ? 'Prüfung läuft …' : 'Bestätigen'}
|
||||
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
/**
|
||||
* Anmeldefunktionen gegen die Syncova-API.
|
||||
*
|
||||
* Die Tokens liegen im Arbeitsspeicher **und** im Sitzungsspeicher des Tabs.
|
||||
* Warum das eine Abkehr von der ursprünglichen Entscheidung ist und was sie
|
||||
* trägt, steht in `sessionStore.ts` — kurz: Ohne Ablage warf jedes Neuladen den
|
||||
* Betreiber auf die Anmeldemaske, und begrenzt wird die Sitzung ohnehin durch
|
||||
* eine harte Obergrenze und eine Untätigkeitsgrenze.
|
||||
* Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in
|
||||
* localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und
|
||||
* ueberstuenden das Schliessen des Browsers (PROMPT.md §45).
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
|
||||
import { clearStoredSession, readStoredSession, writeStoredSession } from './sessionStore';
|
||||
|
||||
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
|
||||
let currentAccessToken: string | null = null;
|
||||
@ -18,53 +15,27 @@ let currentAccessToken: string | null = null;
|
||||
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
|
||||
let currentRefreshToken: string | null = null;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Liefert das aktuelle Zugriffstoken. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Meldet, ob eine gültige Sitzung wiederaufgenommen werden kann. */
|
||||
export function hasResumableSession(): boolean {
|
||||
return readStoredSession() !== null;
|
||||
}
|
||||
|
||||
/** Hinterlegt ein Tokenpaar nach erfolgreicher Anmeldung. */
|
||||
export function storeTokens(tokenPair: TokenPair): void {
|
||||
currentAccessToken = tokenPair.access_token;
|
||||
currentRefreshToken = tokenPair.refresh_token;
|
||||
writeStoredSession(tokenPair);
|
||||
}
|
||||
|
||||
/** Verwirft die hinterlegten Tokens. */
|
||||
export function clearTokens(): void {
|
||||
currentAccessToken = null;
|
||||
currentRefreshToken = null;
|
||||
clearStoredSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
|
||||
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> {
|
||||
return requestApi<CurrentUser>('/me');
|
||||
}
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Prüft die beiden Grenzen der Sitzung.
|
||||
*
|
||||
* Beide sind eine Zusage an den Betreiber, und beide lassen sich mit einer
|
||||
* falschen Zeile still aushebeln: Wer beim Vermerken einer Interaktion die
|
||||
* harte Obergrenze mitverschiebt, macht aus „30 Minuten" ein „unbegrenzt,
|
||||
* solange die Maus wackelt".
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
SESSION_IDLE_MS,
|
||||
SESSION_MAXIMUM_MS,
|
||||
clearStoredSession,
|
||||
readStoredSession,
|
||||
remainingSessionMs,
|
||||
touchStoredSession,
|
||||
writeStoredSession,
|
||||
} from './sessionStore';
|
||||
|
||||
const tokenPair = {
|
||||
access_token: 'zugriff',
|
||||
refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis
|
||||
access_expires_at: '',
|
||||
refresh_expires_at: '',
|
||||
};
|
||||
|
||||
describe('Sitzungsablage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-18T10:00:00Z'));
|
||||
clearStoredSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('überlebt ein Neuladen innerhalb der Frist', () => {
|
||||
writeStoredSession(tokenPair);
|
||||
|
||||
// Ein Neuladen bedeutet: neuer Arbeitsspeicher, gleiche Ablage.
|
||||
const resumed = readStoredSession();
|
||||
|
||||
expect(resumed).not.toBeNull();
|
||||
expect(resumed?.accessToken).toBe('zugriff');
|
||||
});
|
||||
|
||||
it('endet nach der harten Obergrenze, auch bei ständiger Interaktion', () => {
|
||||
writeStoredSession(tokenPair);
|
||||
|
||||
// Alle fünf Minuten eine Interaktion — sieben Mal, also 35 Minuten.
|
||||
for (let step = 0; step < 7; step += 1) {
|
||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
||||
touchStoredSession();
|
||||
}
|
||||
|
||||
// Die Untätigkeitsgrenze wäre nie erreicht worden. Die Obergrenze schon.
|
||||
expect(readStoredSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('endet bei Untätigkeit vor der Obergrenze', () => {
|
||||
writeStoredSession(tokenPair);
|
||||
|
||||
vi.advanceTimersByTime(SESSION_IDLE_MS + 1000);
|
||||
|
||||
expect(readStoredSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('verschiebt die Obergrenze durch eine Interaktion nicht', () => {
|
||||
const created = writeStoredSession(tokenPair);
|
||||
|
||||
vi.advanceTimersByTime(60 * 1000);
|
||||
const touched = touchStoredSession();
|
||||
|
||||
expect(touched?.absoluteExpiryMs).toBe(created.absoluteExpiryMs);
|
||||
expect(touched?.lastActivityMs).toBeGreaterThan(created.lastActivityMs);
|
||||
});
|
||||
|
||||
it('nennt die frühere der beiden Grenzen als Restzeit', () => {
|
||||
const created = writeStoredSession(tokenPair);
|
||||
|
||||
// Direkt nach der Anmeldung fallen beide Grenzen zusammen.
|
||||
expect(remainingSessionMs(created)).toBe(SESSION_MAXIMUM_MS);
|
||||
|
||||
vi.advanceTimersByTime(20 * 60 * 1000);
|
||||
|
||||
const stillValid = readStoredSession();
|
||||
|
||||
expect(stillValid).not.toBeNull();
|
||||
// Zehn Minuten bis zur Obergrenze, dreißig bis zur Untätigkeitsgrenze —
|
||||
// maßgeblich sind die zehn.
|
||||
expect(remainingSessionMs(stillValid!)).toBe(10 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('verwirft einen unlesbaren Eintrag, statt ihn zu raten', () => {
|
||||
window.sessionStorage.setItem('syncova.session', '{kein gültiges json');
|
||||
|
||||
expect(readStoredSession()).toBeNull();
|
||||
expect(window.sessionStorage.getItem('syncova.session')).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Ablage der Sitzung.
|
||||
*
|
||||
* **Diese Datei kehrt eine frühere Entscheidung um, und zwar bewusst.**
|
||||
*
|
||||
* Bisher lagen die Tokens ausschließlich im Arbeitsspeicher. Das war die
|
||||
* sicherste Variante und praktisch unbrauchbar: Jedes Neuladen — F5, ein
|
||||
* versehentlicher Klick auf „Aktualisieren", ein Absturz des Tabs — warf den
|
||||
* Betreiber auf die Anmeldemaske zurück. Mitten in einer Störung ist das kein
|
||||
* Sicherheitsgewinn, sondern ein Hindernis.
|
||||
*
|
||||
* Gewählt ist `sessionStorage`, nicht `localStorage`:
|
||||
*
|
||||
* | | überlebt F5 | überlebt Tab-Schließen | überlebt Neustart |
|
||||
* | --- | --- | --- | --- |
|
||||
* | Arbeitsspeicher | nein | nein | nein |
|
||||
* | **sessionStorage** | **ja** | **nein** | **nein** |
|
||||
* | localStorage | ja | ja | ja |
|
||||
*
|
||||
* Was den Rest trägt, ist nicht der Speicherort, sondern die Begrenzung:
|
||||
*
|
||||
* 1. **Harte Obergrenze** von 30 Minuten ab Anmeldung — unabhängig von jeder
|
||||
* Aktivität.
|
||||
* 2. **Untätigkeitsgrenze**: Wer nichts tut, wird abgemeldet.
|
||||
* 3. **Sofortiger Widerruf serverseitig.** Die Tokens sind opak, kein JWT —
|
||||
* genau deshalb wurden sie so gewählt. Ein gesperrtes Konto ist mit der
|
||||
* nächsten Anfrage draußen, nicht erst nach Ablauf.
|
||||
*
|
||||
* Bleibt das Restrisiko: Ein Skript, das über XSS auf der Seite läuft, kann
|
||||
* `sessionStorage` lesen. Dagegen steht die Content-Security-Policy der
|
||||
* Auslieferung — und die Feststellung, dass ein Angreifer mit Skriptausführung
|
||||
* ohnehin jede Anfrage im Namen des Angemeldeten stellen könnte, auch bei
|
||||
* Tokens im Arbeitsspeicher.
|
||||
*/
|
||||
|
||||
import type { TokenPair } from '../../types/auth';
|
||||
|
||||
/** Schlüssel der Ablage. */
|
||||
const SESSION_STORAGE_KEY = 'syncova.session';
|
||||
|
||||
/** Harte Obergrenze einer Sitzung ab Anmeldung. */
|
||||
export const SESSION_MAXIMUM_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Grenze der Untätigkeit. */
|
||||
export const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Der abgelegte Zustand einer Sitzung. */
|
||||
export interface StoredSession {
|
||||
readonly accessToken: string;
|
||||
readonly refreshToken: string;
|
||||
/** Zeitpunkt, zu dem die Sitzung endet — unabhängig von Aktivität. */
|
||||
readonly absoluteExpiryMs: number;
|
||||
/** Zeitpunkt der letzten Interaktion. */
|
||||
readonly lastActivityMs: number;
|
||||
}
|
||||
|
||||
/** Liest die abgelegte Sitzung; `null`, wenn keine gültige vorliegt. */
|
||||
export function readStoredSession(): StoredSession | null {
|
||||
let rawValue: string | null = null;
|
||||
|
||||
try {
|
||||
rawValue = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ein Browser mit gesperrtem Speicher ist kein Fehlerfall — die Sitzung
|
||||
// gilt dann eben nur für diese Seitenansicht.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedSession: Partial<StoredSession>;
|
||||
|
||||
try {
|
||||
parsedSession = JSON.parse(rawValue) as Partial<StoredSession>;
|
||||
} catch {
|
||||
// Ein unlesbarer Eintrag wird verworfen, nicht geraten.
|
||||
clearStoredSession();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsedSession.accessToken !== 'string' ||
|
||||
typeof parsedSession.refreshToken !== 'string' ||
|
||||
typeof parsedSession.absoluteExpiryMs !== 'number' ||
|
||||
typeof parsedSession.lastActivityMs !== 'number'
|
||||
) {
|
||||
clearStoredSession();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Beide Grenzen werden schon beim Lesen geprüft. Eine abgelaufene Sitzung
|
||||
// darf gar nicht erst in den Arbeitsspeicher zurückkehren.
|
||||
if (
|
||||
currentTime >= parsedSession.absoluteExpiryMs ||
|
||||
currentTime - parsedSession.lastActivityMs >= SESSION_IDLE_MS
|
||||
) {
|
||||
clearStoredSession();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedSession as StoredSession;
|
||||
}
|
||||
|
||||
/** Legt eine neue Sitzung ab und setzt beide Uhren. */
|
||||
export function writeStoredSession(tokenPair: TokenPair): StoredSession {
|
||||
const currentTime = Date.now();
|
||||
|
||||
const storedSession: StoredSession = {
|
||||
accessToken: tokenPair.access_token,
|
||||
refreshToken: tokenPair.refresh_token,
|
||||
absoluteExpiryMs: currentTime + SESSION_MAXIMUM_MS,
|
||||
lastActivityMs: currentTime,
|
||||
};
|
||||
|
||||
persist(storedSession);
|
||||
|
||||
return storedSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermerkt eine Interaktion.
|
||||
*
|
||||
* Die harte Obergrenze bleibt unberührt — sonst ließe sich eine Sitzung durch
|
||||
* Mausbewegung endlos verlängern, und „30 Minuten" wäre keine Zusage.
|
||||
*/
|
||||
export function touchStoredSession(): StoredSession | null {
|
||||
const storedSession = readStoredSession();
|
||||
|
||||
if (storedSession === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshedSession: StoredSession = {
|
||||
...storedSession,
|
||||
lastActivityMs: Date.now(),
|
||||
};
|
||||
|
||||
persist(refreshedSession);
|
||||
|
||||
return refreshedSession;
|
||||
}
|
||||
|
||||
/** Verwirft die abgelegte Sitzung. */
|
||||
export function clearStoredSession(): void {
|
||||
try {
|
||||
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Nichts zu tun: Es gibt keinen Speicher, aus dem etwas zu entfernen wäre.
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt den Zustand in den Sitzungsspeicher. */
|
||||
function persist(storedSession: StoredSession): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession));
|
||||
} catch {
|
||||
// Ohne Speicher läuft die Sitzung weiter, überlebt aber kein Neuladen.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die verbleibende Zeit in Millisekunden.
|
||||
*
|
||||
* Maßgeblich ist die **frühere** der beiden Grenzen: Eine Sitzung endet, sobald
|
||||
* eine von beiden erreicht ist.
|
||||
*/
|
||||
export function remainingSessionMs(storedSession: StoredSession): number {
|
||||
const currentTime = Date.now();
|
||||
|
||||
const untilAbsolute = storedSession.absoluteExpiryMs - currentTime;
|
||||
const untilIdle = storedSession.lastActivityMs + SESSION_IDLE_MS - currentTime;
|
||||
|
||||
return Math.max(0, Math.min(untilAbsolute, untilIdle));
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Sitzungsuhr und Untätigkeitserkennung.
|
||||
*
|
||||
* Der Haken liefert die verbleibende Zeit sekundengenau für die Anzeige und
|
||||
* meldet das Ende, sobald eine der beiden Grenzen erreicht ist.
|
||||
*
|
||||
* Umgesetzt mit `useSyncExternalStore`, und das ist keine Geschmacksfrage: Die
|
||||
* verbleibende Zeit hängt an zwei Dingen außerhalb von React — der Uhr und dem
|
||||
* Sitzungsspeicher. Sie beim Rendern auszurechnen wäre ein unreiner Aufruf, sie
|
||||
* in einem Effekt in den Zustand zu schreiben eine zweite Renderrunde je
|
||||
* Sekunde. `useSyncExternalStore` ist genau für diesen Fall gebaut.
|
||||
*
|
||||
* Die Interaktionen werden **gedrosselt** vermerkt: Jede Mausbewegung in den
|
||||
* Sitzungsspeicher zu schreiben wären hunderte Schreibvorgänge je Minute, und
|
||||
* `sessionStorage` schreibt synchron. Einmal alle zehn Sekunden genügt — die
|
||||
* Grenze liegt bei dreißig Minuten.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
readStoredSession,
|
||||
remainingSessionMs,
|
||||
touchStoredSession,
|
||||
} from './sessionStore';
|
||||
|
||||
/** Abstand, in dem eine Interaktion frühestens erneut vermerkt wird. */
|
||||
const ACTIVITY_THROTTLE_MS = 10_000;
|
||||
|
||||
/** Ereignisse, die als Interaktion gelten. */
|
||||
const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
|
||||
|
||||
export interface SessionClock {
|
||||
/** Verbleibende Sekunden bis zum Ende der Sitzung. */
|
||||
readonly remainingSeconds: number;
|
||||
/** Fällt die Sitzung in Kürze aus? */
|
||||
readonly isExpiringSoon: boolean;
|
||||
}
|
||||
|
||||
/** Angemeldete Beobachter der Sitzungsuhr. */
|
||||
const clockSubscribers = new Set<() => void>();
|
||||
|
||||
/** Zeitgeber, der einmal je Sekunde alle Beobachter weckt. */
|
||||
let clockIntervalHandle: number | null = null;
|
||||
|
||||
/** Meldet einen Beobachter an und startet den Zeitgeber beim ersten. */
|
||||
function subscribeToClock(notify: () => void): () => void {
|
||||
clockSubscribers.add(notify);
|
||||
|
||||
if (clockIntervalHandle === null) {
|
||||
clockIntervalHandle = window.setInterval(() => {
|
||||
for (const subscriber of clockSubscribers) {
|
||||
subscriber();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clockSubscribers.delete(notify);
|
||||
|
||||
// Ohne Beobachter läuft kein Zeitgeber weiter — sonst tickte er nach dem
|
||||
// Abmelden bis zum Schließen des Tabs.
|
||||
if (clockSubscribers.size === 0 && clockIntervalHandle !== null) {
|
||||
window.clearInterval(clockIntervalHandle);
|
||||
clockIntervalHandle = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die verbleibenden Sekunden.
|
||||
*
|
||||
* Der Wert ist ganzzahlig, damit `useSyncExternalStore` ihn zwischen zwei
|
||||
* Sekunden als unverändert erkennt und kein Rendern auslöst.
|
||||
*/
|
||||
function readRemainingSeconds(): number {
|
||||
const storedSession = readStoredSession();
|
||||
|
||||
if (storedSession === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.ceil(remainingSessionMs(storedSession) / 1000);
|
||||
}
|
||||
|
||||
/** Ohne Anmeldung läuft keine Uhr. */
|
||||
function readZero(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beobachtet die Sitzung und ruft `onExpired` genau einmal auf.
|
||||
*
|
||||
* @param isActive Nur bei bestehender Anmeldung läuft die Uhr.
|
||||
* @param onExpired Wird beim Ablauf aufgerufen.
|
||||
*/
|
||||
export function useSession(isActive: boolean, onExpired: () => void): SessionClock {
|
||||
const remainingSeconds = useSyncExternalStore(
|
||||
isActive ? subscribeToClock : noopSubscribe,
|
||||
isActive ? readRemainingSeconds : readZero,
|
||||
readZero,
|
||||
);
|
||||
|
||||
const onExpiredReference = useRef(onExpired);
|
||||
const lastActivityWriteReference = useRef(0);
|
||||
const hasExpiredReference = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
onExpiredReference.current = onExpired;
|
||||
});
|
||||
|
||||
/** Vermerkt eine Interaktion, höchstens alle zehn Sekunden. */
|
||||
const noteActivity = useCallback(() => {
|
||||
const activityTime = Date.now();
|
||||
|
||||
if (activityTime - lastActivityWriteReference.current < ACTIVITY_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActivityWriteReference.current = activityTime;
|
||||
touchStoredSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
hasExpiredReference.current = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventName of ACTIVITY_EVENTS) {
|
||||
// `passive` verhindert, dass die Erkennung das Scrollen ausbremst.
|
||||
window.addEventListener(eventName, noteActivity, { passive: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const eventName of ACTIVITY_EVENTS) {
|
||||
window.removeEventListener(eventName, noteActivity);
|
||||
}
|
||||
};
|
||||
}, [isActive, noteActivity]);
|
||||
|
||||
// Das Ende wird gemeldet, sobald der abgeleitete Wert null erreicht — als
|
||||
// Folge einer Zustandsänderung, nicht als Nebeneffekt des Renderns.
|
||||
useEffect(() => {
|
||||
if (!isActive || remainingSeconds > 0 || hasExpiredReference.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasExpiredReference.current = true;
|
||||
onExpiredReference.current();
|
||||
}, [isActive, remainingSeconds]);
|
||||
|
||||
return {
|
||||
remainingSeconds,
|
||||
// Fünf Minuten vorher wird die Anzeige auffällig. Früher zu warnen
|
||||
// erzeugt Gewöhnung, später lässt keine Zeit zum Speichern.
|
||||
isExpiringSoon: remainingSeconds > 0 && remainingSeconds <= 300,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ohne Anmeldung gibt es nichts zu beobachten. */
|
||||
function noopSubscribe(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
/** Formatiert eine Sekundenzahl als `mm:ss`. */
|
||||
export function formatSessionClock(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
/**
|
||||
* 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
|
||||
* die Antwort belastbar wird:
|
||||
*
|
||||
* 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist.
|
||||
* 2. Die fehlenden Messungen sind die Handlungsanweisung.
|
||||
* 3. Die Prüfung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
|
||||
* 4. Der Schutz sagt, ob jemand das alles löschen könnte.
|
||||
* 3. Die Pruefung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
|
||||
* 4. Der Schutz sagt, ob jemand das alles loeschen koennte.
|
||||
*
|
||||
* Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest
|
||||
* sich wie "weitgehend in Ordnung" — bei einem beschädigten Backup wäre das
|
||||
* grob irreführend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
|
||||
* sich wie "weitgehend in Ordnung" — bei einem beschaedigten Backup waere das
|
||||
* grob irrefuehrend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
|
||||
* nicht.
|
||||
*/
|
||||
|
||||
@ -116,19 +116,19 @@ export function BackupDetailPage({
|
||||
const verifyMutation = useMutation(startVerification, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess(
|
||||
'Prüfung gestartet',
|
||||
'Das Ergebnis erscheint unter „Prüfung“, sobald sie durchgelaufen ist.',
|
||||
'Pruefung gestartet',
|
||||
'Das Ergebnis erscheint unter „Pruefung“, sobald sie durchgelaufen ist.',
|
||||
);
|
||||
setIsVerifyDialogOpen(false);
|
||||
reloadAll();
|
||||
},
|
||||
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, {
|
||||
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);
|
||||
setHoldReason('');
|
||||
reloadAll();
|
||||
@ -150,7 +150,7 @@ export function BackupDetailPage({
|
||||
|
||||
const extendMutation = useMutation(extendRetention, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess('Aufbewahrungsfrist verlängert');
|
||||
toast.showSuccess('Aufbewahrungsfrist verlaengert');
|
||||
setIsExtendDialogOpen(false);
|
||||
reloadAll();
|
||||
},
|
||||
@ -161,13 +161,13 @@ export function BackupDetailPage({
|
||||
const deleteMutation = useMutation(deleteBackup, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess(
|
||||
'Wiederherstellungspunkt gelöscht',
|
||||
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Blöcke werden von einem anderen Backup gebraucht.',
|
||||
'Wiederherstellungspunkt geloescht',
|
||||
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Bloecke werden von einem anderen Backup gebraucht.',
|
||||
);
|
||||
onDeleted();
|
||||
},
|
||||
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') {
|
||||
@ -215,7 +215,7 @@ export function BackupDetailPage({
|
||||
{mayVerify ? (
|
||||
<Button onClick={() => setIsVerifyDialogOpen(true)}>
|
||||
<BadgeCheck />
|
||||
Prüfen
|
||||
Pruefen
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
@ -251,11 +251,11 @@ export function BackupDetailPage({
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
{!assurance.is_trustworthy ? (
|
||||
<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
|
||||
ist, sagt der Prozentwert wenig.
|
||||
</Callout>
|
||||
@ -307,7 +307,7 @@ export function BackupDetailPage({
|
||||
</div>
|
||||
|
||||
<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
|
||||
? formatDateTime(assurance.last_verified_at)
|
||||
: 'Nie'}
|
||||
@ -328,7 +328,7 @@ export function BackupDetailPage({
|
||||
{/* --- Schutz --- */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader title="Löschschutz" />
|
||||
<CardHeader title="Loeschschutz" />
|
||||
<CardContent className="space-y-3">
|
||||
{protection ? (
|
||||
<>
|
||||
@ -339,7 +339,7 @@ export function BackupDetailPage({
|
||||
<ShieldOff className="size-4 text-fg-subtle" aria-hidden />
|
||||
)}
|
||||
<span className="text-sm text-fg">
|
||||
{protection.is_protected ? 'Geschützt' : 'Nicht geschützt'}
|
||||
{protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -353,7 +353,7 @@ export function BackupDetailPage({
|
||||
|
||||
{protection.legal_hold ? (
|
||||
<Callout tone="info" title="Legal Hold">
|
||||
{protection.legal_hold_reason || 'Ohne Begründung gesetzt.'}
|
||||
{protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'}
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
@ -383,7 +383,7 @@ export function BackupDetailPage({
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => setIsExtendDialogOpen(true)}>
|
||||
Frist verlängern
|
||||
Frist verlaengern
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@ -397,7 +397,7 @@ export function BackupDetailPage({
|
||||
{/* --- Ransomware --- */}
|
||||
{ransomware ? (
|
||||
<Card>
|
||||
<CardHeader title="Auffälligkeiten" />
|
||||
<CardHeader title="Auffaelligkeiten" />
|
||||
<CardContent className="space-y-2">
|
||||
<StatusBadge status={ransomware.severity} />
|
||||
{ransomware.summary ? (
|
||||
@ -405,15 +405,15 @@ export function BackupDetailPage({
|
||||
) : null}
|
||||
{ransomware.severity === 'unknown' ? (
|
||||
<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
|
||||
geratene Schwelle wäre schlechter als keine.
|
||||
geratene Schwelle waere schlechter als keine.
|
||||
</p>
|
||||
) : null}
|
||||
{ransomware.triggered_signal_count !== undefined ? (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{ransomware.triggered_signal_count} von 6 Signalen auffällig.
|
||||
Diese Einschätzung <strong>meldet nur</strong> und handelt nie.
|
||||
{ransomware.triggered_signal_count} von 6 Signalen auffaellig.
|
||||
Diese Einschaetzung <strong>meldet nur</strong> und handelt nie.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@ -428,12 +428,12 @@ export function BackupDetailPage({
|
||||
disabled={protection?.is_protected}
|
||||
title={
|
||||
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
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
Löschen
|
||||
Loeschen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@ -453,14 +453,14 @@ export function BackupDetailPage({
|
||||
<ConfirmDialog
|
||||
open={isVerifyDialogOpen}
|
||||
onOpenChange={setIsVerifyDialogOpen}
|
||||
title="Prüfung starten"
|
||||
confirmLabel="Prüfung starten"
|
||||
title="Pruefung starten"
|
||||
confirmLabel="Pruefung starten"
|
||||
isLoading={verifyMutation.isRunning}
|
||||
onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<SelectInput
|
||||
label="Prüfart"
|
||||
label="Pruefart"
|
||||
value={verificationType}
|
||||
onChange={(changeEvent) => setVerificationType(changeEvent.target.value)}
|
||||
>
|
||||
@ -482,7 +482,7 @@ export function BackupDetailPage({
|
||||
|
||||
{verificationType === 'restore_test' ? (
|
||||
<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.
|
||||
</Callout>
|
||||
) : null}
|
||||
@ -493,17 +493,17 @@ export function BackupDetailPage({
|
||||
open={isLegalHoldDialogOpen}
|
||||
onOpenChange={setIsLegalHoldDialogOpen}
|
||||
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"
|
||||
isLoading={legalHoldMutation.isRunning}
|
||||
onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)}
|
||||
>
|
||||
<TextArea
|
||||
label="Begründung"
|
||||
label="Begruendung"
|
||||
required
|
||||
value={holdReason}
|
||||
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"
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
@ -518,7 +518,7 @@ export function BackupDetailPage({
|
||||
onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)}
|
||||
>
|
||||
<TextArea
|
||||
label="Begründung"
|
||||
label="Begruendung"
|
||||
required
|
||||
value={holdReason}
|
||||
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
|
||||
@ -529,8 +529,8 @@ export function BackupDetailPage({
|
||||
<ConfirmDialog
|
||||
open={isExtendDialogOpen}
|
||||
onOpenChange={setIsExtendDialogOpen}
|
||||
title="Aufbewahrungsfrist verlängern"
|
||||
confirmLabel="Verlängern"
|
||||
title="Aufbewahrungsfrist verlaengern"
|
||||
confirmLabel="Verlaengern"
|
||||
isLoading={extendMutation.isRunning}
|
||||
onConfirm={() =>
|
||||
void extendMutation.run(
|
||||
@ -549,13 +549,13 @@ export function BackupDetailPage({
|
||||
onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Begründung"
|
||||
label="Begruendung"
|
||||
value={holdReason}
|
||||
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
|
||||
/>
|
||||
<Callout tone="info">
|
||||
Verlängern ja, <strong>verkürzen nie</strong> — auch nicht für
|
||||
Administratoren. Ein früheres Datum wird abgelehnt.
|
||||
Verlaengern ja, <strong>verkuerzen nie</strong> — auch nicht fuer
|
||||
Administratoren. Ein frueheres Datum wird abgelehnt.
|
||||
</Callout>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
@ -563,14 +563,14 @@ export function BackupDetailPage({
|
||||
<ConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
title="Wiederherstellungspunkt löschen"
|
||||
confirmLabel="Endgültig löschen"
|
||||
title="Wiederherstellungspunkt loeschen"
|
||||
confirmLabel="Endgueltig loeschen"
|
||||
isDestructive
|
||||
isLoading={deleteMutation.isRunning}
|
||||
confirmationWord={backupIdentifier}
|
||||
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">
|
||||
{backupIdentifier}
|
||||
</code>
|
||||
@ -582,7 +582,7 @@ export function BackupDetailPage({
|
||||
>
|
||||
<Callout tone="warning">
|
||||
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.
|
||||
</Callout>
|
||||
</ConfirmDialog>
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Prüfungen — Übersicht.
|
||||
* Pruefungen — Uebersicht.
|
||||
*
|
||||
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
|
||||
* nebeneinander und wird gern verwechselt:
|
||||
*
|
||||
* - **Zustand** (`status`): Ist die Prüfung durchgelaufen?
|
||||
* - **Zustand** (`status`): Ist die Pruefung durchgelaufen?
|
||||
* - **Ergebnis** (`result`): Was hat sie gefunden?
|
||||
*
|
||||
* Eine gescheiterte Prüfung ist **kein Befund am Backup**. Repository nicht
|
||||
* erreichbar heißt: Auftrag `failed`, Einstufung unberührt. Wer beides
|
||||
* vermengt, baut ein Prüfwerkzeug, das grundlos Alarm schlägt — und das wird
|
||||
* Eine gescheiterte Pruefung ist **kein Befund am Backup**. Repository nicht
|
||||
* erreichbar heisst: Auftrag `failed`, Einstufung unberuehrt. Wer beides
|
||||
* vermengt, baut ein Pruefwerkzeug, das grundlos Alarm schlaegt — und das wird
|
||||
* bald nicht mehr ernst genommen.
|
||||
*/
|
||||
|
||||
@ -54,7 +54,7 @@ export function VerificationPage({
|
||||
|
||||
const cancelMutation = useMutation(cancelVerification, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess('Prüfung abgebrochen');
|
||||
toast.showSuccess('Pruefung abgebrochen');
|
||||
verificationsResource.reload();
|
||||
},
|
||||
onError: (apiError) =>
|
||||
@ -66,7 +66,7 @@ export function VerificationPage({
|
||||
const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Prüfart',
|
||||
header: 'Pruefart',
|
||||
render: (job) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-fg">
|
||||
@ -91,21 +91,21 @@ export function VerificationPage({
|
||||
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.
|
||||
<span className="text-xs text-fg-muted">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'chunks',
|
||||
header: 'Blöcke',
|
||||
header: 'Bloecke',
|
||||
numeric: true,
|
||||
render: (job) => (
|
||||
<span>
|
||||
{job.chunks_checked.toLocaleString('de-DE')}
|
||||
{job.chunks_missing > 0 || job.chunks_corrupted > 0 ? (
|
||||
<span className="ml-1 text-critical">
|
||||
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschädigt)
|
||||
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschaedigt)
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@ -156,7 +156,7 @@ export function VerificationPage({
|
||||
if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Prüfung" />
|
||||
<PageHeader title="Pruefung" />
|
||||
<Card>
|
||||
<ErrorState
|
||||
message={describeApiError(verificationsResource.loadError)}
|
||||
@ -171,8 +171,8 @@ export function VerificationPage({
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Prüfung"
|
||||
description="Nachweise über die Wiederherstellbarkeit."
|
||||
title="Pruefung"
|
||||
description="Nachweise ueber die Wiederherstellbarkeit."
|
||||
actions={
|
||||
<Button variant="secondary" onClick={verificationsResource.reload}>
|
||||
<RefreshCw />
|
||||
@ -182,7 +182,9 @@ export function VerificationPage({
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
@ -192,8 +194,8 @@ export function VerificationPage({
|
||||
getRowKey={(job) => job.id}
|
||||
onRowClick={(job) => onOpenBackup(job.backup_id)}
|
||||
isLoading={verificationsResource.loadState === 'loading'}
|
||||
emptyTitle="Noch keine Prüfung"
|
||||
emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt."
|
||||
emptyTitle="Noch keine Pruefung"
|
||||
emptyDescription="Eine Pruefung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz."
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@ -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?**
|
||||
*
|
||||
* - Die Bewertung sagt, wie gut die Auskunft belegt ist.
|
||||
* - Der Schutz sagt, ob es jemand löschen könnte.
|
||||
* - Die Prüfung erzeugt den Beleg.
|
||||
* - Der Schutz sagt, ob es jemand loeschen koennte.
|
||||
* - Die Pruefung erzeugt den Beleg.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
@ -15,17 +15,17 @@ import { requestApi } from '../../api/client';
|
||||
// Bewertung (Phase 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Eine Eingangsgröße der Bewertung. */
|
||||
/** Eine Eingangsgroesse der Bewertung. */
|
||||
export interface AssuranceInput {
|
||||
name: string;
|
||||
/** Höchstpunktzahl dieser Größe. */
|
||||
/** Hoechstpunktzahl dieser Groesse. */
|
||||
weight: 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
|
||||
* zu schätzen wäre die bequeme und falsche Entscheidung.
|
||||
* Unbekannt zaehlt **nie** als gut. Eine nicht gemessene Groesse wohlwollend
|
||||
* zu schaetzen waere die bequeme und falsche Entscheidung.
|
||||
*/
|
||||
is_known: boolean;
|
||||
}
|
||||
@ -41,19 +41,19 @@ export interface AssuranceReport {
|
||||
/**
|
||||
* Meldet, ob die Zahl belastbar ist.
|
||||
*
|
||||
* Bei zu vielen ungemessenen Größen ist die Prozentzahl eine Vermutung,
|
||||
* keine Aussage — und die Oberfläche muss das sagen.
|
||||
* Bei zu vielen ungemessenen Groessen ist die Prozentzahl eine Vermutung,
|
||||
* keine Aussage — und die Oberflaeche muss das sagen.
|
||||
*/
|
||||
is_trustworthy: boolean;
|
||||
summary: string;
|
||||
/** Was gemessen werden müsste. Das ist die Handlungsanweisung. */
|
||||
/** Was gemessen werden muesste. Das ist die Handlungsanweisung. */
|
||||
missing_measurements: string[];
|
||||
inputs: AssuranceInput[];
|
||||
last_verified_at?: string;
|
||||
last_restore_test_at?: string;
|
||||
}
|
||||
|
||||
/** Lädt die Bewertung eines Wiederherstellungspunkts. */
|
||||
/** Laedt die Bewertung eines Wiederherstellungspunkts. */
|
||||
export async function getAssurance(
|
||||
backupIdentifier: string,
|
||||
abortSignal?: AbortSignal,
|
||||
@ -88,7 +88,7 @@ export interface ProtectionStatus {
|
||||
enforcement_explanation?: string;
|
||||
}
|
||||
|
||||
/** Lädt den Schutzstatus. */
|
||||
/** Laedt den Schutzstatus. */
|
||||
export async function getProtection(
|
||||
backupIdentifier: string,
|
||||
abortSignal?: AbortSignal,
|
||||
@ -102,7 +102,7 @@ export async function getProtection(
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* lehnt ein früheres Datum ab; die Oberfläche sagt das vorher.
|
||||
* Verlaengern ja, verkuerzen nie — auch nicht fuer Administratoren. Der Server
|
||||
* lehnt ein frueheres Datum ab; die Oberflaeche sagt das vorher.
|
||||
*/
|
||||
export async function extendRetention(
|
||||
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
|
||||
* beim Überschreiben einer Wiederherstellung.
|
||||
* `confirm_backup_id` wiederholt die Kennung woertlich — dieselbe Huerde wie
|
||||
* beim Ueberschreiben einer Wiederherstellung.
|
||||
*/
|
||||
export async function deleteBackup(
|
||||
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 {
|
||||
backup_id: string;
|
||||
/** none, elevated, high oder unknown. */
|
||||
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
|
||||
* Verschlüsselung.
|
||||
* Verschluesselung.
|
||||
*/
|
||||
triggered_signal_count?: number;
|
||||
signals?: Array<{
|
||||
@ -187,7 +187,7 @@ export interface RansomwareAssessment {
|
||||
baseline_sample_count?: number;
|
||||
}
|
||||
|
||||
/** Lädt die Ransomware-Einschätzung. */
|
||||
/** Laedt die Ransomware-Einschaetzung. */
|
||||
export async function getRansomwareAssessment(
|
||||
backupIdentifier: string,
|
||||
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 = [
|
||||
{
|
||||
value: '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',
|
||||
label: 'Blockprüfung',
|
||||
label: 'Blockpruefung',
|
||||
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',
|
||||
label: 'Kette',
|
||||
description: 'Prüft die Zusatzsicherungen gegen ihre Elternbackups.',
|
||||
description: 'Prueft die Zusatzsicherungen gegen ihre Elternbackups.',
|
||||
},
|
||||
{
|
||||
value: 'metadata',
|
||||
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',
|
||||
label: 'Wiederherstellungstest',
|
||||
description:
|
||||
'Liest das gesamte Backup und schreibt es versuchsweise zurück. Nur diese Prüfung ist ein Nachweis — alles andere sind Indizien. Sie verlangt ein eigenes Recht.',
|
||||
'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;
|
||||
|
||||
/** Ein Prüfauftrag. */
|
||||
/** Ein Pruefauftrag. */
|
||||
export interface VerificationJob {
|
||||
id: string;
|
||||
backup_id: string;
|
||||
@ -254,7 +254,7 @@ export interface VerificationJob {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Lädt die Prüfaufträge. */
|
||||
/** Laedt die Pruefauftraege. */
|
||||
export async function listVerifications(
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<VerificationJob[]> {
|
||||
@ -264,7 +264,7 @@ export async function listVerifications(
|
||||
);
|
||||
}
|
||||
|
||||
/** Startet eine Prüfung. */
|
||||
/** Startet eine Pruefung. */
|
||||
export async function startVerification(
|
||||
backupIdentifier: 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(
|
||||
verificationIdentifier: string,
|
||||
): Promise<VerificationJob> {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tests der Übersicht.
|
||||
* Tests der Uebersicht.
|
||||
*
|
||||
* Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf
|
||||
* 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 type { Dashboard } from './dashboardApi';
|
||||
|
||||
/** Baut eine Antwort mit den übergebenen Kennzahlen. */
|
||||
/** Baut eine Antwort mit den uebergebenen Kennzahlen. */
|
||||
function buildDashboardResponse(dashboard: Dashboard): Response {
|
||||
return {
|
||||
status: 200,
|
||||
@ -21,7 +21,7 @@ function buildDashboardResponse(dashboard: Dashboard): Response {
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('Übersicht', () => {
|
||||
describe('Uebersicht', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
@ -30,7 +30,7 @@ describe('Übersicht', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('zeigt eine Kennzahl samt Erläuterung', async () => {
|
||||
it('zeigt eine Kennzahl samt Erlaeuterung', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
buildDashboardResponse({
|
||||
available_count: 1,
|
||||
@ -42,7 +42,7 @@ describe('Übersicht', () => {
|
||||
available: true,
|
||||
value: 87.5,
|
||||
unit: '%',
|
||||
detail: '7 von 8 Läufen vollständig erfolgreich.',
|
||||
detail: '7 von 8 Laeufen vollstaendig erfolgreich.',
|
||||
severity: 'high',
|
||||
},
|
||||
],
|
||||
@ -53,7 +53,7 @@ describe('Übersicht', () => {
|
||||
|
||||
expect(await screen.findByText('Erfolgsquote (7 Tage)')).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 () => {
|
||||
@ -78,10 +78,10 @@ describe('Übersicht', () => {
|
||||
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
|
||||
|
||||
// Der entscheidende Teil: keine Null. Eine Null hieße „keine Probleme" und
|
||||
// würde bedeuten „es wird nicht geprüft".
|
||||
// Der entscheidende Teil: keine Null. Eine Null hiesse „keine Probleme" und
|
||||
// wuerde bedeuten „es wird nicht geprueft".
|
||||
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 () => {
|
||||
@ -94,7 +94,7 @@ describe('Übersicht', () => {
|
||||
key: 'storage',
|
||||
title: 'Speicherbelegung',
|
||||
available: true,
|
||||
detail: 'Die Gesamtkapazität ist nicht hinterlegt.',
|
||||
detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.',
|
||||
severity: 'information',
|
||||
},
|
||||
],
|
||||
@ -103,7 +103,7 @@ describe('Übersicht', () => {
|
||||
|
||||
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(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Übersicht der Anlage.
|
||||
* Uebersicht der Anlage.
|
||||
*
|
||||
* Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
|
||||
* 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).
|
||||
*
|
||||
* Der Schweregrad färbt nur die Randlinie, nicht die Kachel. Zehn farbige
|
||||
* Flächen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
|
||||
* Der Schweregrad faerbt nur die Randlinie, nicht die Kachel. Zehn farbige
|
||||
* Flaechen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
|
||||
* untergeht — und genau die soll auffallen.
|
||||
*/
|
||||
|
||||
@ -17,18 +17,10 @@ import { describeApiError } from '@/api/useMutation';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card, ErrorState, LoadingState } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QuickActions } from './QuickActions';
|
||||
import { fetchDashboard } from './dashboardApi';
|
||||
import type { Dashboard, DashboardWidget } from './dashboardApi';
|
||||
|
||||
export function DashboardPage({
|
||||
grantedPermissions = [],
|
||||
onNavigate,
|
||||
}: {
|
||||
readonly grantedPermissions?: readonly string[] | undefined;
|
||||
/** Fehlt der Rückruf, entfallen die Schnellzugriffe. */
|
||||
readonly onNavigate?: ((pageIdentifier: string) => void) | undefined;
|
||||
} = {}): React.JSX.Element {
|
||||
export function DashboardPage(): React.JSX.Element {
|
||||
const loadDashboard = useCallback(
|
||||
(abortSignal: AbortSignal) => fetchDashboard(abortSignal),
|
||||
[],
|
||||
@ -38,7 +30,7 @@ export function DashboardPage({
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Übersicht"
|
||||
title="Uebersicht"
|
||||
description={
|
||||
data !== null
|
||||
? `${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' ? (
|
||||
<Card>
|
||||
<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>> = {
|
||||
healthy: 'border-l-healthy',
|
||||
info: 'border-l-info',
|
||||
@ -92,7 +80,7 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
|
||||
return (
|
||||
<Card className="border-l-4 border-l-neutral p-4">
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
@ -109,8 +97,8 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
|
||||
|
||||
<p className="mt-2 flex items-baseline gap-1.5">
|
||||
{widget.value === undefined ? (
|
||||
// Kein Wert heißt nicht null. Eine Kennzahl, die sich nicht bilden
|
||||
// lässt, sagt das — der Grund steht im Detailtext darunter.
|
||||
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
|
||||
// laesst, sagt das — der Grund steht im Detailtext darunter.
|
||||
<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. */
|
||||
function formatWidgetValue(widgetValue: number): string {
|
||||
// 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
|
||||
// weiß, und auch nicht weniger.
|
||||
// weiss, und auch nicht weniger.
|
||||
if (widgetValue > 0 && widgetValue < 0.1) {
|
||||
return '< 0,1';
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
return widgetValue.toLocaleString('de-DE');
|
||||
}
|
||||
@ -161,7 +149,7 @@ function formatWidgetValue(widgetValue: number): string {
|
||||
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 {
|
||||
const readableLabel = breakdownKey.replace(/_/g, ' ');
|
||||
|
||||
|
||||
@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Schnellzugriffe auf der Übersicht.
|
||||
*
|
||||
* Bisher endete die Übersicht bei Kennzahlen — wer etwas anlegen wollte, musste
|
||||
* erst wissen, unter welchem Menüpunkt es steckt. Die Kacheln nennen die
|
||||
* Handlung, nicht den Bereich: „Auftrag anlegen" statt „Sicherungsaufträge".
|
||||
*
|
||||
* Gezeigt wird nur, was die Rolle auch darf. Eine Kachel, die in eine
|
||||
* Fehlermeldung führt, ist schlimmer als keine.
|
||||
*/
|
||||
|
||||
import {
|
||||
Archive,
|
||||
BadgeCheck,
|
||||
Clock,
|
||||
Database,
|
||||
MonitorCog,
|
||||
RotateCcw,
|
||||
Server,
|
||||
UserPlus,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui';
|
||||
|
||||
interface QuickAction {
|
||||
readonly label: string;
|
||||
readonly hint: string;
|
||||
readonly icon: LucideIcon;
|
||||
readonly targetPageId: string;
|
||||
/** Berechtigung, die die Handlung verlangt. */
|
||||
readonly permission: string;
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS: readonly QuickAction[] = [
|
||||
{
|
||||
label: 'Auftrag anlegen',
|
||||
hint: 'Was wann wohin',
|
||||
icon: Clock,
|
||||
targetPageId: 'jobs',
|
||||
permission: 'jobs.write',
|
||||
},
|
||||
{
|
||||
label: 'Wiederherstellen',
|
||||
hint: 'Aus einem Punkt zurück',
|
||||
icon: RotateCcw,
|
||||
targetPageId: 'recovery-points',
|
||||
permission: 'restores.execute',
|
||||
},
|
||||
{
|
||||
label: 'Repository übernehmen',
|
||||
hint: 'Vorhandene Ablage',
|
||||
icon: Database,
|
||||
targetPageId: 'repositories',
|
||||
permission: 'repositories.write',
|
||||
},
|
||||
{
|
||||
label: 'Prüfung starten',
|
||||
hint: 'Nachweis erzeugen',
|
||||
icon: BadgeCheck,
|
||||
targetPageId: 'recovery-points',
|
||||
permission: 'verification.write',
|
||||
},
|
||||
{
|
||||
label: 'Agent aufnehmen',
|
||||
hint: 'Token erzeugen',
|
||||
icon: MonitorCog,
|
||||
targetPageId: 'agents',
|
||||
permission: 'agents.enroll',
|
||||
},
|
||||
{
|
||||
label: 'Proxmox verbinden',
|
||||
hint: 'Verbund einrichten',
|
||||
icon: Server,
|
||||
targetPageId: 'proxmox',
|
||||
permission: 'providers.write',
|
||||
},
|
||||
{
|
||||
label: 'Aufbewahrung',
|
||||
hint: 'Regel anlegen',
|
||||
icon: Archive,
|
||||
targetPageId: 'retention',
|
||||
permission: 'retention.write',
|
||||
},
|
||||
{
|
||||
label: 'Benutzer anlegen',
|
||||
hint: 'Konto und Rollen',
|
||||
icon: UserPlus,
|
||||
targetPageId: 'users',
|
||||
permission: 'users.write',
|
||||
},
|
||||
];
|
||||
|
||||
export function QuickActions({
|
||||
grantedPermissions,
|
||||
onNavigate,
|
||||
}: {
|
||||
readonly grantedPermissions: readonly string[];
|
||||
readonly onNavigate: (pageIdentifier: string) => void;
|
||||
}) {
|
||||
const isAllowed = (permission: string): boolean =>
|
||||
grantedPermissions.includes('*') || grantedPermissions.includes(permission);
|
||||
|
||||
const visibleActions = QUICK_ACTIONS.filter((action) => isAllowed(action.permission));
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{visibleActions.map((action) => {
|
||||
const IconComponent = action.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={action.label}
|
||||
asChild
|
||||
className="transition-colors hover:border-accent hover:bg-hover"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(action.targetPageId)}
|
||||
className="flex w-full items-center gap-3 p-3 text-left"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent">
|
||||
<IconComponent className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-fg">
|
||||
{action.label}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-fg-muted">{action.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,8 +5,8 @@
|
||||
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
|
||||
* Einstufung in jeder Zeile und nicht in einem Detailfenster.
|
||||
*
|
||||
* Der Filter „Ungeprüft" heißt serverseitig `successful`. Das ist kein
|
||||
* Übersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
|
||||
* Der Filter „Ungeprueft" heisst serverseitig `successful`. Das ist kein
|
||||
* Uebersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
|
||||
* Nachweis der Wiederherstellbarkeit.
|
||||
*/
|
||||
|
||||
@ -28,26 +28,26 @@ import { formatBytes, formatDateTime, formatRelativeTime } from '@/lib/utils';
|
||||
import { fetchRecoveryPoints } from './dashboardApi';
|
||||
import type { RecoveryPoint } from './dashboardApi';
|
||||
|
||||
/** Auswahlmöglichkeiten des Einstufungsfilters. */
|
||||
/** Auswahlmoeglichkeiten des Einstufungsfilters. */
|
||||
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
||||
{ value: '', label: 'Alle Einstufungen' },
|
||||
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
|
||||
{ value: 'verified', label: 'Geprüft' },
|
||||
{ value: 'successful', label: 'Ungeprüft' },
|
||||
{ value: 'corrupted', label: 'Beschädigt' },
|
||||
{ value: 'verified', label: 'Geprueft' },
|
||||
{ value: 'successful', label: 'Ungeprueft' },
|
||||
{ value: 'corrupted', label: 'Beschaedigt' },
|
||||
];
|
||||
|
||||
export function RecoveryPointsPage({
|
||||
onOpenBackup,
|
||||
}: {
|
||||
/** Öffnet die Einzelheiten eines Punktes. */
|
||||
/** Oeffnet die Einzelheiten eines Punktes. */
|
||||
readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined;
|
||||
} = {}): React.JSX.Element {
|
||||
const [classificationFilter, setClassificationFilter] = useState('');
|
||||
const [includeDeleted, setIncludeDeleted] = 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 loadRecoveryPoints = useCallback(
|
||||
@ -131,9 +131,9 @@ export function RecoveryPointsPage({
|
||||
header: '',
|
||||
render: (recoveryPoint) =>
|
||||
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.
|
||||
<span className="text-xs text-fg-subtle">gelöscht</span>
|
||||
<span className="text-xs text-fg-subtle">geloescht</span>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
@ -144,7 +144,7 @@ export function RecoveryPointsPage({
|
||||
title="Wiederherstellungspunkte"
|
||||
description={
|
||||
data !== null
|
||||
? `${data.length} Punkte. Nur ein durchgeführter Wiederherstellungstest hebt auf „wiederherstellbar“.`
|
||||
? `${data.length} Punkte. Nur ein durchgefuehrter Wiederherstellungstest hebt auf „wiederherstellbar“.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@ -168,12 +168,12 @@ export function RecoveryPointsPage({
|
||||
<CheckboxField
|
||||
checked={includeDeleted}
|
||||
onCheckedChange={setIncludeDeleted}
|
||||
label="Gelöschte einschließen"
|
||||
label="Geloeschte einschliessen"
|
||||
/>
|
||||
<CheckboxField
|
||||
checked={onlyProtected}
|
||||
onCheckedChange={setOnlyProtected}
|
||||
label="Nur geschützte"
|
||||
label="Nur geschuetzte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Zugriff auf die Übersicht und die Wiederherstellungspunkte.
|
||||
* Zugriff auf die Uebersicht und die Wiederherstellungspunkte.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
@ -7,29 +7,29 @@ import { requestApi } from '../../api/client';
|
||||
/** Statusfarbe einer Kennzahl. */
|
||||
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
|
||||
|
||||
/** Eine Kennzahl der Übersicht. */
|
||||
/** Eine Kennzahl der Uebersicht. */
|
||||
export interface DashboardWidget {
|
||||
/** Maschinenlesbarer Bezeichner. */
|
||||
readonly key: string;
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Meldet, ob es eine Datengrundlage gibt. */
|
||||
readonly available: boolean;
|
||||
/** Erklärt eine fehlende Datengrundlage. */
|
||||
/** Erklaert eine fehlende Datengrundlage. */
|
||||
readonly unavailable_reason?: string;
|
||||
/** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */
|
||||
/** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */
|
||||
readonly value?: number;
|
||||
/** Einheit der Hauptzahl. */
|
||||
readonly unit?: string;
|
||||
/** Erläuterung in einem Satz. */
|
||||
/** Erlaeuterung in einem Satz. */
|
||||
readonly detail?: string;
|
||||
/** Statusfarbe. */
|
||||
readonly severity: WidgetSeverity;
|
||||
/** Ergänzende Einzelwerte. */
|
||||
/** Ergaenzende Einzelwerte. */
|
||||
readonly breakdown?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Die Übersicht der Anlage. */
|
||||
/** Die Uebersicht der Anlage. */
|
||||
export interface Dashboard {
|
||||
/** Kennzahlen in Anzeigereihenfolge. */
|
||||
readonly widgets: readonly DashboardWidget[];
|
||||
@ -39,9 +39,9 @@ export interface Dashboard {
|
||||
readonly generated_at: string;
|
||||
}
|
||||
|
||||
/** Ein Wiederherstellungspunkt in der Übersicht. */
|
||||
/** Ein Wiederherstellungspunkt in der Uebersicht. */
|
||||
export interface RecoveryPoint {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
/** Oeffentlicher Bezeichner. */
|
||||
readonly id: string;
|
||||
/** Repository des Punktes. */
|
||||
readonly repository_id: string;
|
||||
@ -67,7 +67,7 @@ export interface RecoveryPoint {
|
||||
readonly completed_at?: string;
|
||||
/** Objektive Einstufung. */
|
||||
readonly classification?: string;
|
||||
/** Letzte Integritätsprüfung in UTC. */
|
||||
/** Letzte Integritaetspruefung in UTC. */
|
||||
readonly last_verified_at?: string;
|
||||
/** Letzter Wiederherstellungstest in UTC. */
|
||||
readonly last_restore_test_at?: string;
|
||||
@ -77,34 +77,34 @@ export interface RecoveryPoint {
|
||||
readonly immutable_until?: string;
|
||||
/** Meldet einen unbefristeten Schutz. */
|
||||
readonly legal_hold: boolean;
|
||||
/** Meldet, ob eine Löschung derzeit unzulässig ist. */
|
||||
/** Meldet, ob eine Loeschung derzeit unzulaessig ist. */
|
||||
readonly is_protected: boolean;
|
||||
/** Zeitpunkt der Löschung in UTC. */
|
||||
/** Zeitpunkt der Loeschung in UTC. */
|
||||
readonly deleted_at?: string;
|
||||
/** Begründung der Löschung. */
|
||||
/** Begruendung der Loeschung. */
|
||||
readonly deletion_reason?: string;
|
||||
}
|
||||
|
||||
/** Filter der Liste von Wiederherstellungspunkten. */
|
||||
export interface RecoveryPointFilter {
|
||||
/** Beschränkung auf ein Repository. */
|
||||
/** Beschraenkung auf ein Repository. */
|
||||
readonly repositoryId?: string;
|
||||
/** Beschränkung auf eine Einstufung. */
|
||||
/** Beschraenkung auf eine Einstufung. */
|
||||
readonly classification?: string;
|
||||
/** Nimmt gelöschte Punkte auf. */
|
||||
/** Nimmt geloeschte Punkte auf. */
|
||||
readonly includeDeleted?: boolean;
|
||||
/** Beschränkt auf geschützte Punkte. */
|
||||
/** Beschraenkt auf geschuetzte Punkte. */
|
||||
readonly onlyProtected?: boolean;
|
||||
/** Angeforderte Seite. */
|
||||
readonly page?: number;
|
||||
}
|
||||
|
||||
/** Lädt die Übersicht. */
|
||||
/** Laedt die Uebersicht. */
|
||||
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
|
||||
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
|
||||
}
|
||||
|
||||
/** Lädt eine Seite von Wiederherstellungspunkten. */
|
||||
/** Laedt eine Seite von Wiederherstellungspunkten. */
|
||||
export async function fetchRecoveryPoints(
|
||||
filter: RecoveryPointFilter = {},
|
||||
abortSignal?: AbortSignal,
|
||||
|
||||
@ -39,7 +39,7 @@ describe('SystemHealthPanel', () => {
|
||||
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.getByText(/0\.1\.0-test/)).toBeInTheDocument();
|
||||
});
|
||||
@ -72,7 +72,7 @@ describe('SystemHealthPanel', () => {
|
||||
|
||||
render(<SystemHealthPanel />);
|
||||
|
||||
// Ein Problem darf niemals beschönigt werden (PROMPT.md §140).
|
||||
// Ein Problem darf niemals beschoenigt werden (PROMPT.md §140).
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
|
||||
@ -82,7 +82,7 @@ describe('SystemHealthPanel', () => {
|
||||
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')));
|
||||
|
||||
render(<SystemHealthPanel />);
|
||||
@ -91,13 +91,13 @@ describe('SystemHealthPanel', () => {
|
||||
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.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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(() => {})));
|
||||
|
||||
render(<SystemHealthPanel />);
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
* Anzeige des Systemzustands.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* 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';
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* Hook zum Laden des Systemzustands.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
@ -13,7 +13,7 @@ import type { SystemHealth } from '../../types/api';
|
||||
/** Ladezustand der Systemzustandsabfrage. */
|
||||
export type HealthLoadState = 'loading' | 'loaded' | 'failed';
|
||||
|
||||
/** Rückgabewert des Hooks. */
|
||||
/** Rueckgabewert des Hooks. */
|
||||
export interface UseSystemHealthResult {
|
||||
/** Aktueller Ladezustand. */
|
||||
loadState: HealthLoadState;
|
||||
@ -21,12 +21,12 @@ export interface UseSystemHealthResult {
|
||||
systemHealth: SystemHealth | null;
|
||||
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
|
||||
loadError: ApiError | null;
|
||||
/** Lädt den Systemzustand erneut. */
|
||||
/** Laedt den Systemzustand erneut. */
|
||||
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.
|
||||
* Ein Wert von 0 deaktiviert die automatische Aktualisierung.
|
||||
@ -57,13 +57,13 @@ export function useSystemHealth(refreshIntervalMs = 0): UseSystemHealthResult {
|
||||
setLoadError(null);
|
||||
setLoadState('loaded');
|
||||
} 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') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
setLoadError(
|
||||
caughtError instanceof ApiError
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
* Agenten — Aufnahme und Verwaltung.
|
||||
*
|
||||
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
|
||||
* und danach nur noch als Hash gespeichert. Die Oberfläche sagt das
|
||||
* ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu
|
||||
* und danach nur noch als Hash gespeichert. Die Oberflaeche sagt das
|
||||
* ausdruecklich und laesst sich nicht mit Escape schliessen, solange es zu
|
||||
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
|
||||
*
|
||||
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
|
||||
* könnte er sich als ein anderes System ausgeben.
|
||||
* 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 { useApiResource } from '@/api/useApiResource';
|
||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
||||
@ -32,7 +32,7 @@ import {
|
||||
useToast,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import {
|
||||
createEnrollmentToken,
|
||||
listAgents,
|
||||
@ -71,7 +71,7 @@ export function AgentsPage({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Das Aufnahme-Token ließ sich nicht erzeugen',
|
||||
'Das Aufnahme-Token liess sich nicht erzeugen',
|
||||
describeApiError(apiError),
|
||||
apiError.requestId,
|
||||
),
|
||||
@ -209,7 +209,10 @@ export function AgentsPage({
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
@ -219,7 +222,7 @@ export function AgentsPage({
|
||||
getRowKey={(agent) => agent.id}
|
||||
isLoading={agentsResource.loadState === 'loading'}
|
||||
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={
|
||||
mayEnroll ? (
|
||||
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
|
||||
@ -236,7 +239,7 @@ export function AgentsPage({
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Agent aufnehmen"
|
||||
description="Erzeugt ein einmalig gültiges Aufnahme-Token."
|
||||
description="Erzeugt ein einmalig gueltiges Aufnahme-Token."
|
||||
/>
|
||||
<DialogBody>
|
||||
<TextInput
|
||||
@ -245,10 +248,13 @@ export function AgentsPage({
|
||||
value={draftAgentName}
|
||||
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
|
||||
placeholder="dateiserver-01"
|
||||
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst könnte er sich als ein anderes System ausgeben."
|
||||
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst koennte er sich als ein anderes System ausgeben."
|
||||
/>
|
||||
<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>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
@ -287,7 +293,10 @@ export function AgentsPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
@ -297,19 +306,8 @@ export function AgentsPage({
|
||||
/**
|
||||
* Zeigt das Aufnahme-Token einmalig.
|
||||
*
|
||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
||||
*/
|
||||
/**
|
||||
* Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme.
|
||||
*
|
||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
||||
*
|
||||
* Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token
|
||||
* eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
||||
* jemand `<token>` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die
|
||||
* nichts mit seinem Problem zu tun hat.
|
||||
* Der Dialog laesst sich nicht versehentlich schliessen: Es gibt nur eine
|
||||
* Schaltflaeche, und sie sagt, was sie bewirkt.
|
||||
*/
|
||||
function IssuedTokenDialog({
|
||||
token,
|
||||
@ -319,199 +317,64 @@ function IssuedTokenDialog({
|
||||
readonly onClose: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der
|
||||
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
|
||||
const serverAddress = window.location.origin;
|
||||
|
||||
const copyText = async (textToCopy: string, entryKey: string) => {
|
||||
const copyToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopiedKey(entryKey);
|
||||
window.setTimeout(() => setCopiedKey(null), 2000);
|
||||
await navigator.clipboard.writeText(token.enrollment_token);
|
||||
setHasCopied(true);
|
||||
window.setTimeout(() => setHasCopied(false), 2000);
|
||||
} 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 (
|
||||
<DialogRoot open onOpenChange={() => undefined}>
|
||||
<DialogContent size="xl">
|
||||
<DialogContent size="lg">
|
||||
<DialogHeader
|
||||
title="Agent aufnehmen"
|
||||
description={`Für „${token.agent_name}"`}
|
||||
title="Aufnahme-Token"
|
||||
description={`Fuer den Agenten „${token.agent_name}“`}
|
||||
/>
|
||||
<DialogBody>
|
||||
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
||||
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
|
||||
wenn der Agent aufgenommen ist.
|
||||
Es wird nur als Hash gespeichert und laesst sich nicht wieder
|
||||
abrufen. Schliessen Sie dieses Fenster erst, wenn Sie es sicher
|
||||
hinterlegt haben — sonst muessen Sie ein neues erzeugen.
|
||||
</Callout>
|
||||
|
||||
<div className="border border-line bg-sunken p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<code className="min-w-0 break-all font-mono text-sm text-fg">
|
||||
{token.token}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => void copyText(token.token, 'token')}
|
||||
>
|
||||
{copiedKey === 'token' ? <KeyRound /> : <Copy />}
|
||||
{copiedKey === 'token' ? 'Kopiert' : 'Kopieren'}
|
||||
</Button>
|
||||
</div>
|
||||
{token.expires_at ? (
|
||||
<p className="mt-2 text-xs text-fg-muted">
|
||||
Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="rounded-md border border-line bg-sunken p-3">
|
||||
<code className="block break-all font-mono text-sm text-fg">
|
||||
{token.enrollment_token}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Systemwahl */}
|
||||
<div className="flex gap-2 border-b border-line pb-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={platform === 'linux' ? 'primary' : 'ghost'}
|
||||
onClick={() => setPlatform('linux')}
|
||||
>
|
||||
<Terminal />
|
||||
Linux
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={platform === 'windows' ? 'primary' : 'ghost'}
|
||||
onClick={() => setPlatform('windows')}
|
||||
>
|
||||
<MonitorCog />
|
||||
Windows (PowerShell)
|
||||
</Button>
|
||||
<Button onClick={() => void copyToken()}>
|
||||
{hasCopied ? <KeyRound /> : <Copy />}
|
||||
{hasCopied ? 'Kopiert' : 'Token kopieren'}
|
||||
</Button>
|
||||
|
||||
<div className="border-t border-line pt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||
Auf dem zu sichernden System
|
||||
</p>
|
||||
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
|
||||
syncova-agent enroll --server https://<dieser-server> --token
|
||||
<token> --state /var/lib/syncova-agent/state.json
|
||||
</code>
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
|
||||
Mit einem Verzeichnis haelt sich der Agent fuer registriert und
|
||||
laeuft ohne Token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{platform === 'windows' ? (
|
||||
<Callout tone="warning">
|
||||
Der Windows-Dienst ist gebaut und übersetzt, aber{' '}
|
||||
<strong>nie auf echter Hardware gefahren</strong>. Der
|
||||
Kommandozeilenweg ist nachgewiesen.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{activeSteps.map((step) => (
|
||||
<div key={step.key}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-fg">{step.title}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void copyText(step.command, step.key)}
|
||||
>
|
||||
{copiedKey === step.key ? <KeyRound /> : <Copy />}
|
||||
{copiedKey === step.key ? 'Kopiert' : 'Kopieren'}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto border border-line bg-sunken p-2.5 font-mono text-xs text-fg">
|
||||
{step.command}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Callout tone="info" title="Zwei Stolperstellen">
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
<li>
|
||||
<code className="font-mono">--state</code> erwartet eine{' '}
|
||||
<strong>Datei</strong>, kein Verzeichnis. Mit einem Verzeichnis
|
||||
hält sich der Agent für registriert und läuft ohne Token.
|
||||
</li>
|
||||
<li>
|
||||
Der Agent braucht <strong>Schreibzugriff auf das Repository</strong>.
|
||||
Auf einem gemeinsamen Server ist das der lokale Pfad, bei
|
||||
getrennten Maschinen eine Freigabe.
|
||||
</li>
|
||||
</ul>
|
||||
</Callout>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Der Agent ist aufgenommen
|
||||
Ich habe das Token hinterlegt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -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
|
||||
* 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
|
||||
* System, von dem es Sicherungen geben soll.
|
||||
*/
|
||||
@ -111,7 +111,7 @@ export function ProtectedSystemsPage() {
|
||||
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Geschützte Systeme" />
|
||||
<PageHeader title="Geschuetzte Systeme" />
|
||||
<Card>
|
||||
<ErrorState
|
||||
message={describeApiError(agentsResource.loadError)}
|
||||
@ -126,12 +126,14 @@ export function ProtectedSystemsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Geschützte Systeme"
|
||||
description="Was gesichert wird — Agenten und virtuelle Gäste nebeneinander."
|
||||
title="Geschuetzte Systeme"
|
||||
description="Was gesichert wird — Agenten und virtuelle Gaeste nebeneinander."
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Card className="mb-4">
|
||||
@ -146,7 +148,7 @@ export function ProtectedSystemsPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Virtuelle Gäste" />
|
||||
<CardHeader title="Virtuelle Gaeste" />
|
||||
<DataTable
|
||||
columns={machineColumns}
|
||||
rows={machinesResource.data ?? []}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Proxmox-Verbünde.
|
||||
* Proxmox-Verbuende.
|
||||
*
|
||||
* Dieser Bereich hatte bisher gar keine Oberfläche — neun Endpunkte, die sich
|
||||
* nur über die API bedienen ließen.
|
||||
* Dieser Bereich hatte bisher gar keine Oberflaeche — neun Endpunkte, die sich
|
||||
* 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:
|
||||
*
|
||||
* - **`http://` wird abgelehnt.** Über diese Verbindung geht ein API-Token.
|
||||
* - **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter
|
||||
* „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt,
|
||||
* - **`http://` wird abgelehnt.** Ueber diese Verbindung geht ein API-Token.
|
||||
* - **Ohne hinterlegten Wirtsschluessel keine SSH-Verbindung.** 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 für ein Backup hält.
|
||||
* dann das Archiv, das Syncova fuer ein Backup haelt.
|
||||
*/
|
||||
|
||||
import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||
@ -90,7 +90,7 @@ export function ProxmoxPage({
|
||||
onSuccess: (cluster) => {
|
||||
toast.showSuccess(
|
||||
'Verbund angelegt',
|
||||
`${cluster.name} — prüfen Sie als Nächstes die Verbindung.`,
|
||||
`${cluster.name} — pruefen Sie als Naechstes die Verbindung.`,
|
||||
);
|
||||
setIsCreateDialogOpen(false);
|
||||
setDraftTokenSecret('');
|
||||
@ -98,7 +98,7 @@ export function ProxmoxPage({
|
||||
clustersResource.reload();
|
||||
},
|
||||
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, {
|
||||
@ -110,11 +110,11 @@ export function ProxmoxPage({
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
|
||||
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit völlig
|
||||
// verschiedener Abhilfe — sie dürfen nicht dieselbe Meldung ergeben.
|
||||
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit voellig
|
||||
// verschiedener Abhilfe — sie duerfen nicht dieselbe Meldung ergeben.
|
||||
apiError.statusCode === 503
|
||||
? `${describeApiError(apiError)} — prüfen Sie Netz, Adresse und Zertifikat.`
|
||||
: `${describeApiError(apiError)} — prüfen Sie Token-Kennung und Geheimnis.`,
|
||||
? `${describeApiError(apiError)} — pruefen Sie Netz, Adresse und Zertifikat.`
|
||||
: `${describeApiError(apiError)} — pruefen Sie Token-Kennung und Geheimnis.`,
|
||||
apiError.requestId,
|
||||
),
|
||||
});
|
||||
@ -124,7 +124,7 @@ export function ProxmoxPage({
|
||||
toast.showSuccess(
|
||||
'Bestand erfasst',
|
||||
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();
|
||||
},
|
||||
@ -141,7 +141,7 @@ export function ProxmoxPage({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Der Verbund ließ sich nicht entfernen',
|
||||
'Der Verbund liess sich nicht entfernen',
|
||||
apiError.statusCode === 409
|
||||
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
|
||||
: describeApiError(apiError),
|
||||
@ -191,7 +191,7 @@ export function ProxmoxPage({
|
||||
) : (
|
||||
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
|
||||
// Versuch zu setzen machte daraus ein "zuletzt versucht", und ein
|
||||
// seit Wochen toter Verbund sähe frisch aus.
|
||||
// seit Wochen toter Verbund saehe frisch aus.
|
||||
<span className="text-xs text-warning">Nie erreicht</span>
|
||||
),
|
||||
},
|
||||
@ -216,7 +216,7 @@ export function ProxmoxPage({
|
||||
<>
|
||||
<PageHeader
|
||||
title="Proxmox"
|
||||
description="Virtualisierungsverbünde und ihre Gäste."
|
||||
description="Virtualisierungsverbuende und ihre Gaeste."
|
||||
actions={
|
||||
mayWrite ? (
|
||||
<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">
|
||||
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>
|
||||
|
||||
<Card>
|
||||
@ -239,7 +242,7 @@ export function ProxmoxPage({
|
||||
onRowClick={(cluster) => setSelectedCluster(cluster)}
|
||||
isLoading={clustersResource.loadState === 'loading'}
|
||||
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={
|
||||
mayWrite ? (
|
||||
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
|
||||
@ -269,7 +272,7 @@ export function ProxmoxPage({
|
||||
<DialogContent size="lg">
|
||||
<DialogHeader
|
||||
title="Proxmox-Verbund anlegen"
|
||||
description="Zugangsdaten werden verschlüsselt abgelegt und nie wieder ausgeliefert."
|
||||
description="Zugangsdaten werden verschluesselt abgelegt und nie wieder ausgeliefert."
|
||||
/>
|
||||
<DialogBody>
|
||||
<TextInput
|
||||
@ -289,7 +292,7 @@ export function ProxmoxPage({
|
||||
placeholder="https://pve.example:8006"
|
||||
error={
|
||||
draftEndpoint.trim().length > 8 && !endpointIsSecure
|
||||
? 'Nur https. Über diese Verbindung geht ein API-Token.'
|
||||
? 'Nur https. Ueber diese Verbindung geht ein API-Token.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@ -320,11 +323,11 @@ export function ProxmoxPage({
|
||||
value={draftFingerprint}
|
||||
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
|
||||
placeholder="AB:CD:EF:…"
|
||||
hint="Fingerabdruckbindung ist strenger als eine CA-Prüfung: Es gilt genau ein Zertifikat."
|
||||
hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat."
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Speicherkennung für Sicherungen"
|
||||
label="Speicherkennung fuer Sicherungen"
|
||||
required
|
||||
className="font-mono"
|
||||
value={draftStorageId}
|
||||
@ -339,7 +342,7 @@ export function ProxmoxPage({
|
||||
onChange={(changeEvent) =>
|
||||
setDraftTransport(changeEvent.target.value as TransportKind)
|
||||
}
|
||||
hint="Proxmox kann eine Sicherung anstoßen, die entstandene Datei aber nicht über die API herausgeben. Deshalb braucht es einen zweiten Weg."
|
||||
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="ssh">SSH zum Knoten</option>
|
||||
@ -364,15 +367,15 @@ export function ProxmoxPage({
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Privater Schlüssel (PEM)"
|
||||
label="Privater Schluessel (PEM)"
|
||||
required
|
||||
className="font-mono text-xs"
|
||||
value={draftSshKey}
|
||||
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schlüssel */
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schluessel */
|
||||
/>
|
||||
<TextInput
|
||||
label="Wirtsschlüssel-Fingerabdruck"
|
||||
label="Wirtsschluessel-Fingerabdruck"
|
||||
required
|
||||
className="font-mono text-xs"
|
||||
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."
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@ -390,7 +396,7 @@ export function ProxmoxPage({
|
||||
checked={draftKeepArchive}
|
||||
onCheckedChange={setDraftKeepArchive}
|
||||
label="Archiv auf dem Knoten behalten"
|
||||
hint="Normalerweise wird es nach dem Einlesen entfernt. Behält man es, füllt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, für die keine Aufbewahrungsregel gilt."
|
||||
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>
|
||||
<DialogFooter>
|
||||
@ -455,7 +461,8 @@ export function ProxmoxPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
@ -508,8 +515,8 @@ function ClusterDetailCard({
|
||||
header: 'Zustand',
|
||||
render: (machine) =>
|
||||
machine.missing_since ? (
|
||||
// Ein fehlender Gast wird vermerkt, nie gelöscht — eine gelöschte
|
||||
// Zeile nähme die Zuordnung zu vorhandenen Backups mit.
|
||||
// Ein fehlender Gast wird vermerkt, nie geloescht — eine geloeschte
|
||||
// Zeile naehme die Zuordnung zu vorhandenen Backups mit.
|
||||
<span className="text-xs text-warning">
|
||||
Fehlt seit {formatRelativeTime(machine.missing_since)}
|
||||
</span>
|
||||
@ -535,8 +542,8 @@ function ClusterDetailCard({
|
||||
{machine.disk_count}
|
||||
{machine.excluded_disk_count > 0 ? (
|
||||
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
|
||||
// Wiederherstellung liefert sonst eine unvollständige Maschine,
|
||||
// die jemand für vollständig hält.
|
||||
// Wiederherstellung liefert sonst eine unvollstaendige Maschine,
|
||||
// die jemand fuer vollstaendig haelt.
|
||||
<span className="ml-1 text-warning">
|
||||
({machine.excluded_disk_count} ausgenommen)
|
||||
</span>
|
||||
@ -553,7 +560,7 @@ function ClusterDetailCard({
|
||||
description={cluster.api_endpoint}
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@ -575,7 +582,7 @@ function ClusterDetailCard({
|
||||
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
|
||||
<Button size="sm" isLoading={isTesting} onClick={onTest}>
|
||||
<Network />
|
||||
Verbindung prüfen
|
||||
Verbindung pruefen
|
||||
</Button>
|
||||
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
|
||||
<Search />
|
||||
@ -594,7 +601,7 @@ function ClusterDetailCard({
|
||||
|
||||
<div className="mt-5 border-t border-line pt-4">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||
Gäste
|
||||
Gaeste
|
||||
</p>
|
||||
<DataTable
|
||||
columns={machineColumns}
|
||||
@ -602,7 +609,7 @@ function ClusterDetailCard({
|
||||
getRowKey={(machine) => machine.id}
|
||||
isLoading={machinesResource.loadState === 'loading'}
|
||||
emptyTitle="Kein Gast erfasst"
|
||||
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gäste dieses Verbunds zu sehen."
|
||||
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gaeste dieses Verbunds zu sehen."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -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
|
||||
* Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen —
|
||||
* die API gibt sie nicht zurück, und das ist kein Mangel, sondern der Grund,
|
||||
* warum ein Lesezugriff auf die Konfiguration ungefährlich ist.
|
||||
* die API gibt sie nicht zurueck, und das ist kein Mangel, sondern der Grund,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxmox-Verbünde
|
||||
// Proxmox-Verbuende
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Zugriffsweg auf die Sicherungsarchive. */
|
||||
@ -44,7 +44,7 @@ export interface ClusterInput {
|
||||
name: string;
|
||||
api_endpoint: string;
|
||||
api_token_id: string;
|
||||
/** Wird verschlüsselt abgelegt und nie wieder ausgeliefert. */
|
||||
/** Wird verschluesselt abgelegt und nie wieder ausgeliefert. */
|
||||
api_token_secret: string;
|
||||
tls_fingerprint?: string;
|
||||
backup_storage_id: string;
|
||||
@ -53,7 +53,7 @@ export interface ClusterInput {
|
||||
ssh_username?: string;
|
||||
ssh_port?: number;
|
||||
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>;
|
||||
keep_archive_on_node?: boolean;
|
||||
}
|
||||
@ -83,23 +83,23 @@ export interface VirtualMachine {
|
||||
/**
|
||||
* 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
|
||||
* unvollständige Maschine liefert, die jemand für vollständig hält.
|
||||
* unvollstaendige Maschine liefert, die jemand fuer vollstaendig haelt.
|
||||
*/
|
||||
excluded_disk_count: number;
|
||||
/**
|
||||
* Zeitpunkt, seit dem der Gast im Bestand fehlt.
|
||||
*
|
||||
* Ein fehlender Gast wird nie gelöscht: Er könnte abgeschaltet oder
|
||||
* verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu
|
||||
* Ein fehlender Gast wird nie geloescht: Er koennte abgeschaltet oder
|
||||
* verschoben sein, und eine geloeschte Zeile naehme die Zuordnung zu
|
||||
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
|
||||
* weg ist.
|
||||
*/
|
||||
missing_since?: string;
|
||||
}
|
||||
|
||||
/** Lädt die Verbünde. */
|
||||
/** Laedt die Verbuende. */
|
||||
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
|
||||
return requestApi<Cluster[]>(
|
||||
'/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> {
|
||||
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
|
||||
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
|
||||
* 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(
|
||||
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(
|
||||
clusterIdentifier: string,
|
||||
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(
|
||||
clusterIdentifier: string,
|
||||
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(
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<VirtualMachine[]> {
|
||||
@ -197,27 +197,20 @@ export interface Agent {
|
||||
enrolled_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antwort auf die Erzeugung eines Aufnahme-Tokens.
|
||||
*
|
||||
* Das Feld heißt **`token`**, nicht `enrollment_token` — Letzteres ist der Name
|
||||
* im *Anfrage*körper der Registrierung. Die Verwechslung ließ die Oberfläche
|
||||
* „undefined" anzeigen, und der Betreiber hatte kein Token, obwohl der Server
|
||||
* eines ausgestellt hatte.
|
||||
*/
|
||||
/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */
|
||||
export interface EnrollmentToken {
|
||||
id: string;
|
||||
/**
|
||||
* 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;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
/** Lädt die Agenten. */
|
||||
/** Laedt die Agenten. */
|
||||
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
|
||||
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
|
||||
}
|
||||
@ -225,7 +218,7 @@ export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(
|
||||
agentIdentifier: string,
|
||||
abortSignal?: AbortSignal,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Tests des Backup-Assistenten.
|
||||
*
|
||||
* Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte
|
||||
* aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine
|
||||
* Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte
|
||||
* aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine
|
||||
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
|
||||
*/
|
||||
|
||||
@ -40,15 +40,15 @@ beforeEach(() => {
|
||||
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> {
|
||||
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.type(screen.getByLabelText('Pfad'), '/daten');
|
||||
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' }));
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ describe('Backup-Assistent', () => {
|
||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
// 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());
|
||||
|
||||
for (const stepTitle of [
|
||||
@ -67,9 +67,9 @@ describe('Backup-Assistent', () => {
|
||||
'Repository',
|
||||
'Aufbewahrung',
|
||||
'Sicherheit',
|
||||
'Prüfung',
|
||||
'Pruefung',
|
||||
'Benachrichtigung',
|
||||
'Übersicht',
|
||||
'Uebersicht',
|
||||
'Anlegen',
|
||||
]) {
|
||||
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
|
||||
// zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
|
||||
it('hält bei einem leeren Namen auf und erklärt warum', async () => {
|
||||
// zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
|
||||
it('haelt bei einem leeren Namen auf und erklaert warum', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
@ -90,7 +90,7 @@ describe('Backup-Assistent', () => {
|
||||
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();
|
||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
@ -104,17 +104,17 @@ describe('Backup-Assistent', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
|
||||
});
|
||||
|
||||
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre
|
||||
// jeder Blick zurück ein Datenverlust.
|
||||
it('bewahrt die Eingaben beim Zurückgehen', async () => {
|
||||
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst waere
|
||||
// jeder Blick zurueck ein Datenverlust.
|
||||
it('bewahrt die Eingaben beim Zurueckgehen', async () => {
|
||||
const user = userEvent.setup();
|
||||
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: '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 () => {
|
||||
@ -126,12 +126,12 @@ describe('Backup-Assistent', () => {
|
||||
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
||||
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
|
||||
// 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();
|
||||
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
|
||||
// vorgetäuschtes Funktionsversprechen (PROMPT.md §138).
|
||||
// vorgetaeuschtes Funktionsversprechen (PROMPT.md §138).
|
||||
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||
@ -160,15 +160,15 @@ describe('Backup-Assistent', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||
|
||||
// Schritt 5: Aufbewahrung.
|
||||
expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument();
|
||||
expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Noch nicht verfuegbar')).toBeInTheDocument();
|
||||
expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('legt den Auftrag an und meldet ihn nach oben', async () => {
|
||||
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 handleCreated = vi.fn();
|
||||
|
||||
@ -190,20 +190,20 @@ describe('Backup-Assistent', () => {
|
||||
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
|
||||
|
||||
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?.repository_id).toBe(writableRepository.id);
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
|
||||
new ApiError({
|
||||
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,
|
||||
requestId: 'req-1',
|
||||
}),
|
||||
@ -225,8 +225,8 @@ describe('Backup-Assistent', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
|
||||
);
|
||||
|
||||
// Der Assistent bleibt stehen, damit der Anwender zurückgehen und den
|
||||
// Zeitplan ändern kann.
|
||||
// Der Assistent bleibt stehen, damit der Anwender zurueckgehen und den
|
||||
// Zeitplan aendern kann.
|
||||
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();
|
||||
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||
|
||||
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine
|
||||
// Prüfung überspringen, die der Assistent gerade führen soll.
|
||||
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
|
||||
// Pruefung ueberspringen, die der Assistent gerade fuehren soll.
|
||||
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /1\s*Name/ }));
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10).
|
||||
*
|
||||
* Der Assistent führt von Name bis Anlegen. Die Prüfung geschieht je Schritt,
|
||||
* damit ein Fehler dort auffällt, wo er entsteht - nicht erst nach dem letzten
|
||||
* Schritt. Rückwärts geht es ohne Datenverlust: Der Entwurf lebt in einem
|
||||
* Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt,
|
||||
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten
|
||||
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem
|
||||
* Zustand, nicht in den Eingabefeldern.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 {
|
||||
buildCreateRequest,
|
||||
@ -58,7 +58,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
const stepValidation = validateStep(currentStep, jobDraft);
|
||||
|
||||
// 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(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
@ -75,17 +75,17 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
return () => abortController.abort();
|
||||
}, []);
|
||||
|
||||
/** Ändert ein Feld des Entwurfs. */
|
||||
/** Aendert ein Feld des Entwurfs. */
|
||||
function updateDraft(changedFields: Partial<JobDraft>): void {
|
||||
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
|
||||
setShowProblems(false);
|
||||
}
|
||||
|
||||
/** Geht einen Schritt vor, sofern der aktuelle vollständig ist. */
|
||||
/** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */
|
||||
function goToNextStep(): void {
|
||||
if (!stepValidation.isComplete) {
|
||||
// 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);
|
||||
|
||||
return;
|
||||
@ -95,7 +95,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
|
||||
}
|
||||
|
||||
/** Geht einen Schritt zurück. */
|
||||
/** Geht einen Schritt zurueck. */
|
||||
function goToPreviousStep(): void {
|
||||
setShowProblems(false);
|
||||
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
|
||||
@ -171,7 +171,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
<div className="flex gap-2">
|
||||
{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}>
|
||||
Zurück
|
||||
Zurueck
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -195,7 +195,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
</section>
|
||||
);
|
||||
|
||||
/** Wählt den Inhalt des aktuellen Schrittes. */
|
||||
/** Waehlt den Inhalt des aktuellen Schrittes. */
|
||||
function renderStepContent(): React.JSX.Element {
|
||||
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
|
||||
|
||||
@ -227,14 +227,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
* Zeigt einen Schritt ohne Umsetzung im Backend.
|
||||
*
|
||||
* 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
|
||||
* tatsächlich geschieht.
|
||||
* tatsaechlich geschieht.
|
||||
*/
|
||||
function renderUnavailableStep(explanationText: string): React.JSX.Element {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@ -251,7 +251,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
type="text"
|
||||
value={jobDraft.name}
|
||||
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
|
||||
placeholder="Nächtliche Sicherung Dateiserver"
|
||||
placeholder="Naechtliche Sicherung Dateiserver"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
@ -271,7 +271,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
|
||||
/** Schritt 2: Quellen. */
|
||||
function renderSourceStep(): React.JSX.Element {
|
||||
/** Ändert eine einzelne Quelle. */
|
||||
/** Aendert eine einzelne Quelle. */
|
||||
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
|
||||
updateDraft({
|
||||
sources: jobDraft.sources.map((existingSource, currentIndex) =>
|
||||
@ -302,7 +302,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
|
||||
>
|
||||
{SOURCE_TYPE_LABELS[sourceType]}
|
||||
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfügbar)' : ''}
|
||||
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -332,7 +332,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
</label>
|
||||
|
||||
<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
|
||||
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"
|
||||
@ -392,10 +392,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
}
|
||||
>
|
||||
<option value="manual">Nur auf Anforderung</option>
|
||||
<option value="interval">In festen Abständen</option>
|
||||
<option value="hourly">Stündlich</option>
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
<option value="interval">In festen Abstaenden</option>
|
||||
<option value="hourly">Stuendlich</option>
|
||||
<option value="daily">Taeglich</option>
|
||||
<option value="weekly">Woechentlich</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
<option value="cron">Cron-Ausdruck</option>
|
||||
</select>
|
||||
@ -476,10 +476,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
</label>
|
||||
))}
|
||||
</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. */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* --- 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>
|
||||
);
|
||||
}
|
||||
@ -594,7 +529,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
if (repositories.length === 0) {
|
||||
return (
|
||||
<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 <pfad> --name <name></code> angelegt.
|
||||
</p>
|
||||
);
|
||||
@ -619,7 +554,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
|
||||
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
|
||||
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 && (
|
||||
<span className="text-xs text-warning">
|
||||
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
|
||||
@ -636,12 +571,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
function renderSecurityStep(): React.JSX.Element {
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Verschlüsselung ist keine Wahl: Der Executor verweigert den Dienst
|
||||
ohne Schlüsselmaterial. Eine Schaltfläche zum Abschalten wäre
|
||||
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
|
||||
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
|
||||
eine Einstellung, die es nicht gibt. */}
|
||||
<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
|
||||
verschlüsselt, bevor sie das System verlassen. Der Schlüssel gehört zum Repository und
|
||||
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
|
||||
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
|
||||
wird vom Dienst verwaltet.
|
||||
</div>
|
||||
|
||||
@ -660,7 +595,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
@ -681,7 +616,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
);
|
||||
}
|
||||
|
||||
/** Schritt 9: Übersicht. */
|
||||
/** Schritt 9: Uebersicht. */
|
||||
function renderReviewStep(): React.JSX.Element {
|
||||
const selectedRepository = repositories.find(
|
||||
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
|
||||
@ -729,12 +664,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
|
||||
<dt>Sicherheit</dt>
|
||||
<dd>
|
||||
verschlüsselt, Dringlichkeit {jobDraft.priority}
|
||||
{jobDraft.bandwidthLimit.trim() !== '' && `, höchstens ${jobDraft.bandwidthLimit}`}
|
||||
verschluesselt, Dringlichkeit {jobDraft.priority}
|
||||
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`}
|
||||
</dd>
|
||||
|
||||
<dt>Prüfung</dt>
|
||||
<dd className="text-sm text-fg-muted">keine automatische Prüfung</dd>
|
||||
<dt>Pruefung</dt>
|
||||
<dd className="text-sm text-fg-muted">keine automatische Pruefung</dd>
|
||||
|
||||
<dt>Benachrichtigung</dt>
|
||||
<dd className="text-sm text-fg-muted">keine</dd>
|
||||
@ -747,8 +682,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<p>
|
||||
Der Auftrag wird angelegt und läuft ab sofort nach Zeitplan. Der erste Lauf ist eine
|
||||
vollständige Sicherung; jeder weitere liest nur, was sich geändert hat.
|
||||
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
|
||||
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
|
||||
</p>
|
||||
|
||||
{submitError !== null && (
|
||||
@ -764,8 +699,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
/**
|
||||
* Bildet die Darstellung eines Schrittes in der Kopfzeile.
|
||||
*
|
||||
* Ein noch nicht erreichter Schritt bleibt gedämpft und ist nicht anklickbar —
|
||||
* er übersprang eine Prüfung.
|
||||
* Ein noch nicht erreichter Schritt bleibt gedaempft und ist nicht anklickbar —
|
||||
* er uebersprang eine Pruefung.
|
||||
*/
|
||||
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (caughtError instanceof ApiError) {
|
||||
return caughtError.message;
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
* Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum**
|
||||
* etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht
|
||||
* nur "fehlgeschlagen", sondern Fehlercode, Fehlerklasse und Meldung — und die
|
||||
* Klasse sagt zugleich, ob eine Wiederholung überhaupt etwas bringt: Ein
|
||||
* Klasse sagt zugleich, ob eine Wiederholung ueberhaupt etwas bringt: Ein
|
||||
* Anmeldefehler behebt sich nicht durch Warten.
|
||||
*
|
||||
* Ein Teilfehler bekommt eine eigene Erläuterung. "Erfolgreich mit
|
||||
* übergangenen Objekten" gibt es nicht — die Datenbank lässt es per CHECK
|
||||
* nicht zu, und die Oberfläche darf es erst recht nicht suggerieren.
|
||||
* Ein Teilfehler bekommt eine eigene Erlaeuterung. "Erfolgreich mit
|
||||
* uebergangenen Objekten" gibt es nicht — die Datenbank laesst es per CHECK
|
||||
* nicht zu, und die Oberflaeche darf es erst recht nicht suggerieren.
|
||||
*/
|
||||
|
||||
import { Ban, Pause, Play, RefreshCw } from 'lucide-react';
|
||||
@ -45,7 +45,6 @@ import {
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
WEEKDAY_LABELS,
|
||||
type BackupJobRun,
|
||||
} from './jobsApi';
|
||||
|
||||
@ -85,8 +84,8 @@ export function JobDetailPage({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
apiError.statusCode === 409
|
||||
? toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError))
|
||||
: toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId),
|
||||
? toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError))
|
||||
: toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId),
|
||||
});
|
||||
|
||||
const pauseMutation = useMutation(pauseJob, {
|
||||
@ -109,11 +108,11 @@ export function JobDetailPage({
|
||||
|
||||
const cancelRunMutation = useMutation(cancelJobRun, {
|
||||
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();
|
||||
},
|
||||
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') {
|
||||
@ -123,7 +122,7 @@ export function JobDetailPage({
|
||||
if (jobResource.loadState === 'failed' || !jobResource.data) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Aufträgen" />
|
||||
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Auftraegen" />
|
||||
<Card>
|
||||
<ErrorState
|
||||
message={
|
||||
@ -187,10 +186,10 @@ export function JobDetailPage({
|
||||
<span>
|
||||
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
|
||||
{run.files_skipped ? (
|
||||
// Übergangene Objekte sind der Grund für den Teilfehler und
|
||||
// gehören deshalb in dieselbe Zelle, nicht in eine Fußnote.
|
||||
// Uebergangene Objekte sind der Grund fuer den Teilfehler und
|
||||
// gehoeren deshalb in dieselbe Zelle, nicht in eine Fussnote.
|
||||
<span className="ml-1 text-warning">
|
||||
({run.files_skipped.toLocaleString('de-DE')} übergangen)
|
||||
({run.files_skipped.toLocaleString('de-DE')} uebergangen)
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@ -223,7 +222,7 @@ export function JobDetailPage({
|
||||
title={job.name}
|
||||
description={job.description || job.schedule_description}
|
||||
onBack={onBack}
|
||||
backLabel="Zu den Aufträgen"
|
||||
backLabel="Zu den Auftraegen"
|
||||
actions={
|
||||
mayRunJobs ? (
|
||||
<>
|
||||
@ -253,7 +252,7 @@ export function JobDetailPage({
|
||||
|
||||
{job.status === 'paused' ? (
|
||||
<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.
|
||||
</Callout>
|
||||
) : null}
|
||||
@ -268,7 +267,7 @@ export function JobDetailPage({
|
||||
</DetailItem>
|
||||
<DetailItem label="Dringlichkeit">{job.priority}</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'}
|
||||
</DetailItem>
|
||||
<DetailItem label="Bandbreitengrenze">
|
||||
@ -277,15 +276,6 @@ export function JobDetailPage({
|
||||
: 'Ohne Grenze'}
|
||||
</DetailItem>
|
||||
<DetailItem label="Quellen">{job.sources.length}</DetailItem>
|
||||
<DetailItem label="Sicherungsart">
|
||||
{job.backup_mode === 'always_full' ? (
|
||||
'Immer voll'
|
||||
) : job.full_backup_weekday !== undefined ? (
|
||||
<>Inkrementell, {WEEKDAY_LABELS[job.full_backup_weekday]}s voll</>
|
||||
) : (
|
||||
'Inkrementell'
|
||||
)}
|
||||
</DetailItem>
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 border-t border-line pt-4">
|
||||
@ -327,7 +317,7 @@ export function JobDetailPage({
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Laufhistorie"
|
||||
description="Die letzten 50 Läufe."
|
||||
description="Die letzten 50 Laeufe."
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={runsResource.reload}>
|
||||
<RefreshCw />
|
||||
@ -342,7 +332,7 @@ export function JobDetailPage({
|
||||
onRowClick={(run) => setSelectedRun(run)}
|
||||
isLoading={runsResource.loadState === 'loading'}
|
||||
emptyTitle="Noch kein Lauf"
|
||||
emptyDescription="Stoßen Sie den ersten Lauf über die Schaltfläche oben an."
|
||||
emptyDescription="Stossen Sie den ersten Lauf ueber die Schaltflaeche oben an."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@ -380,9 +370,9 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
|
||||
|
||||
{run.status === 'partial_failure' ? (
|
||||
<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
|
||||
wiederholt — die übergangenen Objekte wären beim nächsten Versuch
|
||||
wiederholt — die uebergangenen Objekte waeren beim naechsten Versuch
|
||||
dieselben.
|
||||
</Callout>
|
||||
) : null}
|
||||
@ -399,7 +389,7 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Einzelheiten eines ausgewählten Laufs. */
|
||||
/** Einzelheiten eines ausgewaehlten Laufs. */
|
||||
function RunDetailCard({
|
||||
run,
|
||||
onClose,
|
||||
@ -414,7 +404,7 @@ function RunDetailCard({
|
||||
description={formatDateTime(run.started_at)}
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@ -434,7 +424,7 @@ function RunDetailCard({
|
||||
<DetailItem label="Objekte erfasst">
|
||||
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
|
||||
</DetailItem>
|
||||
<DetailItem label="Objekte übergangen">
|
||||
<DetailItem label="Objekte uebergangen">
|
||||
{run.files_skipped?.toLocaleString('de-DE') ?? '0'}
|
||||
</DetailItem>
|
||||
<DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem>
|
||||
|
||||
@ -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
|
||||
* ließen. Ein Betreiber, der einen Lauf außerhalb des Zeitplans anstoßen
|
||||
* wollte, brauchte dafür die Kommandozeile.
|
||||
* Bis hierher zeigte die Oberflaeche Auftraege an, die sich nicht bedienen
|
||||
* liessen. Ein Betreiber, der einen Lauf ausserhalb des Zeitplans anstossen
|
||||
* wollte, brauchte dafuer die Kommandozeile.
|
||||
*
|
||||
* Zwei Entscheidungen tragen diese Seite:
|
||||
*
|
||||
* - **Ein zweiter Anstoß ist kein Fehler, sondern eine Auskunft.** Der Server
|
||||
* antwortet mit 409, wenn der Auftrag bereits läuft. Das als Fehler zu
|
||||
* melden wäre technisch richtig und praktisch irreführend — der Auftrag
|
||||
* läuft ja, und genau das wollte der Betreiber.
|
||||
* - **Löschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
|
||||
* bestehen; sie gehören zum Repository, nicht zum Auftrag. Wer das nicht
|
||||
* weiß, löscht einen Auftrag in der Annahme, damit Platz zu schaffen.
|
||||
* - **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der Server
|
||||
* antwortet mit 409, wenn der Auftrag bereits laeuft. Das als Fehler zu
|
||||
* melden waere technisch richtig und praktisch irrefuehrend — der Auftrag
|
||||
* laeuft ja, und genau das wollte der Betreiber.
|
||||
* - **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
|
||||
* bestehen; sie gehoeren zum Repository, nicht zum Auftrag. Wer das nicht
|
||||
* weiss, loescht einen Auftrag in der Annahme, damit Platz zu schaffen.
|
||||
*/
|
||||
|
||||
import {
|
||||
@ -47,7 +47,6 @@ import {
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
WEEKDAY_LABELS,
|
||||
type BackupJob,
|
||||
} from './jobsApi';
|
||||
|
||||
@ -77,30 +76,30 @@ export function JobsPage({
|
||||
const runMutation = useMutation(runJob, {
|
||||
onSuccess: () => {
|
||||
// 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.');
|
||||
jobsResource.reload();
|
||||
},
|
||||
onError: (apiError) => {
|
||||
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.
|
||||
toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError));
|
||||
toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError));
|
||||
|
||||
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, {
|
||||
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();
|
||||
},
|
||||
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, {
|
||||
@ -109,20 +108,20 @@ export function JobsPage({
|
||||
jobsResource.reload();
|
||||
},
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess(
|
||||
'Auftrag gelöscht',
|
||||
'Auftrag geloescht',
|
||||
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
|
||||
);
|
||||
setJobPendingDeletion(null);
|
||||
jobsResource.reload();
|
||||
},
|
||||
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 ?? [];
|
||||
@ -134,14 +133,7 @@ export function JobsPage({
|
||||
render: (job) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-fg">{job.name}</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{job.schedule_description}
|
||||
{job.backup_mode === 'always_full'
|
||||
? ' · immer voll'
|
||||
: job.full_backup_weekday !== undefined
|
||||
? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll`
|
||||
: ''}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -162,14 +154,14 @@ export function JobsPage({
|
||||
</span>
|
||||
</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.
|
||||
<span className="text-xs text-fg-muted">Noch nie gelaufen</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'next_run_at',
|
||||
header: 'Nächster Lauf',
|
||||
header: 'Naechster Lauf',
|
||||
render: (job) =>
|
||||
job.next_run_at ? (
|
||||
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
|
||||
@ -192,7 +184,7 @@ export function JobsPage({
|
||||
render: (job) => (
|
||||
<div
|
||||
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.
|
||||
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
||||
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
||||
@ -233,8 +225,8 @@ export function JobsPage({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Löschen"
|
||||
aria-label={`Auftrag ${job.name} löschen`}
|
||||
title="Loeschen"
|
||||
aria-label={`Auftrag ${job.name} loeschen`}
|
||||
onClick={() => setJobPendingDeletion(job)}
|
||||
>
|
||||
<Trash2 />
|
||||
@ -257,7 +249,7 @@ export function JobsPage({
|
||||
if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Sicherungsaufträge" />
|
||||
<PageHeader title="Sicherungsauftraege" />
|
||||
<Card>
|
||||
<ErrorState
|
||||
message={describeApiError(jobsResource.loadError)}
|
||||
@ -276,7 +268,7 @@ export function JobsPage({
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Sicherungsaufträge"
|
||||
title="Sicherungsauftraege"
|
||||
description="Was wann wohin gesichert wird."
|
||||
actions={
|
||||
mayWriteJobs ? (
|
||||
@ -288,11 +280,13 @@ export function JobsPage({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Ohne beschreibbares Ziel ist kein Auftrag möglich. Das erst beim
|
||||
Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */}
|
||||
{/* Ohne beschreibbares Ziel ist kein Auftrag moeglich. Das erst beim
|
||||
Absenden des Assistenten zu melden waere die unfreundlichste Stelle. */}
|
||||
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
|
||||
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
|
||||
Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories.
|
||||
Ein Auftrag braucht ein Ziel, das Sicherungen annimmt. Legen Sie
|
||||
zuerst unter „Repositories" eines an oder pruefen Sie den Zustand der
|
||||
vorhandenen.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
@ -304,7 +298,7 @@ export function JobsPage({
|
||||
onRowClick={(job) => onOpenJob(job.id)}
|
||||
isLoading={jobsResource.loadState === 'loading'}
|
||||
emptyTitle="Noch kein Sicherungsauftrag"
|
||||
emptyDescription="Ohne Auftrag wird nichts gesichert."
|
||||
emptyDescription="Ohne Auftrag wird nichts gesichert — auch nicht im Hintergrund."
|
||||
emptyAction={
|
||||
mayWriteJobs ? (
|
||||
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
|
||||
@ -334,15 +328,15 @@ export function JobsPage({
|
||||
setJobPendingDeletion(null);
|
||||
}
|
||||
}}
|
||||
title="Auftrag löschen"
|
||||
title="Auftrag loeschen"
|
||||
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
|
||||
confirmLabel="Löschen"
|
||||
confirmLabel="Loeschen"
|
||||
isDestructive
|
||||
isLoading={deleteMutation.isRunning}
|
||||
confirmationWord={jobPendingDeletion?.name}
|
||||
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">
|
||||
{jobPendingDeletion?.name}
|
||||
</code>
|
||||
@ -355,7 +349,9 @@ export function JobsPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
* dem Backend und nicht der Oberfläche: Der Wizard führt seinen eigenen
|
||||
* Entwurf und übersetzt ihn erst beim Anlegen. Andernfalls müsste jede
|
||||
* Änderung an der API sofort die Maske umbauen.
|
||||
* Das Modul kennt nur den Vertrag nach aussen. Die Gestalt der Anfrage folgt
|
||||
* dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen
|
||||
* Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede
|
||||
* Aenderung an der API sofort die Maske umbauen.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
@ -56,9 +56,9 @@ export interface SourceDescriptor {
|
||||
id: string;
|
||||
/** Sprechende Bezeichnung. */
|
||||
name?: string;
|
||||
/** Einzuschließende Muster. */
|
||||
/** Einzuschliessende Muster. */
|
||||
include_patterns?: string[];
|
||||
/** Auszuschließende Muster. */
|
||||
/** Auszuschliessende Muster. */
|
||||
exclude_patterns?: string[];
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ export interface SourceDescriptor {
|
||||
export interface CreateJobRequest {
|
||||
/** Eindeutige Bezeichnung. */
|
||||
name: string;
|
||||
/** Erläuterung des Zwecks. */
|
||||
/** Erlaeuterung des Zwecks. */
|
||||
description?: string;
|
||||
/** Dringlichkeit. */
|
||||
priority?: JobPriority;
|
||||
@ -76,48 +76,21 @@ export interface CreateJobRequest {
|
||||
sources: SourceDescriptor[];
|
||||
/** Ziel-Repository. */
|
||||
repository_id: string;
|
||||
/** Zulässiger Datenverlust in Sekunden. */
|
||||
/** Zulaessiger Datenverlust in Sekunden. */
|
||||
rpo_seconds?: number;
|
||||
/** Zulässige Wiederherstellungsdauer in Sekunden. */
|
||||
/** Zulaessige Wiederherstellungsdauer in Sekunden. */
|
||||
rto_seconds?: number;
|
||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||
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. */
|
||||
export interface BackupJob {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
/** Oeffentlicher Bezeichner. */
|
||||
id: string;
|
||||
/** Bezeichnung. */
|
||||
name: string;
|
||||
/** Erläuterung. */
|
||||
/** Erlaeuterung. */
|
||||
description?: string;
|
||||
/** Zustand. */
|
||||
status: string;
|
||||
@ -125,13 +98,13 @@ export interface BackupJob {
|
||||
priority: JobPriority;
|
||||
/** Zeitplan. */
|
||||
schedule: ScheduleDescriptor;
|
||||
/** Erklärung des Zeitplans in einem Satz. */
|
||||
/** Erklaerung des Zeitplans in einem Satz. */
|
||||
schedule_description: string;
|
||||
/** Quellen. */
|
||||
sources: SourceDescriptor[];
|
||||
/** Ziel-Repository. */
|
||||
repository_id: string;
|
||||
/** Nächster Zeitpunkt in UTC. */
|
||||
/** Naechster Zeitpunkt in UTC. */
|
||||
next_run_at?: string;
|
||||
/** Beginn des letzten Laufs in UTC. */
|
||||
last_run_at?: string;
|
||||
@ -139,15 +112,11 @@ export interface BackupJob {
|
||||
last_outcome?: string;
|
||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||
bandwidth_limit_bps?: number;
|
||||
/** Sicherungsart. */
|
||||
backup_mode?: BackupMode;
|
||||
/** Wochentag einer erzwungenen Vollsicherung. */
|
||||
full_backup_weekday?: number;
|
||||
}
|
||||
|
||||
/** Sicherungsziel in der Antwort der API. */
|
||||
export interface BackupRepository {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
/** Oeffentlicher Bezeichner. */
|
||||
id: string;
|
||||
/** Sprechende Bezeichnung. */
|
||||
name: string;
|
||||
@ -160,26 +129,26 @@ export interface BackupRepository {
|
||||
/**
|
||||
* Meldet, ob dieses Ziel Sicherungen annimmt.
|
||||
*
|
||||
* Die Auskunft kommt vom Server. Die Oberfläche müsste sonst wissen, welche
|
||||
* Zustände schreibend sind - eine Regel, die dort nicht hingehört.
|
||||
* Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche
|
||||
* Zustaende schreibend sind - eine Regel, die dort nicht hingehoert.
|
||||
*/
|
||||
accepts_backups: boolean;
|
||||
/** Meldet den gehärteten Modus. */
|
||||
/** Meldet den gehaerteten Modus. */
|
||||
hardened: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die bekannten Sicherungsziele.
|
||||
* Laedt die bekannten Sicherungsziele.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
|
||||
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[]> {
|
||||
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
|
||||
// nicht. Eine Liste, deren Einträge sich nicht bedienen lassen, ist ein
|
||||
// Bis hierher konnte die Oberflaeche einen Auftrag anlegen und ansehen — mehr
|
||||
// nicht. Eine Liste, deren Eintraege sich nicht bedienen lassen, ist ein
|
||||
// Bericht, keine Konsole.
|
||||
|
||||
/** Ein einzelner Lauf eines Auftrags. */
|
||||
export interface BackupJobRun {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
/** Oeffentlicher Bezeichner. */
|
||||
id: string;
|
||||
/** Zugehöriger Auftrag. */
|
||||
/** Zugehoeriger Auftrag. */
|
||||
job_id: string;
|
||||
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
|
||||
status: string;
|
||||
@ -211,15 +180,15 @@ export interface BackupJobRun {
|
||||
completed_at?: string;
|
||||
/** Gelesene Bytes. */
|
||||
bytes_processed?: number;
|
||||
/** Tatsächlich abgelegte Bytes nach Deduplizierung. */
|
||||
/** Tatsaechlich abgelegte Bytes nach Deduplizierung. */
|
||||
bytes_written?: number;
|
||||
/** Anzahl erfasster Dateien. */
|
||||
files_processed?: number;
|
||||
/**
|
||||
* Übergangene Objekte.
|
||||
* Uebergangene Objekte.
|
||||
*
|
||||
* Größer als null bedeutet Teilfehler — die Datenbank lässt
|
||||
* "erfolgreich mit übergangenen Objekten" per CHECK gar nicht zu.
|
||||
* Groesser als null bedeutet Teilfehler — die Datenbank laesst
|
||||
* "erfolgreich mit uebergangenen Objekten" per CHECK gar nicht zu.
|
||||
*/
|
||||
files_skipped?: number;
|
||||
/** Durchsatz in Byte je Sekunde. */
|
||||
@ -232,7 +201,7 @@ export interface BackupJobRun {
|
||||
error_class?: string;
|
||||
}
|
||||
|
||||
/** Lädt einen einzelnen Auftrag. */
|
||||
/** Laedt einen einzelnen Auftrag. */
|
||||
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
|
||||
return requestApi<BackupJob>(
|
||||
`/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(
|
||||
jobIdentifier: string,
|
||||
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
|
||||
* nicht begonnen. Ein zweiter Anstoß bei laufendem Auftrag ergibt 409 — das
|
||||
* ist eine Auskunft, kein Fehler, und wird in der Oberfläche als solche
|
||||
* nicht begonnen. Ein zweiter Anstoss bei laufendem Auftrag ergibt 409 — das
|
||||
* ist eine Auskunft, kein Fehler, und wird in der Oberflaeche als solche
|
||||
* gezeigt.
|
||||
*/
|
||||
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> {
|
||||
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
|
||||
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
|
||||
* gehören zum Repository, nicht zum Auftrag. Das muss die Oberfläche sagen,
|
||||
* sonst löscht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
|
||||
* gehoeren zum Repository, nicht zum Auftrag. Das muss die Oberflaeche sagen,
|
||||
* sonst loescht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
|
||||
*/
|
||||
export async function deleteJob(jobIdentifier: string): Promise<void> {
|
||||
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -19,11 +19,11 @@ import {
|
||||
} from './wizardModel';
|
||||
import type { JobDraft } from './wizardModel';
|
||||
|
||||
/** Liefert einen vollständig ausgefüllten Entwurf. */
|
||||
/** Liefert einen vollstaendig ausgefuellten Entwurf. */
|
||||
function buildCompleteDraft(): JobDraft {
|
||||
return {
|
||||
...createEmptyDraft(),
|
||||
name: 'Nächtliche Sicherung',
|
||||
name: 'Naechtliche Sicherung',
|
||||
sources: [
|
||||
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
|
||||
],
|
||||
@ -43,8 +43,8 @@ describe('Schrittfolge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prüfung der Schritte', () => {
|
||||
it('nimmt einen vollständigen Entwurf an', () => {
|
||||
describe('Pruefung der Schritte', () => {
|
||||
it('nimmt einen vollstaendigen Entwurf an', () => {
|
||||
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
|
||||
});
|
||||
|
||||
@ -56,7 +56,7 @@ describe('Prüfung der Schritte', () => {
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
const sourcelessDraft = {
|
||||
...buildCompleteDraft(),
|
||||
@ -108,13 +108,13 @@ describe('Prüfung der Schritte', () => {
|
||||
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' };
|
||||
|
||||
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 = {
|
||||
...buildCompleteDraft(),
|
||||
scheduleType: 'cron',
|
||||
@ -128,9 +128,9 @@ describe('Prüfung der Schritte', () => {
|
||||
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.
|
||||
it('hält bei den nicht umgesetzten Schritten nicht auf', () => {
|
||||
it('haelt bei den nicht umgesetzten Schritten nicht auf', () => {
|
||||
const emptyDraft = createEmptyDraft();
|
||||
|
||||
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
|
||||
@ -142,7 +142,7 @@ describe('Prüfung der Schritte', () => {
|
||||
describe('Bandbreitenangabe', () => {
|
||||
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
|
||||
// Achtfache der beabsichtigten Rate.
|
||||
it('hält Bit und Byte auseinander', () => {
|
||||
it('haelt Bit und Byte auseinander', () => {
|
||||
const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
|
||||
const byteValue = parseBandwidthToBytesPerSecond('100MB');
|
||||
|
||||
@ -151,7 +151,7 @@ describe('Bandbreitenangabe', () => {
|
||||
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('50 MB/s')).toBe(52_428_800);
|
||||
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
|
||||
@ -170,10 +170,10 @@ describe('Bandbreitenangabe', () => {
|
||||
});
|
||||
|
||||
describe('Anfrage an die API', () => {
|
||||
it('überträgt Name, Quelle, Zeitplan und Ziel', () => {
|
||||
it('uebertraegt Name, Quelle, Zeitplan und Ziel', () => {
|
||||
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.schedule.type).toBe('daily');
|
||||
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
|
||||
// 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());
|
||||
|
||||
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.
|
||||
it('lässt die Zeitzone bei einem Intervallplan weg', () => {
|
||||
it('laesst die Zeitzone bei einem Intervallplan weg', () => {
|
||||
const intervalDraft: JobDraft = {
|
||||
...buildCompleteDraft(),
|
||||
scheduleType: 'interval',
|
||||
@ -205,9 +205,9 @@ describe('Anfrage an die API', () => {
|
||||
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.
|
||||
it('lässt leere Felder weg', () => {
|
||||
it('laesst leere Felder weg', () => {
|
||||
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
|
||||
|
||||
const jobRequest = buildCreateRequest(sparseDraft);
|
||||
@ -216,13 +216,13 @@ describe('Anfrage an die API', () => {
|
||||
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' };
|
||||
|
||||
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
|
||||
});
|
||||
|
||||
it('übergeht leere Quellzeilen', () => {
|
||||
it('uebergeht leere Quellzeilen', () => {
|
||||
const mixedDraft: JobDraft = {
|
||||
...buildCompleteDraft(),
|
||||
sources: [
|
||||
@ -246,10 +246,10 @@ describe('Anfrage an die API', () => {
|
||||
});
|
||||
|
||||
describe('Beschreibung des Zeitplans', () => {
|
||||
it('beschreibt die Zeitpläne verständlich', () => {
|
||||
it('beschreibt die Zeitplaene verstaendlich', () => {
|
||||
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');
|
||||
|
||||
|
||||
Binary file not shown.
@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Tests des Liniendiagramms.
|
||||
*
|
||||
* Sie prüfen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
|
||||
* wurde: **Eine Lücke wird nicht überbrückt und nicht auf null gezogen.**
|
||||
* Beides ergäbe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
|
||||
* Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
|
||||
* wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.**
|
||||
* Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
|
||||
*/
|
||||
|
||||
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 {
|
||||
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[] {
|
||||
return Array.from(container.querySelectorAll('path')).map(
|
||||
(pathElement) => pathElement.getAttribute('d') ?? '',
|
||||
@ -43,7 +43,7 @@ function readPathDefinitions(container: HTMLElement): string[] {
|
||||
}
|
||||
|
||||
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.
|
||||
const { container } = render(
|
||||
<LineChart
|
||||
@ -62,19 +62,19 @@ describe('Liniendiagramm', () => {
|
||||
|
||||
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);
|
||||
|
||||
for (const pathDefinition of pathDefinitions) {
|
||||
// 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.
|
||||
expect(pathDefinition.startsWith('M')).toBe(true);
|
||||
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(
|
||||
<LineChart
|
||||
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
|
||||
@ -86,7 +86,7 @@ describe('Liniendiagramm', () => {
|
||||
const allCoordinates = pathDefinitions.join(' ');
|
||||
|
||||
// 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.
|
||||
const lowestDrawnPosition = Math.max(
|
||||
...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', () => {
|
||||
// Eine einzelne Messung ergäbe eine Linie der Länge null und wäre
|
||||
// unsichtbar — die Kurve sähe aus wie „nichts gemessen".
|
||||
// Eine einzelne Messung ergaebe eine Linie der Laenge null und waere
|
||||
// unsichtbar — die Kurve saehe aus wie „nichts gemessen".
|
||||
const { container } = render(
|
||||
<LineChart
|
||||
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(/nicht/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('beginnt die Werteachse bei null', () => {
|
||||
// Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche
|
||||
// aussehen — der häufigste Weg, mit korrekten Zahlen etwas Falsches zu
|
||||
// Eine abgeschnittene Achse laesst kleine Schwankungen wie Einbrueche
|
||||
// aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu
|
||||
// zeigen.
|
||||
render(
|
||||
<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('102')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
/**
|
||||
* Liniendiagramm als SVG, ohne Diagrammbibliothek.
|
||||
*
|
||||
* Warum keine? Die gängigen Bibliotheken bringen mehr Code mit, als die ganze
|
||||
* Oberfläche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
|
||||
* beherrschen sie standardmäßig falsch: **Sie zeichnen Lücken als Nullen.**
|
||||
* Warum keine? Die gaengigen Bibliotheken bringen mehr Code mit, als die ganze
|
||||
* Oberflaeche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
|
||||
* beherrschen sie standardmaessig falsch: **Sie zeichnen Luecken als Nullen.**
|
||||
*
|
||||
* 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
|
||||
* unterbricht die Linie stattdessen.
|
||||
*/
|
||||
|
||||
import type { DataPoint, Series, SeriesUnit } from './metricsApi';
|
||||
|
||||
/** Abmessungen der Zeichenfläche. */
|
||||
/** Abmessungen der Zeichenflaeche. */
|
||||
const CHART_VIEWBOX_WIDTH = 720;
|
||||
/** Höhe der Zeichenfläche. */
|
||||
/** Hoehe der Zeichenflaeche. */
|
||||
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;
|
||||
/** Innenabstand rechts. */
|
||||
const CHART_PADDING_RIGHT = 12;
|
||||
/** Innenabstand oben. */
|
||||
const CHART_PADDING_TOP = 12;
|
||||
/** Innenabstand unten für die Zeitachse. */
|
||||
/** Innenabstand unten fuer die Zeitachse. */
|
||||
const CHART_PADDING_BOTTOM = 28;
|
||||
|
||||
/** Farben der Reihen in Zeichenreihenfolge. */
|
||||
@ -46,19 +46,19 @@ interface LineChartProperties {
|
||||
export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element {
|
||||
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.
|
||||
if (maximumValue === null) {
|
||||
return (
|
||||
<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".
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiß das nach
|
||||
// der Rückgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
|
||||
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiss das nach
|
||||
// der Rueckgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
|
||||
const scaleMaximum: number = maximumValue;
|
||||
|
||||
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. */
|
||||
function verticalPositionOf(dataValue: number): number {
|
||||
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse lässt
|
||||
// kleine Schwankungen wie Einbrüche aussehen — der häufigste Weg, mit
|
||||
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse laesst
|
||||
// kleine Schwankungen wie Einbrueche aussehen — der haeufigste Weg, mit
|
||||
// einem korrekten Diagramm etwas Falsches zu zeigen.
|
||||
return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight;
|
||||
}
|
||||
@ -126,11 +126,11 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
|
||||
|
||||
/** Eigenschaften der Hilfslinien. */
|
||||
interface GridLinesProperties {
|
||||
/** Größter Wert der Reihen. */
|
||||
/** Groesster Wert der Reihen. */
|
||||
readonly maximumValue: number;
|
||||
/** Einheit der Werte. */
|
||||
readonly unit: SeriesUnit;
|
||||
/** Breite der Zeichenfläche. */
|
||||
/** Breite der Zeichenflaeche. */
|
||||
readonly plotWidth: number;
|
||||
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
||||
readonly verticalPositionOf: (dataValue: number) => number;
|
||||
@ -197,8 +197,8 @@ function SeriesPath({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ein einzelner Messpunkt ohne Nachbarn ergäbe eine Linie der Länge
|
||||
null und wäre unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
|
||||
{/* Ein einzelner Messpunkt ohne Nachbarn ergaebe eine Linie der Laenge
|
||||
null und waere unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
|
||||
{series.points.map((dataPoint, pointIndex) =>
|
||||
dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? (
|
||||
<circle
|
||||
@ -218,8 +218,8 @@ function SeriesPath({
|
||||
/**
|
||||
* Baut die Linienabschnitte einer Reihe.
|
||||
*
|
||||
* Jede zusammenhängende Folge von Punkten mit Wert wird ein eigener Abschnitt.
|
||||
* Eine Lücke unterbricht die Linie — sie wird nicht überbrückt und nicht auf
|
||||
* Jede zusammenhaengende Folge von Punkten mit Wert wird ein eigener Abschnitt.
|
||||
* Eine Luecke unterbricht die Linie — sie wird nicht ueberbrueckt und nicht auf
|
||||
* null gezogen.
|
||||
*/
|
||||
function buildPathSegments(
|
||||
@ -263,7 +263,7 @@ function isIsolatedPoint(points: readonly DataPoint[], pointIndex: number): bool
|
||||
return !previousHasValue && !nextHasValue;
|
||||
}
|
||||
|
||||
/** Sucht den größten Wert aller Reihen. */
|
||||
/** Sucht den groessten Wert aller Reihen. */
|
||||
function findMaximumValue(series: readonly Series[]): number | 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
|
||||
// Nullen ist ein gültiger Fall — etwa null Fehlschläge.
|
||||
// Ein Maximum von null macht die Division unmoeglich. Eine Reihe aus lauter
|
||||
// Nullen ist ein gueltiger Fall — etwa null Fehlschlaege.
|
||||
if (maximumValue !== null && maximumValue === 0) {
|
||||
return 1;
|
||||
}
|
||||
@ -290,7 +290,7 @@ function findMaximumValue(series: readonly Series[]): number | null {
|
||||
return maximumValue;
|
||||
}
|
||||
|
||||
/** Faktor zwischen zwei Größeneinheiten. */
|
||||
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||
const BYTE_UNIT_STEP = 1024;
|
||||
|
||||
/** Schreibt einen Achsenwert lesbar. */
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -16,7 +16,7 @@ import { LineChart } from './LineChart';
|
||||
import { fetchChart, fetchChartCatalog } 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 }[] = [
|
||||
{ value: '1h', label: 'Letzte Stunde' },
|
||||
{ value: '24h', label: 'Letzte 24 Stunden' },
|
||||
@ -93,7 +93,7 @@ export function MetricsPage(): React.JSX.Element {
|
||||
interface ChartCardProperties {
|
||||
/** Das darzustellende Diagramm. */
|
||||
readonly definition: ChartDefinition;
|
||||
/** Der gewählte Zeitraum. */
|
||||
/** Der gewaehlte Zeitraum. */
|
||||
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
|
||||
// antwortete mit 501, und ein Fehler in der Oberfläche sähe aus wie eine
|
||||
// Störung. Es ist keine — die Funktion gibt es nur noch nicht.
|
||||
// antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine
|
||||
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht.
|
||||
const { loadState, data, loadError, reload } = useApiResource<Chart>(
|
||||
loadChart,
|
||||
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
|
||||
@ -115,7 +115,7 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
|
||||
if (!definition.available) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={definition.title} description="noch nicht verfügbar" />
|
||||
<CardHeader title={definition.title} description="noch nicht verfuegbar" />
|
||||
<CardContent>
|
||||
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
|
||||
</CardContent>
|
||||
|
||||
@ -17,9 +17,9 @@ export type SeriesUnit =
|
||||
export interface ChartDefinition {
|
||||
/** Bezeichner im Pfad. */
|
||||
readonly metric: string;
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Erklärung. */
|
||||
/** Erklaerung. */
|
||||
readonly description: string;
|
||||
/** Einheit der Werte. */
|
||||
readonly unit: SeriesUnit;
|
||||
@ -27,7 +27,7 @@ export interface ChartDefinition {
|
||||
readonly source: string;
|
||||
/** Meldet, ob es eine Datengrundlage gibt. */
|
||||
readonly available: boolean;
|
||||
/** Erklärt eine fehlende Datengrundlage. */
|
||||
/** Erklaert eine fehlende Datengrundlage. */
|
||||
readonly unavailable_reason?: string;
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export interface ChartCatalog {
|
||||
readonly charts: readonly ChartDefinition[];
|
||||
/** Zahl der Diagramme mit Datengrundlage. */
|
||||
readonly available_count: number;
|
||||
/** Wählbare Zeiträume. */
|
||||
/** Waehlbare Zeitraeume. */
|
||||
readonly ranges: readonly string[];
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ export interface ChartCatalog {
|
||||
export interface DataPoint {
|
||||
/** Beginn des Zeitfensters in UTC. */
|
||||
readonly timestamp: string;
|
||||
/** Wert; nur gültig, wenn has_value gesetzt ist. */
|
||||
/** Wert; nur gueltig, wenn has_value gesetzt ist. */
|
||||
readonly value?: number;
|
||||
/**
|
||||
* Meldet, ob in diesem Zeitfenster etwas gemessen wurde.
|
||||
@ -70,13 +70,13 @@ export interface Series {
|
||||
readonly points: readonly DataPoint[];
|
||||
}
|
||||
|
||||
/** Ein vollständiges Diagramm. */
|
||||
/** Ein vollstaendiges Diagramm. */
|
||||
export interface Chart {
|
||||
/** Bezeichner. */
|
||||
readonly metric: string;
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Erklärung. */
|
||||
/** Erklaerung. */
|
||||
readonly description: string;
|
||||
/** Ausgewerteter Zeitraum. */
|
||||
readonly window: {
|
||||
@ -90,12 +90,12 @@ export interface Chart {
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
/** Lädt den Katalog aller Diagramme. */
|
||||
/** Laedt den Katalog aller Diagramme. */
|
||||
export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> {
|
||||
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(
|
||||
metricName: string,
|
||||
timeRange: string,
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* überlebt eine erfundene Null jede mündliche Erläuterung.
|
||||
* ueberlebt eine erfundene Null jede muendliche Erlaeuterung.
|
||||
*/
|
||||
|
||||
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 { ReportsPage } from './ReportsPage';
|
||||
|
||||
/** Baut eine Antwort in der Standardhülle. */
|
||||
/** Baut eine Antwort in der Standardhuelle. */
|
||||
function buildJsonResponse(payload: unknown): Response {
|
||||
return {
|
||||
status: 200,
|
||||
@ -28,14 +28,14 @@ const reportCatalog = [
|
||||
{
|
||||
type: 'daily_backup',
|
||||
title: 'Tagesbericht Sicherungen',
|
||||
description: 'Alle Sicherungsläufe eines Tages.',
|
||||
description: 'Alle Sicherungslaeufe eines Tages.',
|
||||
period_kind: 'range',
|
||||
default_period: 'letzte 24 Stunden',
|
||||
formats: ['json', 'csv', 'pdf'],
|
||||
},
|
||||
{
|
||||
type: 'repository_capacity',
|
||||
title: 'Bericht Repository-Kapazität',
|
||||
title: 'Bericht Repository-Kapazitaet',
|
||||
description: 'Belegung und Zustand aller Repositories.',
|
||||
period_kind: 'point_in_time',
|
||||
formats: ['json', 'csv', 'pdf'],
|
||||
@ -46,34 +46,34 @@ const reportCatalog = [
|
||||
const reportWithUnknownMetric = {
|
||||
type: 'daily_backup',
|
||||
title: 'Tagesbericht Sicherungen',
|
||||
description: 'Alle Sicherungsläufe eines Tages.',
|
||||
description: 'Alle Sicherungslaeufe eines Tages.',
|
||||
period_from: '2026-08-12T00:00:00Z',
|
||||
period_to: '2026-08-13T00:00:00Z',
|
||||
generated_at: '2026-08-13T06:00:00Z',
|
||||
generated_by: 'prüfer',
|
||||
generated_by: 'pruefer',
|
||||
sections: [
|
||||
{
|
||||
title: 'Überblick',
|
||||
title: 'Ueberblick',
|
||||
metrics: [
|
||||
{ label: 'Läufe insgesamt', value: 0, unit: 'count', is_known: true },
|
||||
{ label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true },
|
||||
{
|
||||
label: 'Erfolgsquote',
|
||||
unit: 'percent',
|
||||
is_known: false,
|
||||
unknown_reason: 'Im gewählten Zeitraum wurde kein Lauf abgeschlossen.',
|
||||
unknown_reason: 'Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.',
|
||||
},
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
title: 'Einzelne Läufe',
|
||||
title: 'Einzelne Laeufe',
|
||||
columns: ['Auftrag', 'Ergebnis'],
|
||||
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', () => {
|
||||
@ -94,10 +94,10 @@ describe('Berichte', () => {
|
||||
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
|
||||
// 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('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
|
||||
});
|
||||
@ -119,18 +119,18 @@ describe('Berichte', () => {
|
||||
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('Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'),
|
||||
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'),
|
||||
).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.
|
||||
// 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
|
||||
// ungemessenen Quote daneben.
|
||||
const measuredValue = screen.getByText('Läufe insgesamt').nextElementSibling;
|
||||
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling;
|
||||
expect(measuredValue?.textContent).toBe('0');
|
||||
});
|
||||
|
||||
@ -148,11 +148,11 @@ describe('Berichte', () => {
|
||||
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Einzelne Läufe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Einzelne Laeufe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Im gewählten Zeitraum wurde kein Lauf begonnen.'),
|
||||
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 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
|
||||
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
|
||||
@ -41,13 +41,13 @@ interface ReportCatalogEntry {
|
||||
readonly type: string;
|
||||
/** Bezeichnung. */
|
||||
readonly title: string;
|
||||
/** Erklärt, welche Frage der Bericht beantwortet. */
|
||||
/** Erklaert, welche Frage der Bericht beantwortet. */
|
||||
readonly description: string;
|
||||
/** Art des Zeitbezugs. */
|
||||
readonly period_kind: PeriodKind;
|
||||
/** Beschreibung des Standardzeitraums. */
|
||||
readonly default_period?: string;
|
||||
/** Verfügbare Ausgabeformate. */
|
||||
/** Verfuegbare Ausgabeformate. */
|
||||
readonly formats: readonly ReportFormat[];
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ interface ReportCatalogEntry {
|
||||
interface ReportMetric {
|
||||
/** Beschriftung. */
|
||||
readonly label: string;
|
||||
/** Gemessener Wert; nur gültig, wenn is_known gilt. */
|
||||
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */
|
||||
readonly value?: number;
|
||||
/** Einheit. */
|
||||
readonly unit?: string;
|
||||
@ -63,13 +63,13 @@ interface ReportMetric {
|
||||
readonly text?: string;
|
||||
/** Meldet, ob der Wert gemessen wurde. */
|
||||
readonly is_known: boolean;
|
||||
/** Erklärt einen fehlenden Wert. */
|
||||
/** Erklaert einen fehlenden Wert. */
|
||||
readonly unknown_reason?: string;
|
||||
}
|
||||
|
||||
/** Eine Tabelle eines Berichts. */
|
||||
interface ReportTable {
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Spaltenbeschriftungen. */
|
||||
readonly columns: readonly string[];
|
||||
@ -81,9 +81,9 @@ interface ReportTable {
|
||||
|
||||
/** Ein Abschnitt eines Berichts. */
|
||||
interface ReportSection {
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Erklärung. */
|
||||
/** Erklaerung. */
|
||||
readonly description?: string;
|
||||
/** Kennzahlen. */
|
||||
readonly metrics?: readonly ReportMetric[];
|
||||
@ -97,7 +97,7 @@ interface GeneratedReport {
|
||||
readonly type: string;
|
||||
/** Bezeichnung. */
|
||||
readonly title: string;
|
||||
/** Erklärung. */
|
||||
/** Erklaerung. */
|
||||
readonly description: string;
|
||||
/** Beginn des Zeitraums. */
|
||||
readonly period_from: string;
|
||||
@ -109,19 +109,19 @@ interface GeneratedReport {
|
||||
readonly generated_by?: string;
|
||||
/** Abschnitte. */
|
||||
readonly sections: readonly ReportSection[];
|
||||
/** Benannte Lücken und Einschränkungen. */
|
||||
/** Benannte Luecken und Einschraenkungen. */
|
||||
readonly notes?: readonly string[];
|
||||
}
|
||||
|
||||
/** Zustand einer laufenden Ausgabe. */
|
||||
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;
|
||||
/** Fehler der letzten Ausgabe. */
|
||||
readonly exportError: ApiError | null;
|
||||
}
|
||||
|
||||
/** Lädt den Berichtskatalog. */
|
||||
/** Laedt den Berichtskatalog. */
|
||||
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
|
||||
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
|
||||
}
|
||||
@ -202,11 +202,11 @@ export function ReportsPage(): React.JSX.Element {
|
||||
|
||||
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
|
||||
// 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 (
|
||||
<Card>
|
||||
<ErrorState
|
||||
title="Der Berichtskatalog steht nicht zur Verfügung."
|
||||
title="Der Berichtskatalog steht nicht zur Verfuegung."
|
||||
message={
|
||||
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
|
||||
}
|
||||
@ -221,7 +221,7 @@ export function ReportsPage(): React.JSX.Element {
|
||||
<>
|
||||
<PageHeader
|
||||
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">
|
||||
@ -246,7 +246,7 @@ export function ReportsPage(): React.JSX.Element {
|
||||
nicht historisiert; "vom letzten Dienstag" kann es nicht geben. */}
|
||||
{catalogEntry.period_kind === 'point_in_time'
|
||||
? 'Zustandsbericht'
|
||||
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
|
||||
: `Zeitraum: ${catalogEntry.default_period ?? 'waehlbar'}`}
|
||||
</span>
|
||||
</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: {
|
||||
readonly catalogEntry: ReportCatalogEntry;
|
||||
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>
|
||||
<dd className="mt-1">
|
||||
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
|
||||
Eine erfundene Null überlebt in einem Prüfordner jede
|
||||
mündliche Erläuterung. */}
|
||||
Eine erfundene Null ueberlebt in einem Pruefordner jede
|
||||
muendliche Erlaeuterung. */}
|
||||
<span
|
||||
className={
|
||||
reportMetric.is_known
|
||||
@ -412,7 +412,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-fg">{table.title}</h4>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
{table.empty_notice ?? 'Keine Einträge.'}
|
||||
{table.empty_notice ?? 'Keine Eintraege.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@ -441,10 +441,10 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
|
||||
</tr>
|
||||
</thead>
|
||||
{/*
|
||||
Die Position ist der Schlüssel: Eine Berichtszeile trägt keine
|
||||
Kennung, und zwei Zeilen können Zeichen für Zeichen gleich sein.
|
||||
Die Position ist der Schluessel: Eine Berichtszeile traegt keine
|
||||
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein.
|
||||
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>
|
||||
{visibleRows.map((tableRow, rowIndex) => (
|
||||
@ -462,7 +462,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
|
||||
{table.rows.length > visibleRows.length && (
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
|
||||
heruntergeladene Datei enthält alle.
|
||||
heruntergeladene Datei enthaelt alle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -539,7 +539,7 @@ function formatDuration(seconds: number): string {
|
||||
* Stellt einen Anteil dar.
|
||||
*
|
||||
* 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 {
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {
|
||||
|
||||
@ -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**
|
||||
* wird. Ein Repository entsteht auf einem Datenträger; der Server öffnet das
|
||||
* Die Maske zur Uebernahme sagt ausdruecklich, dass hier **nichts angelegt**
|
||||
* wird. Ein Repository entsteht auf einem Datentraeger; der Server oeffnet das
|
||||
* vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis
|
||||
* trägt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
|
||||
* traegt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
|
||||
*
|
||||
* Der Integritätslauf trennt zwei Lagen, die gern vermengt werden: „Die
|
||||
* Prüfung schlug fehl" und „das Repository ist beschädigt". Das eine ist ein
|
||||
* Der Integritaetslauf trennt zwei Lagen, die gern vermengt werden: „Die
|
||||
* Pruefung schlug fehl" und „das Repository ist beschaedigt". Das eine ist ein
|
||||
* Problem des Werkzeugs, das andere ein Datenverlust.
|
||||
*/
|
||||
|
||||
@ -36,7 +36,7 @@ import {
|
||||
useToast,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils';
|
||||
import {
|
||||
adoptRepository,
|
||||
checkRepositoryHealth,
|
||||
@ -44,7 +44,7 @@ import {
|
||||
measureEnforcement,
|
||||
rebuildCatalog,
|
||||
startIntegrityScan,
|
||||
type IntegrityScanDetails,
|
||||
type IntegrityScanResult,
|
||||
type Repository,
|
||||
} from './repositoriesApi';
|
||||
|
||||
@ -56,7 +56,7 @@ export function RepositoriesPage({
|
||||
const toast = useToast();
|
||||
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
||||
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 [adoptName, setAdoptName] = useState('');
|
||||
@ -72,7 +72,7 @@ export function RepositoriesPage({
|
||||
const adoptMutation = useMutation(adoptRepository, {
|
||||
onSuccess: (repository) => {
|
||||
toast.showSuccess(
|
||||
'Repository übernommen',
|
||||
'Repository uebernommen',
|
||||
`${repository.name} ist jetzt in der Control Plane bekannt.`,
|
||||
);
|
||||
setIsAdoptDialogOpen(false);
|
||||
@ -82,7 +82,7 @@ export function RepositoriesPage({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Die Übernahme schlug fehl',
|
||||
'Die Uebernahme schlug fehl',
|
||||
describeApiError(apiError),
|
||||
apiError.requestId,
|
||||
),
|
||||
@ -94,17 +94,17 @@ export function RepositoriesPage({
|
||||
setRepositoryPendingScan(null);
|
||||
|
||||
// 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.
|
||||
if (result.missing_chunks > 0 || result.corrupted_chunks > 0) {
|
||||
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
|
||||
toast.showError(
|
||||
'Der Integritätslauf hat Befunde',
|
||||
`${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`,
|
||||
'Der Integritaetslauf hat Befunde',
|
||||
`${result.chunks_missing} Bloecke fehlen, ${result.chunks_corrupted} sind beschaedigt.`,
|
||||
);
|
||||
} else {
|
||||
toast.showSuccess(
|
||||
'Integritätslauf ohne Befund',
|
||||
`${result.chunks_checked.toLocaleString('de-DE')} Blöcke geprüft.`,
|
||||
'Integritaetslauf ohne Befund',
|
||||
`${result.chunks_checked.toLocaleString('de-DE')} Bloecke geprueft.`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -112,28 +112,18 @@ export function RepositoriesPage({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Der Integritätslauf ließ sich nicht durchführen',
|
||||
`${describeApiError(apiError)} — das ist ein Problem der Prüfung, kein Befund am Repository.`,
|
||||
'Der Integritaetslauf liess sich nicht durchfuehren',
|
||||
`${describeApiError(apiError)} — das ist ein Problem der Pruefung, kein Befund am Repository.`,
|
||||
apiError.requestId,
|
||||
),
|
||||
});
|
||||
|
||||
const healthMutation = useMutation(checkRepositoryHealth, {
|
||||
onSuccess: (checkResponse) => {
|
||||
// Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der
|
||||
// Hülle, die zweite in `details`.
|
||||
if (!checkResponse.reachable) {
|
||||
toast.showError(
|
||||
'Das Repository ist nicht erreichbar',
|
||||
checkResponse.error ?? 'Ohne nähere Angabe.',
|
||||
);
|
||||
} else {
|
||||
toast.showSuccess(
|
||||
'Gesundheitsprüfung abgeschlossen',
|
||||
checkResponse.details?.message ?? 'Das Repository ist erreichbar.',
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess: (health) => {
|
||||
toast.showSuccess(
|
||||
'Gesundheitspruefung abgeschlossen',
|
||||
health.message ?? `Zustand: ${health.status}`,
|
||||
);
|
||||
repositoriesResource.reload();
|
||||
},
|
||||
onError: (apiError) =>
|
||||
@ -153,10 +143,11 @@ export function RepositoriesPage({
|
||||
});
|
||||
|
||||
const rebuildMutation = useMutation(rebuildCatalog, {
|
||||
onSuccess: (checkResponse) => {
|
||||
onSuccess: (result) => {
|
||||
toast.showSuccess(
|
||||
'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();
|
||||
},
|
||||
@ -191,7 +182,7 @@ export function RepositoriesPage({
|
||||
},
|
||||
{
|
||||
key: 'enforcement',
|
||||
header: 'Löschschutz',
|
||||
header: 'Loeschschutz',
|
||||
render: (repository) =>
|
||||
repository.enforcement_level ? (
|
||||
<StatusBadge status={repository.enforcement_level} />
|
||||
@ -208,10 +199,10 @@ export function RepositoriesPage({
|
||||
},
|
||||
{
|
||||
key: 'hardened',
|
||||
header: 'Gehärtet',
|
||||
header: 'Gehaertet',
|
||||
render: (repository) =>
|
||||
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>
|
||||
),
|
||||
@ -242,7 +233,7 @@ export function RepositoriesPage({
|
||||
mayWrite ? (
|
||||
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
|
||||
<Plus />
|
||||
Repository übernehmen
|
||||
Repository uebernehmen
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
@ -256,12 +247,12 @@ export function RepositoriesPage({
|
||||
onRowClick={(repository) => setSelectedRepository(repository)}
|
||||
isLoading={repositoriesResource.loadState === 'loading'}
|
||||
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={
|
||||
mayWrite ? (
|
||||
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
|
||||
<Plus />
|
||||
Repository übernehmen
|
||||
Repository uebernehmen
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
@ -275,7 +266,7 @@ export function RepositoriesPage({
|
||||
description={selectedRepository.location}
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@ -288,7 +279,7 @@ export function RepositoriesPage({
|
||||
<DetailItem label="Belegt">
|
||||
{formatBytes(selectedRepository.used_bytes)}
|
||||
</DetailItem>
|
||||
<DetailItem label="Zuletzt geprüft">
|
||||
<DetailItem label="Zuletzt geprueft">
|
||||
{selectedRepository.last_health_check_at
|
||||
? formatDateTime(selectedRepository.last_health_check_at)
|
||||
: 'Nie'}
|
||||
@ -303,7 +294,7 @@ export function RepositoriesPage({
|
||||
onClick={() => void healthMutation.run(selectedRepository.id)}
|
||||
>
|
||||
<RefreshCw />
|
||||
Gesundheit prüfen
|
||||
Gesundheit pruefen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -315,7 +306,7 @@ export function RepositoriesPage({
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
|
||||
<Database />
|
||||
Integritätslauf
|
||||
Integritaetslauf
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -329,7 +320,10 @@ export function RepositoriesPage({
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -339,20 +333,20 @@ export function RepositoriesPage({
|
||||
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
|
||||
) : null}
|
||||
|
||||
{/* --- Übernahme --- */}
|
||||
{/* --- Uebernahme --- */}
|
||||
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader
|
||||
title="Repository übernehmen"
|
||||
title="Repository uebernehmen"
|
||||
description="Ein vorhandenes Repository in die Control Plane aufnehmen."
|
||||
/>
|
||||
<DialogBody>
|
||||
<Callout tone="warning" title="Hier wird nichts angelegt">
|
||||
Ein Repository entsteht auf einem Datenträger:
|
||||
Ein Repository entsteht auf einem Datentraeger:
|
||||
<code className="mt-1 block rounded bg-sunken px-2 py-1 font-mono text-xs">
|
||||
sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
|
||||
</code>
|
||||
Dieser Schritt öffnet das vorhandene und liest seine Kennung aus
|
||||
Dieser Schritt oeffnet das vorhandene und liest seine Kennung aus
|
||||
dem Descriptor.
|
||||
</Callout>
|
||||
|
||||
@ -370,7 +364,7 @@ export function RepositoriesPage({
|
||||
value={adoptLocation}
|
||||
onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)}
|
||||
placeholder="/srv/syncova-repository"
|
||||
hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Einträge auf dasselbe Verzeichnis ergäben Wettlauf um die Schreibsperre."
|
||||
hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Eintraege auf dasselbe Verzeichnis ergaeben Wettlauf um die Schreibsperre."
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
@ -388,7 +382,7 @@ export function RepositoriesPage({
|
||||
})
|
||||
}
|
||||
>
|
||||
Übernehmen
|
||||
Uebernehmen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@ -397,7 +391,7 @@ export function RepositoriesPage({
|
||||
<ConfirmDialog
|
||||
open={repositoryPendingScan !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
|
||||
title="Integritätslauf starten"
|
||||
title="Integritaetslauf starten"
|
||||
confirmLabel="Lauf starten"
|
||||
isLoading={scanMutation.isRunning}
|
||||
onConfirm={() => {
|
||||
@ -407,75 +401,68 @@ export function RepositoriesPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ergebnis eines Integritätslaufs. */
|
||||
/** Ergebnis eines Integritaetslaufs. */
|
||||
function IntegrityScanCard({
|
||||
result,
|
||||
onClose,
|
||||
}: {
|
||||
readonly result: IntegrityScanDetails;
|
||||
readonly result: IntegrityScanResult;
|
||||
readonly onClose: () => void;
|
||||
}) {
|
||||
const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0;
|
||||
const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0;
|
||||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardHeader
|
||||
title="Ergebnis des Integritätslaufs"
|
||||
title="Ergebnis des Integritaetslaufs"
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
||||
{hasFindings
|
||||
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
|
||||
: result.summary}
|
||||
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups waeren unvollstaendig.'
|
||||
: 'Ohne Befund. Jeder gepruefte Block stimmt mit seiner Pruefsumme ueberein.'}
|
||||
</Callout>
|
||||
|
||||
<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')}
|
||||
</DetailItem>
|
||||
<DetailItem label="Fehlend">
|
||||
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
|
||||
{result.missing_chunks.toLocaleString('de-DE')}
|
||||
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
|
||||
{result.chunks_missing.toLocaleString('de-DE')}
|
||||
</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Beschädigt">
|
||||
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
|
||||
{result.corrupted_chunks.toLocaleString('de-DE')}
|
||||
<DetailItem label="Beschaedigt">
|
||||
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
|
||||
{result.chunks_corrupted.toLocaleString('de-DE')}
|
||||
</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Backups">
|
||||
{result.backups_healthy} von {result.backups_checked} vollständig
|
||||
</DetailItem>
|
||||
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
|
||||
</dl>
|
||||
|
||||
{!result.verified_chunk_contents ? (
|
||||
<Callout tone="warning" className="mt-4">
|
||||
Nur die Kennungen wurden geprüft, nicht die Blockinhalte. Das ist
|
||||
ein halber Nachweis.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{result.affected_backup_ids && result.affected_backup_ids.length > 0 ? (
|
||||
{result.findings && result.findings.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||
Betroffene Backups
|
||||
Betroffene Objekte
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{result.affected_backup_ids.map((backupIdentifier) => (
|
||||
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
|
||||
{backupIdentifier}
|
||||
{result.findings.map((finding) => (
|
||||
<li key={finding} className="break-all font-mono text-xs text-fg-muted">
|
||||
{finding}
|
||||
</li>
|
||||
))}
|
||||
</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 }) {
|
||||
// Ohne hinterlegte Kapazität gibt es keinen Prozentsatz. Ihn zu erfinden
|
||||
// wäre die häufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
|
||||
// Ohne hinterlegte Kapazitaet gibt es keinen Prozentsatz. Ihn zu erfinden
|
||||
// waere die haeufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
|
||||
const hasCapacity =
|
||||
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">
|
||||
{hasCapacity
|
||||
? `${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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Regressionstest zum schwarzen Bildschirm auf /retention.
|
||||
*
|
||||
* `GET /retention-policies` liefert ein **Objekt** `{ policies, predefined }`,
|
||||
* als einziger von zehn geprüften Listenendpunkten. Die Oberfläche behandelte
|
||||
* es als Array; `map` gibt es auf einem Objekt nicht, React hängte den ganzen
|
||||
* Baum aus, und übrig blieb eine leere Seite — im dunklen Thema schwarz.
|
||||
*
|
||||
* Der Test füttert die **echte** Antwortform. Ein Test mit einem Array hätte
|
||||
* den Fehler nie gefunden — und genau das war passiert.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ToastProvider } from '@/components/ui';
|
||||
import { RetentionPage } from './RetentionPage';
|
||||
|
||||
/** Baut eine Antwort in der Hülle der API. */
|
||||
function apiResponse(payload: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
json: async () => ({ data: payload, meta: { request_id: 'req-1' } }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** Die Antwortform, die der Server tatsächlich liefert. */
|
||||
const echteAntwort = {
|
||||
policies: [],
|
||||
predefined: [
|
||||
{
|
||||
name: '7 Tage',
|
||||
description: 'Behalten werden alles jünger als 7 Tage, die letzten 1 Backups.',
|
||||
keep_within_seconds: 604800,
|
||||
keep_last: 1,
|
||||
keep_daily: 0,
|
||||
keep_weekly: 0,
|
||||
keep_monthly: 0,
|
||||
keep_yearly: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('Aufbewahrung', () => {
|
||||
it('stürzt nicht ab, wenn die Regelliste ein Objekt ist', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (requestUrl: string) =>
|
||||
String(requestUrl).includes('retention-policies')
|
||||
? apiResponse(echteAntwort)
|
||||
: apiResponse([]),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<RetentionPage grantedPermissions={['*']} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
// Die Seite steht, statt den Baum abzureißen.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Und die mitgelieferten Vorlagen erscheinen als anklickbare Kacheln.
|
||||
expect(await screen.findByText('7 Tage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kommt ohne die beiden Felder zurecht', async () => {
|
||||
// Ein Server, der beide Felder weglässt, darf die Seite nicht schwärzen.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (requestUrl: string) =>
|
||||
String(requestUrl).includes('retention-policies')
|
||||
? apiResponse({})
|
||||
: apiResponse([]),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<RetentionPage grantedPermissions={['*']} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Keine Aufbewahrungsregel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -3,16 +3,16 @@
|
||||
*
|
||||
* 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 —
|
||||
* 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.
|
||||
* - **Die Vorschau ist keine Formalie.** Sie löscht nichts und liefert die
|
||||
* Zahl, die danach wörtlich bestätigt werden muss.
|
||||
* - **Die Vorschau ist keine Formalie.** Sie loescht nichts und liefert die
|
||||
* Zahl, die danach woertlich bestaetigt werden muss.
|
||||
*
|
||||
* Dass beim Löschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
|
||||
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rückfrage.
|
||||
* Dass beim Loeschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
|
||||
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rueckfrage.
|
||||
*/
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
@ -85,12 +85,12 @@ export function RetentionPage({
|
||||
|
||||
const createMutation = useMutation(createRetentionPolicy, {
|
||||
onSuccess: (policy) => {
|
||||
// Eine stillschweigend ergänzte Haltevorgabe wäre eine Änderung, die
|
||||
// niemand sieht — und die später niemand erklären kann.
|
||||
// Eine stillschweigend ergaenzte Haltevorgabe waere eine Aenderung, die
|
||||
// niemand sieht — und die spaeter niemand erklaeren kann.
|
||||
if (!draftKeepLast || Number(draftKeepLast) < 1) {
|
||||
toast.showInfo(
|
||||
'Regel angelegt',
|
||||
`„${policy.name}" — es wurde eine Mindesthaltung von 1 ergänzt, damit nie das letzte Backup entfällt.`,
|
||||
`„${policy.name}" — es wurde eine Mindesthaltung von 1 ergaenzt, damit nie das letzte Backup entfaellt.`,
|
||||
);
|
||||
} else {
|
||||
toast.showSuccess('Regel angelegt', policy.name);
|
||||
@ -101,12 +101,12 @@ export function RetentionPage({
|
||||
policiesResource.reload();
|
||||
},
|
||||
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, {
|
||||
onSuccess: () => {
|
||||
toast.showSuccess('Regel gelöscht');
|
||||
toast.showSuccess('Regel geloescht');
|
||||
setPolicyPendingDeletion(null);
|
||||
policiesResource.reload();
|
||||
},
|
||||
@ -119,7 +119,7 @@ export function RetentionPage({
|
||||
setPreviewResult(preview);
|
||||
toast.showInfo(
|
||||
'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) =>
|
||||
@ -132,7 +132,7 @@ export function RetentionPage({
|
||||
'Aufbewahrung angewendet',
|
||||
result.bytes_to_free
|
||||
? `${formatBytes(result.bytes_to_free)} frei geworden.`
|
||||
: 'Es wurde kein Speicher frei — die Blöcke werden von anderen Backups gebraucht (Deduplizierung).',
|
||||
: 'Es wurde kein Speicher frei — die Bloecke werden von anderen Backups gebraucht (Deduplizierung).',
|
||||
);
|
||||
setIsApplyDialogOpen(false);
|
||||
setPreviewResult(null);
|
||||
@ -141,8 +141,7 @@ export function RetentionPage({
|
||||
toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
|
||||
});
|
||||
|
||||
const policies = policiesResource.data?.policies ?? [];
|
||||
const predefinedTemplates = policiesResource.data?.predefined ?? [];
|
||||
const policies = policiesResource.data ?? [];
|
||||
const repositories = repositoriesResource.data ?? [];
|
||||
|
||||
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
|
||||
@ -164,14 +163,14 @@ export function RetentionPage({
|
||||
header: 'Mindestens',
|
||||
numeric: true,
|
||||
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 ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'grid',
|
||||
header: 'Täglich / Wöchentlich / Monatlich / Jährlich',
|
||||
header: 'Taeglich / Woechentlich / Monatlich / Jaehrlich',
|
||||
render: (policy) =>
|
||||
[policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly]
|
||||
.map((value) => value ?? '—')
|
||||
@ -186,7 +185,7 @@ export function RetentionPage({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Regel ${policy.name} löschen`}
|
||||
aria-label={`Regel ${policy.name} loeschen`}
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
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">
|
||||
<CardHeader title="Eigene Regeln" />
|
||||
<CardHeader title="Regeln" />
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
rows={policies}
|
||||
getRowKey={(policy) => policy.id}
|
||||
isLoading={policiesResource.loadState === 'loading'}
|
||||
emptyTitle="Keine Aufbewahrungsregel"
|
||||
emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand löscht."
|
||||
emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand loescht."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@ -286,7 +243,7 @@ export function RetentionPage({
|
||||
<Card>
|
||||
<CardHeader
|
||||
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">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@ -295,7 +252,7 @@ export function RetentionPage({
|
||||
value={selectedRepositoryId}
|
||||
onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
<option value="">Bitte waehlen</option>
|
||||
{repositories.map((repository) => (
|
||||
<option key={repository.id} value={repository.id}>
|
||||
{repository.name}
|
||||
@ -308,7 +265,7 @@ export function RetentionPage({
|
||||
value={selectedPolicyId}
|
||||
onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
<option value="">Bitte waehlen</option>
|
||||
{policies.map((policy) => (
|
||||
<option key={policy.id} value={policy.id}>
|
||||
{policy.name}
|
||||
@ -337,7 +294,7 @@ export function RetentionPage({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Anwenden und löschen
|
||||
Anwenden und loeschen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -345,18 +302,18 @@ export function RetentionPage({
|
||||
<div className="rounded-md border border-line bg-sunken p-3">
|
||||
<p className="text-sm text-fg">
|
||||
<strong>{previewResult.backups_to_delete}</strong>{' '}
|
||||
Wiederherstellungspunkte würden entfallen
|
||||
Wiederherstellungspunkte wuerden entfallen
|
||||
{previewResult.backups_protected
|
||||
? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
Frei würden voraussichtlich {formatBytes(previewResult.bytes_to_free)}.
|
||||
Frei wuerden voraussichtlich {formatBytes(previewResult.bytes_to_free)}.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-fg-muted">
|
||||
Deutlich weniger als die Summe der Backupgrößen ist der
|
||||
Normalfall: Deduplizierte Blöcke werden von anderen
|
||||
Deutlich weniger als die Summe der Backupgroessen ist der
|
||||
Normalfall: Deduplizierte Bloecke werden von anderen
|
||||
Wiederherstellungspunkten weiter gebraucht.
|
||||
</p>
|
||||
</div>
|
||||
@ -392,10 +349,10 @@ export function RetentionPage({
|
||||
min={1}
|
||||
value={draftKeepLast}
|
||||
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
|
||||
label="Tägliche behalten"
|
||||
label="Taegliche behalten"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draftKeepDaily}
|
||||
@ -411,7 +368,9 @@ export function RetentionPage({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
@ -441,9 +400,9 @@ export function RetentionPage({
|
||||
<ConfirmDialog
|
||||
open={policyPendingDeletion !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)}
|
||||
title="Regel löschen"
|
||||
title="Regel loeschen"
|
||||
description={policyPendingDeletion?.name}
|
||||
confirmLabel="Löschen"
|
||||
confirmLabel="Loeschen"
|
||||
isDestructive
|
||||
isLoading={deleteMutation.isRunning}
|
||||
onConfirm={() => {
|
||||
@ -453,7 +412,8 @@ export function RetentionPage({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</ConfirmDialog>
|
||||
|
||||
@ -461,14 +421,14 @@ export function RetentionPage({
|
||||
open={isApplyDialogOpen}
|
||||
onOpenChange={setIsApplyDialogOpen}
|
||||
title="Aufbewahrung anwenden"
|
||||
description="Wiederherstellungspunkte werden endgültig entfernt."
|
||||
confirmLabel="Endgültig löschen"
|
||||
description="Wiederherstellungspunkte werden endgueltig entfernt."
|
||||
confirmLabel="Endgueltig loeschen"
|
||||
isDestructive
|
||||
isLoading={applyMutation.isRunning}
|
||||
confirmationWord={String(previewResult?.backups_to_delete ?? '')}
|
||||
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">
|
||||
{previewResult?.backups_to_delete}
|
||||
</code>
|
||||
@ -483,7 +443,8 @@ export function RetentionPage({
|
||||
}
|
||||
>
|
||||
<Callout tone="critical">
|
||||
Geschützte Punkte bleiben unberührt.
|
||||
Geschuetzte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben
|
||||
unberuehrt — der Server lehnt ihre Loeschung ab.
|
||||
</Callout>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
/**
|
||||
* API-Anbindung der Repositories (SYNCOVA_API.md §8).
|
||||
*
|
||||
* Ein Punkt prägt die ganze Maske: **`POST /repositories` legt nichts an, es
|
||||
* übernimmt.** Ein Repository entsteht auf einem Datenträger — mit
|
||||
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt öffnet
|
||||
* Ein Punkt praegt die ganze Maske: **`POST /repositories` legt nichts an, es
|
||||
* uebernimmt.** Ein Repository entsteht auf einem Datentraeger — mit
|
||||
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt oeffnet
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -24,60 +24,32 @@ export interface Repository {
|
||||
hardened: boolean;
|
||||
/** Belegter Speicher; nicht immer bekannt. */
|
||||
used_bytes?: number;
|
||||
/** Gesamtkapazität, sofern hinterlegt. */
|
||||
/** Gesamtkapazitaet, sofern hinterlegt. */
|
||||
total_bytes?: number;
|
||||
/** Gemessene Durchsetzungsstufe des Löschschutzes. */
|
||||
/** Gemessene Durchsetzungsstufe des Loeschschutzes. */
|
||||
enforcement_level?: string;
|
||||
last_health_check_at?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antworthülle der Repository-Prüfungen.
|
||||
*
|
||||
* **Alle vier Prüfendpunkte antworten in dieser Form** — Integritätslauf,
|
||||
* Gesundheitsprüfung, Verbindungstest und Katalog-Neuaufbau. Das eigentliche
|
||||
* Ergebnis steckt unter `details`, nicht an der Oberfläche der Antwort.
|
||||
*
|
||||
* Das falsch anzunehmen kostete eine Fehlermeldung, die wie ein Defekt der
|
||||
* Anlage aussah: „can't access property toLocaleString, chunks_checked is
|
||||
* undefined — das ist ein Problem der Prüfung, kein Befund am Repository."
|
||||
* Genau das Gegenteil war der Fall; die Prüfung war einwandfrei gelaufen.
|
||||
*/
|
||||
export interface RepositoryCheckResponse<TDetails> {
|
||||
repository_id: string;
|
||||
reachable: boolean;
|
||||
repository_uuid?: string;
|
||||
error?: string;
|
||||
details?: TDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergebnis eines Integritätslaufs.
|
||||
*
|
||||
* Die Feldnamen stammen aus `repository.ScanReport` und heißen anders herum als
|
||||
* erwartet: `missing_chunks`, nicht `chunks_missing`.
|
||||
*/
|
||||
export interface IntegrityScanDetails {
|
||||
/** Geprüfte Blöcke. */
|
||||
/** Ergebnis eines Integritaetslaufs. */
|
||||
export interface IntegrityScanResult {
|
||||
/** Geprueft. */
|
||||
chunks_checked: number;
|
||||
/** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */
|
||||
missing_chunks: number;
|
||||
/** Prüfsumme stimmt nicht. */
|
||||
corrupted_chunks: number;
|
||||
/** Blöcke ohne Verweis aus einem Manifest. */
|
||||
orphaned_chunks: number;
|
||||
backups_checked: number;
|
||||
backups_healthy: number;
|
||||
/** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */
|
||||
verified_chunk_contents: boolean;
|
||||
healthy: boolean;
|
||||
summary: string;
|
||||
/** Betroffene Backups, sofern benennbar. */
|
||||
affected_backup_ids?: string[] | null;
|
||||
/** Nicht auffindbar. */
|
||||
chunks_missing: number;
|
||||
/** Pruefsumme stimmt nicht. */
|
||||
chunks_corrupted: number;
|
||||
manifests_checked?: number;
|
||||
manifests_invalid?: number;
|
||||
duration_seconds?: number;
|
||||
/** Zusammenfassung im Klartext. */
|
||||
summary?: string;
|
||||
/** Betroffene Objekte, sofern benennbar. */
|
||||
findings?: string[];
|
||||
}
|
||||
|
||||
/** Ergebnis einer Gesundheitsprüfung. */
|
||||
/** Ergebnis einer Gesundheitspruefung. */
|
||||
export interface RepositoryHealth {
|
||||
status: string;
|
||||
/** Freier Platz am Ablageort. */
|
||||
@ -96,9 +68,9 @@ export interface RetentionPreview {
|
||||
/** Punkte, die durch Schutz erhalten bleiben. */
|
||||
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.
|
||||
*/
|
||||
bytes_to_free?: number;
|
||||
@ -106,12 +78,12 @@ export interface RetentionPreview {
|
||||
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[]> {
|
||||
return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
||||
}
|
||||
|
||||
/** Lädt ein einzelnes Repository. */
|
||||
/** Laedt ein einzelnes Repository. */
|
||||
export async function getRepository(
|
||||
repositoryIdentifier: string,
|
||||
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
|
||||
* Kennung aus dem Descriptor. Liegt dort keines, schlägt der Aufruf fehl.
|
||||
* Es wird **nicht angelegt**: Der Server oeffnet das Verzeichnis und liest die
|
||||
* Kennung aus dem Descriptor. Liegt dort keines, schlaegt der Aufruf fehl.
|
||||
*/
|
||||
export async function adoptRepository(repositoryRequest: {
|
||||
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(
|
||||
repositoryIdentifier: 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(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
|
||||
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
|
||||
): Promise<RepositoryHealth> {
|
||||
return requestApi<RepositoryHealth>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
/** Führt eine Gesundheitsprüfung aus. */
|
||||
/** Fuehrt eine Gesundheitspruefung aus. */
|
||||
export async function checkRepositoryHealth(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<RepositoryCheckResponse<RepositoryHealth>> {
|
||||
return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
|
||||
): Promise<RepositoryHealth> {
|
||||
return requestApi<RepositoryHealth>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet einen Integritätslauf.
|
||||
* Startet einen Integritaetslauf.
|
||||
*
|
||||
* Er prüft jeden Block gegen die gespeicherte Prüfsumme der **abgelegten**
|
||||
* Form — deshalb braucht er den Datenschlüssel nicht. Ein Befund ist ein
|
||||
* Ergebnis, kein Fehler des Laufs; die Oberfläche trennt beides.
|
||||
* Er prueft jeden Block gegen die gespeicherte Pruefsumme der **abgelegten**
|
||||
* Form — deshalb braucht er den Datenschluessel nicht. Ein Befund ist ein
|
||||
* Ergebnis, kein Fehler des Laufs; die Oberflaeche trennt beides.
|
||||
*/
|
||||
export async function startIntegrityScan(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<IntegrityScanDetails> {
|
||||
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
|
||||
): Promise<IntegrityScanResult> {
|
||||
return requestApi<IntegrityScanResult>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
||||
{ method: 'POST', idempotencyKey: true },
|
||||
);
|
||||
|
||||
// Ein nicht erreichbares Repository ist kein Befund am Bestand, sondern ein
|
||||
// Fehler des Laufs — die Unterscheidung, die diese Seite durchgehend macht.
|
||||
if (!response.reachable || !response.details) {
|
||||
throw new Error(
|
||||
response.error || 'Das Repository war während der Prüfung nicht erreichbar.',
|
||||
);
|
||||
}
|
||||
|
||||
return response.details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den Katalog neu auf.
|
||||
*
|
||||
* Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er
|
||||
* lässt sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis,
|
||||
* laesst sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis,
|
||||
* dass das Repository ohne die Datenbank auskommt.
|
||||
*/
|
||||
export async function rebuildCatalog(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
|
||||
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
|
||||
): Promise<{ backups_found?: number; summary?: string }> {
|
||||
return requestApi<{ backups_found?: number; summary?: string }>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
||||
{ 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
|
||||
* zu löschen. Gemeldet wird nur, was das Betriebssystem nachweislich
|
||||
* zu loeschen. Gemeldet wird nur, was das Betriebssystem nachweislich
|
||||
* verhindert.
|
||||
*/
|
||||
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(
|
||||
repositoryIdentifier: 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.
|
||||
*/
|
||||
export async function applyRetention(
|
||||
@ -276,7 +238,7 @@ export interface RetentionPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
keep_within_seconds?: number;
|
||||
/** Schützt das letzte vorhandene Backup. */
|
||||
/** Schuetzt das letzte vorhandene Backup. */
|
||||
keep_last?: number;
|
||||
keep_daily?: number;
|
||||
keep_weekly?: number;
|
||||
@ -285,41 +247,14 @@ export interface RetentionPolicy {
|
||||
time_zone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antwort auf die Regelliste.
|
||||
*
|
||||
* **Dieser Endpunkt liefert ein Objekt, keine Liste** — als einziger von zehn
|
||||
* geprüften. Ihn als Array zu behandeln ließ die Seite abstürzen: `map` gibt es
|
||||
* auf einem Objekt nicht, React hängte den ganzen Baum aus, und übrig blieb ein
|
||||
* schwarzer Bildschirm.
|
||||
*/
|
||||
export interface RetentionPolicyList {
|
||||
/** Selbst angelegte Regeln. */
|
||||
policies: RetentionPolicy[];
|
||||
/**
|
||||
* Mitgelieferte Vorlagen.
|
||||
*
|
||||
* Sie tragen keine Kennung — es sind Vorschläge, keine Regeln. Wer eine
|
||||
* übernimmt, legt damit eine eigene an.
|
||||
*/
|
||||
predefined: Array<Omit<RetentionPolicy, 'id'> & { description?: string }>;
|
||||
}
|
||||
|
||||
/** Lädt die Aufbewahrungsregeln samt Vorlagen. */
|
||||
/** Laedt die Aufbewahrungsregeln. */
|
||||
export async function listRetentionPolicies(
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<RetentionPolicyList> {
|
||||
const response = await requestApi<RetentionPolicyList>(
|
||||
): Promise<RetentionPolicy[]> {
|
||||
return requestApi<RetentionPolicy[]>(
|
||||
'/retention-policies',
|
||||
abortSignal ? { signal: abortSignal } : {},
|
||||
);
|
||||
|
||||
// Fehlt ein Feld, ist die Liste leer — nicht undefined. Ein `?? []` an jeder
|
||||
// Verwendungsstelle wäre ein `??` zu viel, das irgendwann jemand vergisst.
|
||||
return {
|
||||
policies: response.policies ?? [],
|
||||
predefined: response.predefined ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Legt eine Aufbewahrungsregel an. */
|
||||
@ -333,7 +268,7 @@ export async function createRetentionPolicy(
|
||||
});
|
||||
}
|
||||
|
||||
/** Löscht eine Aufbewahrungsregel. */
|
||||
/** Loescht eine Aufbewahrungsregel. */
|
||||
export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> {
|
||||
return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Auswahl dessen, was zurückgeholt werden soll.
|
||||
*
|
||||
* Bisher gab es nur „alles" — ein Textfeld für einen Teilbaum, das voraussetzte,
|
||||
* dass man die Pfade im Backup auswendig kennt. Jetzt lässt sich blättern.
|
||||
*
|
||||
* Der gewählte Pfad wandert als `path_prefix` in die Anfrage. Das genügt für
|
||||
* beides: Der Server vergleicht auf Gleichheit **oder** Präfix mit
|
||||
* Verzeichnisgrenze, trifft also sowohl einen ganzen Ordner als auch eine
|
||||
* einzelne Datei. „dokumente" trifft dabei nicht „dokumentation".
|
||||
*
|
||||
* Bewusste Grenze: **eine** Auswahl je Lauf, kein Mehrfachhaken. Eine Liste
|
||||
* ausgewählter Pfade kennt die API nicht, und sie vorzutäuschen — etwa durch
|
||||
* mehrere Läufe hintereinander — ergäbe mehrere Wiederherstellungen mit
|
||||
* getrenntem Ausgang. Ein „teilweise fehlgeschlagen" ließe sich dann niemandem
|
||||
* mehr erklären.
|
||||
*/
|
||||
|
||||
import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useApiResource } from '@/api/useApiResource';
|
||||
import { describeApiError } from '@/api/useMutation';
|
||||
import { Button, Callout, ErrorState, LoadingState } from '@/components/ui';
|
||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
||||
import { browseBackupContents } from './browseApi';
|
||||
|
||||
export function BackupContentPicker({
|
||||
backupIdentifier,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
readonly backupIdentifier: string;
|
||||
/** Leer bedeutet: das gesamte Backup. */
|
||||
readonly selectedPath: string;
|
||||
readonly onSelect: (contentPath: string) => void;
|
||||
}) {
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
|
||||
const contentsResource = useApiResource(
|
||||
useCallback(
|
||||
(abortSignal) => browseBackupContents(backupIdentifier, currentPath, abortSignal),
|
||||
[backupIdentifier, currentPath],
|
||||
),
|
||||
`${backupIdentifier}|${currentPath}`,
|
||||
);
|
||||
|
||||
const listing = contentsResource.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Der Regelfall steht oben und ist vorausgewählt: Die meisten
|
||||
Wiederherstellungen holen alles zurück. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPath === '' ? 'primary' : 'secondary'}
|
||||
onClick={() => onSelect('')}
|
||||
>
|
||||
Gesamtes Backup
|
||||
</Button>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pfadleiste */}
|
||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() => setCurrentPath('')}
|
||||
>
|
||||
Wurzel
|
||||
</button>
|
||||
{currentPath
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
.map((segment, segmentIndex, allSegments) => (
|
||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() => setCurrentPath(allSegments.slice(0, segmentIndex + 1).join('/'))}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto border border-line">
|
||||
{contentsResource.loadState === 'loading' ? (
|
||||
<LoadingState label="Inhalt wird gelesen …" />
|
||||
) : contentsResource.loadState === 'failed' && contentsResource.loadError ? (
|
||||
<ErrorState
|
||||
message={describeApiError(contentsResource.loadError)}
|
||||
requestId={contentsResource.loadError.requestId}
|
||||
onRetry={contentsResource.reload}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{currentPath !== '' ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
||||
onClick={() => setCurrentPath(listing?.parent_path ?? '')}
|
||||
>
|
||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
||||
… eine Ebene höher
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{(listing?.entries ?? []).map((entry) => (
|
||||
<li key={entry.path} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
||||
onClick={() =>
|
||||
entry.is_directory ? setCurrentPath(entry.path) : onSelect(entry.path)
|
||||
}
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
<Folder className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<File className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{entry.name}</span>
|
||||
|
||||
<span className="shrink-0 text-[11px] tabular text-fg-muted">
|
||||
{entry.is_directory
|
||||
? entry.child_count
|
||||
? `${entry.child_count} Objekte · ${formatBytes(entry.total_bytes)}`
|
||||
: ''
|
||||
: formatBytes(entry.size_bytes)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
||||
className="mr-1 shrink-0"
|
||||
onClick={() => onSelect(entry.path)}
|
||||
title={
|
||||
entry.is_directory
|
||||
? 'Diesen Ordner mit allem darin zurückholen'
|
||||
: 'Nur diese Datei zurückholen'
|
||||
}
|
||||
>
|
||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{(listing?.entries ?? []).length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
||||
Dieser Ordner ist im Backup leer.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPath ? (
|
||||
<Callout tone="info">
|
||||
Zurückgeholt wird nur: <code className="font-mono">{selectedPath}</code>
|
||||
<p className="mt-1 text-xs">
|
||||
Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des
|
||||
Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das
|
||||
Backup.
|
||||
</p>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout tone="info">Zurückgeholt wird das gesamte Backup.</Callout>
|
||||
)}
|
||||
|
||||
{listing?.entries.some((entry) => entry.modified_at) ? (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Stand der Dateien:{' '}
|
||||
{formatDateTime(
|
||||
listing.entries.find((entry) => entry.modified_at)?.modified_at,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,220 +0,0 @@
|
||||
/**
|
||||
* Auswahl des Zielverzeichnisses.
|
||||
*
|
||||
* Ersetzt das Textfeld, in das man einen Pfad tippte und erst nach der
|
||||
* Vorabprüfung erfuhr, dass der Dienst dort gar nicht schreiben darf.
|
||||
*
|
||||
* Die tragende Angabe ist **`is_writable`**, und sie wird gemessen: Der Server
|
||||
* legt eine Probedatei an und entfernt sie wieder. Aus den Rechtebits ließe sie
|
||||
* sich nicht ableiten — der Dienst läuft mit `ProtectSystem=strict`, und davon
|
||||
* steht nichts im Modus. Genau deshalb endete eine Wiederherstellung nach
|
||||
* `/opt/test` mit „permission denied", obwohl das Verzeichnis beschreibbar
|
||||
* aussah.
|
||||
*
|
||||
* Nicht beschreibbare Verzeichnisse werden **gezeigt**, nicht versteckt: Sie
|
||||
* lassen sich betreten, um tiefer zu blättern, aber nicht auswählen. Sie
|
||||
* wegzulassen ließe den Betreiber im Dunkeln, warum sein Pfad fehlt.
|
||||
*/
|
||||
|
||||
import { ChevronRight, FolderOpen, Lock, Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useApiResource } from '@/api/useApiResource';
|
||||
import { describeApiError } from '@/api/useMutation';
|
||||
import {
|
||||
Button,
|
||||
Callout,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
TextInput,
|
||||
} from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { browseFilesystem } from './browseApi';
|
||||
|
||||
export function DirectoryPicker({
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
readonly selectedPath: string;
|
||||
readonly onSelect: (directoryPath: string) => void;
|
||||
}) {
|
||||
// Der Startpunkt ist das übergeordnete Verzeichnis der Auswahl, sonst die
|
||||
// Wurzel — so landet man beim zweiten Öffnen dort, wo man aufgehört hat.
|
||||
const [currentPath, setCurrentPath] = useState(() => {
|
||||
const trimmedSelection = selectedPath.trim();
|
||||
|
||||
if (!trimmedSelection.startsWith('/')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const parentPath = trimmedSelection.replace(/\/[^/]*$/, '');
|
||||
|
||||
return parentPath === '' ? '/' : parentPath;
|
||||
});
|
||||
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
const browseResource = useApiResource(
|
||||
useCallback((abortSignal) => browseFilesystem(currentPath, abortSignal), [currentPath]),
|
||||
currentPath,
|
||||
);
|
||||
|
||||
const listing = browseResource.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen
|
||||
brauchbaren Ort zu kommen. */}
|
||||
{listing?.suggested_paths && listing.suggested_paths.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-fg-subtle">Beschreibbare Orte</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{listing.suggested_paths.map((suggestedPath) => (
|
||||
<Button
|
||||
key={suggestedPath}
|
||||
size="sm"
|
||||
variant={selectedPath === suggestedPath ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
onSelect(suggestedPath);
|
||||
setCurrentPath(suggestedPath);
|
||||
}}
|
||||
>
|
||||
{suggestedPath}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Pfadleiste */}
|
||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() => setCurrentPath('/')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{currentPath
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
.map((segment, segmentIndex, allSegments) => (
|
||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() =>
|
||||
setCurrentPath('/' + allSegments.slice(0, segmentIndex + 1).join('/'))
|
||||
}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div className="max-h-64 overflow-y-auto border border-line">
|
||||
{browseResource.loadState === 'loading' ? (
|
||||
<LoadingState label="Verzeichnis wird gelesen …" />
|
||||
) : browseResource.loadState === 'failed' && browseResource.loadError ? (
|
||||
<ErrorState
|
||||
message={describeApiError(browseResource.loadError)}
|
||||
requestId={browseResource.loadError.requestId}
|
||||
onRetry={browseResource.reload}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{listing?.parent_path ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
||||
onClick={() => setCurrentPath(listing.parent_path!)}
|
||||
>
|
||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
||||
… eine Ebene höher
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{(listing?.entries ?? []).map((entry) => (
|
||||
<li key={entry.path} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
||||
onClick={() => setCurrentPath(entry.path)}
|
||||
title={entry.forbidden_reason}
|
||||
>
|
||||
{entry.is_writable ? (
|
||||
<FolderOpen className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<Lock className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
||||
)}
|
||||
<span className={cn('truncate', entry.is_writable ? 'text-fg' : 'text-fg-subtle')}>
|
||||
{entry.name}
|
||||
</span>
|
||||
{!entry.is_writable ? (
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-subtle">
|
||||
{entry.forbidden_reason ? 'gesperrt' : 'nicht beschreibbar'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{entry.is_writable ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
||||
className="mr-1 shrink-0"
|
||||
onClick={() => onSelect(entry.path)}
|
||||
>
|
||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{(listing?.entries ?? []).length === 0 && !listing?.parent_path ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
||||
Keine Unterverzeichnisse.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er
|
||||
im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach
|
||||
an den aktuellen Pfad. */}
|
||||
<div className="flex items-end gap-2">
|
||||
<TextInput
|
||||
label="Neues Unterverzeichnis"
|
||||
className="flex-1"
|
||||
value={newFolderName}
|
||||
onChange={(changeEvent) => setNewFolderName(changeEvent.target.value)}
|
||||
placeholder="wiederherstellung-2026-08-18"
|
||||
hint="Es wird beim Zurückschreiben angelegt."
|
||||
/>
|
||||
<Button
|
||||
className="mb-0.5"
|
||||
disabled={!newFolderName.trim()}
|
||||
onClick={() => {
|
||||
const combinedPath =
|
||||
(currentPath === '/' ? '' : currentPath) + '/' + newFolderName.trim();
|
||||
onSelect(combinedPath);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
Übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPath ? (
|
||||
<Callout tone="info">
|
||||
Ziel: <code className="font-mono">{selectedPath}</code>
|
||||
</Callout>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,26 +1,26 @@
|
||||
/**
|
||||
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
|
||||
*
|
||||
* Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht
|
||||
* auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen —
|
||||
* Bis hierher liess sich eine Wiederherstellung ueber die Oberflaeche gar nicht
|
||||
* ausloesen. Wer im Ernstfall Daten zurueckbrauchte, musste `curl` bemuehen —
|
||||
* ausgerechnet in der Lage, in der niemand experimentieren will.
|
||||
*
|
||||
* Der Assistent hat vier Schritte, und der dritte ist der Grund für seine
|
||||
* Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die
|
||||
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block
|
||||
* Der Assistent hat vier Schritte, und der dritte ist der Grund fuer seine
|
||||
* Existenz: **Die Vorabpruefung schreibt nichts und stellt fest, ob die
|
||||
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benoetigte Block
|
||||
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
|
||||
* gesichert hat.
|
||||
*
|
||||
* Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt:
|
||||
* Die drei Huerden vor dem Ueberschreiben sind hier sichtbar umgesetzt:
|
||||
*
|
||||
* 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`.
|
||||
* 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
|
||||
* überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das
|
||||
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
|
||||
* Laeuft die dritte Huerde ins Leere — weil das Ziel leer ist und nichts
|
||||
* ueberschrieben wird —, entfaellt sie. Ein Ritual ohne Anlass gewoehnt das
|
||||
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
|
||||
*/
|
||||
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
|
||||
@ -41,8 +41,6 @@ import {
|
||||
useToast,
|
||||
} from '@/components/ui';
|
||||
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
||||
import { BackupContentPicker } from './BackupContentPicker';
|
||||
import { DirectoryPicker } from './DirectoryPicker';
|
||||
import {
|
||||
createRestore,
|
||||
validateRestore,
|
||||
@ -54,8 +52,8 @@ import {
|
||||
const WIZARD_STEPS = [
|
||||
{ key: 'ziel', label: 'Ziel' },
|
||||
{ key: 'umfang', label: 'Umfang' },
|
||||
{ key: 'prüfung', label: 'Vorabprüfung' },
|
||||
{ key: 'ausführen', label: 'Ausführen' },
|
||||
{ key: 'pruefung', label: 'Vorabpruefung' },
|
||||
{ key: 'ausfuehren', label: 'Ausfuehren' },
|
||||
] as const;
|
||||
|
||||
export function RestoreWizard({
|
||||
@ -76,7 +74,7 @@ export function RestoreWizard({
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
|
||||
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst
|
||||
// wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten).
|
||||
// waere jeder Blick zurueck ein Datenverlust (wie im Backup-Assistenten).
|
||||
const [targetPath, setTargetPath] = useState('');
|
||||
const [pathPrefix, setPathPrefix] = useState('');
|
||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
||||
@ -129,7 +127,7 @@ export function RestoreWizard({
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Die Vorabprüfung ist fehlgeschlagen',
|
||||
'Die Vorabpruefung ist fehlgeschlagen',
|
||||
describeApiError(apiError),
|
||||
apiError.requestId,
|
||||
),
|
||||
@ -140,13 +138,13 @@ export function RestoreWizard({
|
||||
onSuccess: (session) => {
|
||||
toast.showSuccess(
|
||||
'Wiederherstellung gestartet',
|
||||
'Der Fortschritt erscheint in der Übersicht.',
|
||||
'Der Fortschritt erscheint in der Uebersicht.',
|
||||
);
|
||||
onStarted(session.id);
|
||||
},
|
||||
onError: (apiError) =>
|
||||
toast.showError(
|
||||
'Die Wiederherstellung ließ sich nicht starten',
|
||||
'Die Wiederherstellung liess sich nicht starten',
|
||||
describeApiError(apiError),
|
||||
apiError.requestId,
|
||||
),
|
||||
@ -154,8 +152,8 @@ export function RestoreWizard({
|
||||
|
||||
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
|
||||
|
||||
// Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich
|
||||
// überschrieben wird — der Server sagt das nach der Prüfung.
|
||||
// Die woertliche Bestaetigung wird nur verlangt, wenn tatsaechlich
|
||||
// ueberschrieben wird — der Server sagt das nach der Pruefung.
|
||||
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
|
||||
const confirmationSatisfied =
|
||||
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
|
||||
@ -172,15 +170,38 @@ export function RestoreWizard({
|
||||
|
||||
<DialogBody className="min-h-64">
|
||||
{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}
|
||||
|
||||
{currentStepIndex === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<BackupContentPicker
|
||||
backupIdentifier={backupIdentifier}
|
||||
selectedPath={pathPrefix}
|
||||
onSelect={setPathPrefix}
|
||||
<TextInput
|
||||
label="Nur ein Teilbaum (optional)"
|
||||
placeholder="daten/projekte"
|
||||
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">
|
||||
@ -188,28 +209,36 @@ export function RestoreWizard({
|
||||
checked={overwriteExisting}
|
||||
onCheckedChange={setOverwriteExisting}
|
||||
disabled={!mayOverwrite}
|
||||
label="Vorhandene Dateien überschreiben"
|
||||
label="Vorhandene Dateien ueberschreiben"
|
||||
hint={
|
||||
mayOverwrite
|
||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.'
|
||||
: 'Ihrer Rolle fehlt das Recht restores.overwrite.'
|
||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusaetzlich verlangt der Server danach den woertlich wiederholten Zielpfad.'
|
||||
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={setSkipPermissions}
|
||||
label="Rechte nicht zurückschreiben"
|
||||
hint="Dateien entstehen mit den Standardrechten des Dienstkontos."
|
||||
label="Rechte nicht zurueckschreiben"
|
||||
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
checked={skipDeepCheck}
|
||||
onCheckedChange={setSkipDeepCheck}
|
||||
label="Blockprüfung überspringen"
|
||||
hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind."
|
||||
label="Blockpruefung ueberspringen"
|
||||
hint="Beschleunigt die Vorabpruefung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benoetigte Block noch vorhanden ist."
|
||||
/>
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
@ -226,11 +255,11 @@ export function RestoreWizard({
|
||||
{confirmationRequired ? (
|
||||
<>
|
||||
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>
|
||||
@ -252,8 +281,8 @@ export function RestoreWizard({
|
||||
|
||||
{confirmationRequired ? (
|
||||
<Field
|
||||
label="Zielpfad zur Bestätigung wiederholen"
|
||||
hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
|
||||
label="Zielpfad zur Bestaetigung wiederholen"
|
||||
hint="Woertlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
|
||||
required
|
||||
htmlFor="restore-confirm"
|
||||
>
|
||||
@ -279,7 +308,7 @@ export function RestoreWizard({
|
||||
disabled={validationMutation.isRunning || createMutation.isRunning}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Zurück
|
||||
Zurueck
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
@ -304,7 +333,7 @@ export function RestoreWizard({
|
||||
isLoading={validationMutation.isRunning}
|
||||
onClick={() => void validationMutation.run()}
|
||||
>
|
||||
Vorabprüfung starten
|
||||
Vorabpruefung starten
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
) : 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,
|
||||
* bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen
|
||||
* Fehlende Bloecke stehen ganz oben und in Rot. Sie sind der einzige Befund,
|
||||
* bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** gelingen
|
||||
* kann — alles andere ist ein Hinweis.
|
||||
*/
|
||||
function ValidationSummary({
|
||||
@ -388,8 +417,8 @@ function ValidationSummary({
|
||||
if (isRunning || !validationResult) {
|
||||
return (
|
||||
<div className="py-10 text-center text-sm text-fg-muted">
|
||||
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder
|
||||
benötigte Block gesucht — das dauert bei großen Beständen.
|
||||
Die Vorabpruefung laeuft. Bei aktiver Blockpruefung wird jeder
|
||||
benoetigte Block gesucht — das dauert bei grossen Bestaenden.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -424,7 +453,7 @@ function ValidationSummary({
|
||||
>
|
||||
{validationResult.can_proceed
|
||||
? 'Die Wiederherstellung kann beginnen'
|
||||
: 'Die Wiederherstellung ist nicht möglich'}
|
||||
: 'Die Wiederherstellung ist nicht moeglich'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
|
||||
</div>
|
||||
@ -445,15 +474,15 @@ function ValidationSummary({
|
||||
<div>
|
||||
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
|
||||
<dd className="tabular text-sm text-fg">
|
||||
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil
|
||||
der Wahrheit — es sähe nach "voll" aus. */}
|
||||
{/* -1 bedeutet unbekannt. Als 0 zu zeigen waere das Gegenteil
|
||||
der Wahrheit — es saehe nach "voll" aus. */}
|
||||
{report.available_target_bytes < 0
|
||||
? 'unbekannt'
|
||||
: formatBytes(report.available_target_bytes)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-fg-subtle">Dauer der Prüfung</dt>
|
||||
<dt className="text-xs text-fg-subtle">Dauer der Pruefung</dt>
|
||||
<dd className="tabular text-sm text-fg">
|
||||
{formatDuration(report.duration_seconds)}
|
||||
</dd>
|
||||
@ -462,11 +491,11 @@ function ValidationSummary({
|
||||
) : null}
|
||||
|
||||
{report && report.missing_chunk_count > 0 ? (
|
||||
<Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}>
|
||||
Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten
|
||||
Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
|
||||
auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das
|
||||
Repository mit einem Integritätslauf.
|
||||
<Callout tone="critical" title={`${report.missing_chunk_count} Bloecke fehlen`}>
|
||||
Von {report.unique_chunk_count.toLocaleString('de-DE')} benoetigten
|
||||
Bloecken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
|
||||
auffindbar. Die Wiederherstellung waere unvollstaendig. Pruefen Sie das
|
||||
Repository mit einem Integritaetslauf.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -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
|
||||
* 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
|
||||
* 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
|
||||
* 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';
|
||||
@ -103,7 +103,7 @@ export function RestoresPage({
|
||||
render: (session) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
Fortsetzung und einem Neubeginn. */}
|
||||
{session.status === 'failed' && session.checkpoint ? (
|
||||
@ -130,7 +130,7 @@ export function RestoresPage({
|
||||
{session.files_restored.toLocaleString('de-DE')}
|
||||
{session.files_skipped > 0 ? (
|
||||
<span className="ml-1 text-warning">
|
||||
({session.files_skipped.toLocaleString('de-DE')} übergangen)
|
||||
({session.files_skipped.toLocaleString('de-DE')} uebergangen)
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@ -138,7 +138,7 @@ export function RestoresPage({
|
||||
},
|
||||
{
|
||||
key: 'bytes_restored',
|
||||
header: 'Zurückgeschrieben',
|
||||
header: 'Zurueckgeschrieben',
|
||||
numeric: true,
|
||||
render: (session) => formatBytes(session.bytes_restored),
|
||||
},
|
||||
@ -200,7 +200,7 @@ export function RestoresPage({
|
||||
|
||||
<Callout tone="info" className="mb-4">
|
||||
Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '}
|
||||
gestartet — dort steht, was gesichert wurde und ob es geprüft ist.
|
||||
gestartet — dort steht, was gesichert wurde und ob es geprueft ist.
|
||||
</Callout>
|
||||
|
||||
<Card>
|
||||
@ -211,7 +211,7 @@ export function RestoresPage({
|
||||
onRowClick={(session) => setSelectedSession(session)}
|
||||
isLoading={restoresResource.loadState === 'loading'}
|
||||
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>
|
||||
|
||||
@ -236,7 +236,7 @@ export function RestoresPage({
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-fg">
|
||||
Es wird ab dem letzten vollständig geschriebenen Pfad weitergearbeitet.
|
||||
Es wird ab dem letzten vollstaendig geschriebenen Pfad weitergearbeitet.
|
||||
</p>
|
||||
{sessionPendingResume?.checkpoint?.last_completed_path ? (
|
||||
<div className="rounded-md border border-line bg-sunken p-2.5">
|
||||
@ -247,7 +247,10 @@ export function RestoresPage({
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
@ -270,7 +273,7 @@ function RestoreDetailCard({
|
||||
description={session.target_path}
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Schließen
|
||||
Schliessen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@ -285,11 +288,11 @@ function RestoreDetailCard({
|
||||
<DetailItem label="Dateien">
|
||||
{session.files_restored.toLocaleString('de-DE')}
|
||||
</DetailItem>
|
||||
<DetailItem label="Übergangen">
|
||||
<DetailItem label="Uebergangen">
|
||||
{session.files_skipped.toLocaleString('de-DE')}
|
||||
</DetailItem>
|
||||
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
|
||||
<DetailItem label="Überschreiben erlaubt">
|
||||
<DetailItem label="Ueberschreiben erlaubt">
|
||||
{session.overwrite_existing ? 'ja' : 'nein'}
|
||||
</DetailItem>
|
||||
</dl>
|
||||
@ -303,15 +306,15 @@ function RestoreDetailCard({
|
||||
{session.checkpoint ? (
|
||||
<div className="mt-4 rounded-md border border-line bg-sunken p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||
Prüfpunkt
|
||||
Pruefpunkt
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs text-fg">
|
||||
{session.checkpoint.last_completed_path ?? '—'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Er entsteht erst, <strong>nachdem</strong> eine Datei vollständig
|
||||
und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei
|
||||
wäre schlimmer als keiner.
|
||||
Er entsteht erst, <strong>nachdem</strong> eine Datei vollstaendig
|
||||
und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei
|
||||
waere schlimmer als keiner.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Blättern im Dateisystem des Servers und im Inhalt eines Backups.
|
||||
*
|
||||
* Beides gehört zur Wiederherstellung: Wohin darf zurückgeschrieben werden, und
|
||||
* was steckt überhaupt in dem Backup? Ein Textfeld beantwortet weder das eine
|
||||
* noch das andere.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
|
||||
/** Ein Verzeichnis auf dem Server. */
|
||||
export interface FilesystemEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
/**
|
||||
* Meldet, ob der **Dienst** hier anlegen darf.
|
||||
*
|
||||
* Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: Der
|
||||
* Dienst läuft mit `ProtectSystem=strict`, und davon steht nichts im Modus.
|
||||
*/
|
||||
is_writable: boolean;
|
||||
/** Grund, wenn der Zielschutz den Ort ausschließt. */
|
||||
forbidden_reason?: string;
|
||||
}
|
||||
|
||||
/** Antwort auf das Blättern im Dateisystem. */
|
||||
export interface BrowseFilesystemResponse {
|
||||
path: string;
|
||||
parent_path?: string;
|
||||
entries: FilesystemEntry[];
|
||||
/** Orte, an denen der Dienst nachweislich schreiben darf. */
|
||||
suggested_paths?: string[];
|
||||
}
|
||||
|
||||
/** Ein Eintrag im Inhaltsverzeichnis eines Backups. */
|
||||
export interface BackupContentEntry {
|
||||
name: string;
|
||||
/** Genau der Wert, den eine Wiederherstellung als `path_prefix` erwartet. */
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
entry_type: string;
|
||||
size_bytes?: number;
|
||||
modified_at?: string;
|
||||
mode?: string;
|
||||
/** Zahl der Einträge unterhalb eines Verzeichnisses. */
|
||||
child_count?: number;
|
||||
/** Datenmenge unterhalb eines Verzeichnisses. */
|
||||
total_bytes?: number;
|
||||
}
|
||||
|
||||
/** Antwort auf das Blättern im Backup. */
|
||||
export interface BrowseBackupResponse {
|
||||
backup_id: string;
|
||||
path: string;
|
||||
parent_path?: string;
|
||||
entries: BackupContentEntry[];
|
||||
total_entry_count: number;
|
||||
}
|
||||
|
||||
/** Listet die Verzeichnisse unterhalb eines Pfades. */
|
||||
export async function browseFilesystem(
|
||||
directoryPath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<BrowseFilesystemResponse> {
|
||||
return requestApi<BrowseFilesystemResponse>(
|
||||
`/filesystem/browse?path=${encodeURIComponent(directoryPath)}`,
|
||||
abortSignal ? { signal: abortSignal } : {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Listet den Inhalt eines Backups auf einer Ebene. */
|
||||
export async function browseBackupContents(
|
||||
backupIdentifier: string,
|
||||
contentPath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<BrowseBackupResponse> {
|
||||
return requestApi<BrowseBackupResponse>(
|
||||
`/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`,
|
||||
abortSignal ? { signal: abortSignal } : {},
|
||||
);
|
||||
}
|
||||
@ -1,22 +1,22 @@
|
||||
/**
|
||||
* 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* —
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
|
||||
/** Schweregrad eines Befundes der Vorabprüfung. */
|
||||
/** Schweregrad eines Befundes der Vorabpruefung. */
|
||||
export type ValidationSeverity = 'blocking' | 'warning' | 'info';
|
||||
|
||||
/** Einzelner Befund der Vorabprüfung. */
|
||||
/** Einzelner Befund der Vorabpruefung. */
|
||||
export interface ValidationFinding {
|
||||
/** Maschinenlesbarer Code. */
|
||||
code: string;
|
||||
/** Schweregrad; nur `blocking` verhindert die Ausführung. */
|
||||
/** Schweregrad; nur `blocking` verhindert die Ausfuehrung. */
|
||||
severity: ValidationSeverity;
|
||||
/** Meldung im Klartext. */
|
||||
message: string;
|
||||
@ -24,7 +24,7 @@ export interface ValidationFinding {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Bericht der Vorabprüfung. */
|
||||
/** Bericht der Vorabpruefung. */
|
||||
export interface ValidationReport {
|
||||
backup_id: string;
|
||||
target_path: string;
|
||||
@ -33,11 +33,11 @@ export interface ValidationReport {
|
||||
entry_count: number;
|
||||
/** Zahl wiederherzustellender Dateien. */
|
||||
file_count: number;
|
||||
/** Zurückzuschreibende Datenmenge. */
|
||||
/** Zurueckzuschreibende Datenmenge. */
|
||||
total_bytes: number;
|
||||
/** Zahl benötigter Blöcke. */
|
||||
/** Zahl benoetigter Bloecke. */
|
||||
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;
|
||||
/** Freier Platz am Ziel; -1 bedeutet unbekannt. */
|
||||
available_target_bytes: number;
|
||||
@ -45,20 +45,20 @@ export interface ValidationReport {
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
/** Antwort der Vorabprüfung. */
|
||||
/** Antwort der Vorabpruefung. */
|
||||
export interface ValidationResponse {
|
||||
/** Darf die Wiederherstellung beginnen? */
|
||||
can_proceed: boolean;
|
||||
/** Verlangt das Ziel eine wörtliche Bestätigung? */
|
||||
/** Verlangt das Ziel eine woertliche Bestaetigung? */
|
||||
requires_overwrite_confirmation: boolean;
|
||||
/** Zusammenfassung in einem Satz. */
|
||||
summary: string;
|
||||
report: ValidationReport | null;
|
||||
}
|
||||
|
||||
/** Prüfpunkt einer unterbrochenen Wiederherstellung. */
|
||||
/** Pruefpunkt einer unterbrochenen Wiederherstellung. */
|
||||
export interface RestoreCheckpoint {
|
||||
/** Zuletzt vollständig geschriebener Pfad. */
|
||||
/** Zuletzt vollstaendig geschriebener Pfad. */
|
||||
last_completed_path?: string;
|
||||
files_restored?: number;
|
||||
bytes_restored?: number;
|
||||
@ -95,31 +95,31 @@ export interface CreateRestoreRequest {
|
||||
target_type: string;
|
||||
/** Zielverzeichnis. */
|
||||
target_path: string;
|
||||
/** Beschränkung auf einen Teilbaum des Manifests. */
|
||||
/** Beschraenkung auf einen Teilbaum des Manifests. */
|
||||
path_prefix?: string;
|
||||
/** Erste Hürde: das Kennzeichen. */
|
||||
/** Erste Huerde: das Kennzeichen. */
|
||||
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
|
||||
* damit nicht aus.
|
||||
*/
|
||||
confirm_overwrite?: string;
|
||||
/** Rechte nicht mit zurückschreiben. */
|
||||
/** Rechte nicht mit zurueckschreiben. */
|
||||
skip_permissions?: boolean;
|
||||
/**
|
||||
* Blockprüfung überspringen.
|
||||
* Blockpruefung ueberspringen.
|
||||
*
|
||||
* Beschleunigt die Vorabprüfung erheblich und **senkt ihre Aussagekraft**:
|
||||
* Ohne sie ist nicht belegt, dass jeder benötigte Block noch da ist. Der
|
||||
* Beschleunigt die Vorabpruefung erheblich und **senkt ihre Aussagekraft**:
|
||||
* Ohne sie ist nicht belegt, dass jeder benoetigte Block noch da ist. Der
|
||||
* Bericht weist das als Hinweis aus.
|
||||
*/
|
||||
skip_deep_check?: boolean;
|
||||
}
|
||||
|
||||
/** Führt die Vorabprüfung aus. Sie schreibt nichts. */
|
||||
/** Fuehrt die Vorabpruefung aus. Sie schreibt nichts. */
|
||||
export async function validateRestore(
|
||||
validationRequest: CreateRestoreRequest,
|
||||
): 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[]> {
|
||||
return requestApi<RestoreSession[]>(
|
||||
'/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(
|
||||
restoreIdentifier: string,
|
||||
abortSignal?: AbortSignal,
|
||||
@ -171,7 +171,7 @@ export async function cancelRestore(restoreIdentifier: string): Promise<RestoreS
|
||||
* Setzt eine unterbrochene Wiederherstellung fort.
|
||||
*
|
||||
* Es gibt bewusst **keinen** automatischen Wiederholungsversuch: Ein zweiter
|
||||
* Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der erste
|
||||
* Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der erste
|
||||
* bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung.
|
||||
*/
|
||||
export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
/**
|
||||
* 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
|
||||
* gibt es beides.
|
||||
*
|
||||
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt
|
||||
* Schweregrad, Erklärung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
|
||||
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt
|
||||
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
|
||||
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
|
||||
*/
|
||||
|
||||
@ -34,29 +34,29 @@ type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
|
||||
|
||||
/** Ein Sicherheitsbefund. */
|
||||
interface SecurityFinding {
|
||||
/** Prüfbereich. */
|
||||
/** Pruefbereich. */
|
||||
readonly area: string;
|
||||
/** Schweregrad. */
|
||||
readonly severity: FindingSeverity;
|
||||
/** Überschrift. */
|
||||
/** Ueberschrift. */
|
||||
readonly title: string;
|
||||
/** Erklärung, warum der Zustand ein Problem ist. */
|
||||
/** Erklaerung, warum der Zustand ein Problem ist. */
|
||||
readonly explanation: string;
|
||||
/** Das betroffene Objekt. */
|
||||
readonly affected_object: string;
|
||||
/** Die nächste Handlung. */
|
||||
/** Die naechste Handlung. */
|
||||
readonly recommendation: string;
|
||||
}
|
||||
|
||||
/** Ergebnis eines Prüfbereichs. */
|
||||
/** Ergebnis eines Pruefbereichs. */
|
||||
interface AreaResult {
|
||||
/** Bezeichner. */
|
||||
readonly area: string;
|
||||
/** Bezeichnung. */
|
||||
readonly title: string;
|
||||
/** Meldet, ob der Bereich geprüft werden konnte. */
|
||||
/** Meldet, ob der Bereich geprueft werden konnte. */
|
||||
readonly available: boolean;
|
||||
/** Erklärt einen nicht prüfbaren Bereich. */
|
||||
/** Erklaert einen nicht pruefbaren Bereich. */
|
||||
readonly unavailable_reason?: string;
|
||||
/** Befunde des Bereichs. */
|
||||
readonly findings: readonly SecurityFinding[];
|
||||
@ -72,13 +72,13 @@ interface AreaResult {
|
||||
interface SecurityAssessment {
|
||||
/** Gesamtbewertung in Prozent. */
|
||||
readonly score: number;
|
||||
/** Erreichbare Punkte der geprüften Bereiche. */
|
||||
/** Erreichbare Punkte der geprueften Bereiche. */
|
||||
readonly maximum_score: number;
|
||||
/** Einstufung. */
|
||||
readonly grade: string;
|
||||
/** Ergebnisse je Bereich. */
|
||||
readonly areas: readonly AreaResult[];
|
||||
/** Zahl nicht prüfbarer Bereiche. */
|
||||
/** Zahl nicht pruefbarer Bereiche. */
|
||||
readonly unchecked_area_count: number;
|
||||
/** Zahl kritischer Befunde. */
|
||||
readonly critical_finding_count: number;
|
||||
@ -86,7 +86,7 @@ interface SecurityAssessment {
|
||||
readonly high_finding_count: number;
|
||||
/** Zusammenfassung. */
|
||||
readonly summary: string;
|
||||
/** Nicht geprüfte Bereiche. */
|
||||
/** Nicht gepruefte Bereiche. */
|
||||
readonly unchecked_areas: readonly string[];
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ export function SecurityPage(): React.JSX.Element {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Security Center"
|
||||
description="Was geprüft wurde — und was nicht."
|
||||
description="Was geprueft wurde — und was nicht."
|
||||
/>
|
||||
|
||||
{loadState === 'loading' ? (
|
||||
@ -144,12 +144,12 @@ export function SecurityPage(): React.JSX.Element {
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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".
|
||||
*/
|
||||
function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element {
|
||||
@ -181,15 +181,15 @@ function ScoreHeader({ response }: { readonly response: SecurityResponse }): Rea
|
||||
<Callout tone="critical">
|
||||
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
|
||||
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
|
||||
„unzureichend" — unabhängig von der Punktzahl.
|
||||
„unzureichend" — unabhaengig von der Punktzahl.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die
|
||||
Handlungsanweisung, nicht nur eine Einschränkung. */}
|
||||
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
|
||||
Handlungsanweisung, nicht nur eine Einschraenkung. */}
|
||||
{assessment.unchecked_areas.length > 0 ? (
|
||||
<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
|
||||
? ' — 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.
|
||||
*
|
||||
* 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
|
||||
* etwas nicht stimmt, und lässt den Betreiber damit allein.
|
||||
* etwas nicht stimmt, und laesst den Betreiber damit allein.
|
||||
*/
|
||||
function FindingList({
|
||||
assessment,
|
||||
@ -224,8 +224,8 @@ function FindingList({
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Keine Befunde in den geprüften Bereichen"
|
||||
description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben."
|
||||
title="Keine Befunde in den geprueften Bereichen"
|
||||
description="Das gilt ausschliesslich fuer die geprueften Bereiche — die ungeprueften stehen oben."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
@ -257,10 +257,10 @@ function FindingList({
|
||||
</div>
|
||||
|
||||
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
|
||||
der Befund überhaupt angezeigt wird. */}
|
||||
der Befund ueberhaupt angezeigt wird. */}
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
|
||||
Nächster Schritt
|
||||
Naechster Schritt
|
||||
</dt>
|
||||
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
|
||||
</div>
|
||||
@ -273,7 +273,7 @@ function FindingList({
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeigt die Ergebnisse je Prüfbereich. */
|
||||
/** Zeigt die Ergebnisse je Pruefbereich. */
|
||||
function AreaTable({
|
||||
assessment,
|
||||
}: {
|
||||
@ -293,7 +293,7 @@ function AreaTable({
|
||||
<span className="text-sm text-fg">{areaResult.summary}</span>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
),
|
||||
@ -308,7 +308,7 @@ function AreaTable({
|
||||
) : (
|
||||
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
|
||||
// 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 (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Geprüft wurde"
|
||||
title="Geprueft wurde"
|
||||
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
|
||||
/>
|
||||
<DataTable
|
||||
|
||||
@ -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 { 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
|
||||
* im Aufruf — eine Komponente ließe sich dann von außen nicht mehr anpassen,
|
||||
* und das fällt erst auf, wenn jemand es versucht.
|
||||
* Ohne twMerge gewaenne bei "p-2 p-4" die Reihenfolge im Stylesheet, nicht die
|
||||
* im Aufruf — eine Komponente liesse sich dann von aussen nicht mehr anpassen,
|
||||
* und das faellt erst auf, wenn jemand es versucht.
|
||||
*/
|
||||
export function cn(...classValues: ClassValue[]): string {
|
||||
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
|
||||
* vermischt, erzeugt Rückfragen bei jeder Kapazitätsplanung.
|
||||
* Bewusst mit binaeren Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB
|
||||
* vermischt, erzeugt Rueckfragen bei jeder Kapazitaetsplanung.
|
||||
*/
|
||||
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.
|
||||
if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) {
|
||||
return '—';
|
||||
@ -38,7 +38,7 @@ export function formatBytes(byteCount: number | null | undefined): string {
|
||||
);
|
||||
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.
|
||||
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").
|
||||
*
|
||||
* 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,
|
||||
* sagt alles.
|
||||
*/
|
||||
@ -97,8 +97,8 @@ export function formatRelativeTime(isoTimestamp: string | null | undefined): str
|
||||
return isInFuture ? 'gleich' : 'gerade eben';
|
||||
}
|
||||
|
||||
// Schwelle, Teiler und Einheit gehören zusammen und stehen deshalb in einem
|
||||
// Eintrag. Drei parallele Arrays wären die Sorte Code, bei der ein
|
||||
// Schwelle, Teiler und Einheit gehoeren zusammen und stehen deshalb in einem
|
||||
// Eintrag. Drei parallele Arrays waeren die Sorte Code, bei der ein
|
||||
// eingeschobener Eintrag still die Zuordnung verschiebt.
|
||||
const timeScales = [
|
||||
{ limit: 3600, divisor: 60, unit: 'minute' },
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/** Einstiegspunkt der Syncova-Oberfläche. */
|
||||
/** Einstiegspunkt der Syncova-Oberflaeche. */
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
@ -8,7 +8,7 @@ import './styles/theme.css';
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
// 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) {
|
||||
throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.');
|
||||
}
|
||||
|
||||
@ -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:
|
||||
* 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
|
||||
* leere Tabelle zeigt, lässt ihn glauben, es gäbe nichts zu sehen.
|
||||
* nur „in Arbeit" sagt, laesst den Bediener ratlos zurueck — und eine, die eine
|
||||
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen.
|
||||
*/
|
||||
|
||||
import { Construction } from 'lucide-react';
|
||||
@ -39,7 +39,7 @@ export function UnavailablePage({
|
||||
|
||||
<p className="mt-5 text-xs text-fg-muted">
|
||||
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten
|
||||
gefüllt. Eine Maske mit erfundenen Zahlen ließe sich im Betrieb
|
||||
gefuellt. Eine Maske mit erfundenen Zahlen liesse sich im Betrieb
|
||||
nicht von einer echten unterscheiden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Tests des Seitenverzeichnisses.
|
||||
*
|
||||
* Sie prüfen weniger die Technik als eine Zusage: Jede Seite aus dem
|
||||
* Implementierungsplan erscheint, und jede unfertige erklärt sich.
|
||||
* Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem
|
||||
* Implementierungsplan erscheint, und jede unfertige erklaert sich.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ALL_PAGES, findPage, mayViewPage } from './pages';
|
||||
|
||||
describe('Seitenverzeichnis', () => {
|
||||
it('nennt zu jeder nicht verfügbaren Seite einen Grund', () => {
|
||||
// Ein Bereich, der nur „nicht verfügbar" sagt, lässt den Bediener ratlos
|
||||
// zurück. Der Grund ist der eigentliche Inhalt dieser Seiten.
|
||||
it('nennt zu jeder nicht verfuegbaren Seite einen Grund', () => {
|
||||
// Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos
|
||||
// zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten.
|
||||
for (const pageDefinition of ALL_PAGES) {
|
||||
if (!pageDefinition.available) {
|
||||
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) {
|
||||
if (pageDefinition.available) {
|
||||
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
|
||||
@ -35,7 +35,7 @@ describe('Seitenverzeichnis', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// es.
|
||||
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');
|
||||
expect(usersPage).toBeDefined();
|
||||
|
||||
|
||||
@ -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
|
||||
* fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Einträge
|
||||
* in leere Masken führen, täuscht ihn vor. Jeder Eintrag erscheint, und ein
|
||||
* noch nicht verfügbarer nennt, was fehlt und was stattdessen gilt.
|
||||
* Der Grundsatz aus PROMPT.md §139 gilt unveraendert: Ein Menue, das nur die
|
||||
* fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Eintraege
|
||||
* in leere Masken fuehren, taeuscht ihn vor. Jeder Eintrag erscheint, und ein
|
||||
* noch nicht verfuegbarer nennt, was fehlt und was stattdessen gilt.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -50,28 +50,28 @@ export const SECTION_LABELS: ReadonlyArray<readonly [NavigationSection, string]>
|
||||
['verwaltung', 'Verwaltung'],
|
||||
];
|
||||
|
||||
/** Beschreibung einer Seite der Oberfläche. */
|
||||
/** Beschreibung einer Seite der Oberflaeche. */
|
||||
export interface PageDefinition {
|
||||
/** Stabiler Bezeichner; er steht auch im Pfad. */
|
||||
readonly id: string;
|
||||
/** Beschriftung im Menü. */
|
||||
/** Beschriftung im Menue. */
|
||||
readonly label: string;
|
||||
/** Bereich, unter dem die Seite erscheint. */
|
||||
readonly section: NavigationSection;
|
||||
/** Sinnbild im Menü. */
|
||||
/** Sinnbild im Menue. */
|
||||
readonly icon: LucideIcon;
|
||||
/**
|
||||
* Berechtigung, die der Zugriff verlangt.
|
||||
*
|
||||
* Rein zur Anzeige: Die Prüfung erfolgt immer serverseitig (PROMPT.md §42).
|
||||
* Ein Menüpunkt, den der Server ohnehin ablehnt, führt sonst nur zu einer
|
||||
* Fehlermeldung statt zu einer verständlichen Oberfläche.
|
||||
* Rein zur Anzeige: Die Pruefung erfolgt immer serverseitig (PROMPT.md §42).
|
||||
* Ein Menuepunkt, den der Server ohnehin ablehnt, fuehrt sonst nur zu einer
|
||||
* Fehlermeldung statt zu einer verstaendlichen Oberflaeche.
|
||||
*/
|
||||
readonly requiredPermission: string | null;
|
||||
/** Meldet, ob hinter der Seite ein Backend steht. */
|
||||
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
|
||||
* Auskunft kommt.
|
||||
@ -81,10 +81,10 @@ export interface PageDefinition {
|
||||
|
||||
/** Alle Seiten in Anzeigereihenfolge. */
|
||||
export const ALL_PAGES: readonly PageDefinition[] = [
|
||||
// --- Betrieb: was täglich beobachtet und bedient wird ---
|
||||
// --- Betrieb: was taeglich beobachtet und bedient wird ---
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Übersicht',
|
||||
label: 'Uebersicht',
|
||||
section: 'betrieb',
|
||||
icon: LayoutDashboard,
|
||||
requiredPermission: 'backups.read',
|
||||
@ -92,7 +92,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Sicherungsaufträge',
|
||||
label: 'Sicherungsauftraege',
|
||||
section: 'betrieb',
|
||||
icon: Clock,
|
||||
requiredPermission: 'jobs.read',
|
||||
@ -126,7 +126,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'verification',
|
||||
label: 'Prüfung',
|
||||
label: 'Pruefung',
|
||||
section: 'daten',
|
||||
icon: BadgeCheck,
|
||||
requiredPermission: 'verification.read',
|
||||
@ -168,7 +168,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: 'protected-systems',
|
||||
label: 'Geschützte Systeme',
|
||||
label: 'Geschuetzte Systeme',
|
||||
section: 'infrastruktur',
|
||||
icon: Activity,
|
||||
requiredPermission: 'providers.read',
|
||||
@ -247,8 +247,8 @@ export function findPage(pageIdentifier: string): PageDefinition | undefined {
|
||||
/**
|
||||
* Meldet, ob ein Benutzer eine Seite sehen darf.
|
||||
*
|
||||
* Ausschließlich zur Anzeige — sie verhindert Sackgassen im Menü. Die
|
||||
* verbindliche Prüfung findet auf dem Server statt (PROMPT.md §42).
|
||||
* Ausschliesslich zur Anzeige — sie verhindert Sackgassen im Menue. Die
|
||||
* verbindliche Pruefung findet auf dem Server statt (PROMPT.md §42).
|
||||
*/
|
||||
export function mayViewPage(
|
||||
page: PageDefinition,
|
||||
@ -258,7 +258,7 @@ export function mayViewPage(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Der Super-Administrator trägt einen Platzhalter statt aller Einzelrechte.
|
||||
// Der Super-Administrator traegt einen Platzhalter statt aller Einzelrechte.
|
||||
if (grantedPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -3,29 +3,29 @@
|
||||
*
|
||||
* Warum keine? Eine Anwendung mit einer Ebene flacher Seiten braucht kein
|
||||
* Routing-Framework; sie braucht Adressen, die man kopieren kann, und einen
|
||||
* funktionierenden Zurück-Knopf. Beides leistet die History-API in etwa fünfzig
|
||||
* Zeilen. Eine Abhängigkeit, die ein Vielfaches an Code mitbringt, wäre hier
|
||||
* Aufwand ohne Nutzen — und müsste dauerhaft gepflegt werden.
|
||||
* funktionierenden Zurueck-Knopf. Beides leistet die History-API in etwa fuenfzig
|
||||
* Zeilen. Eine Abhaengigkeit, die ein Vielfaches an Code mitbringt, waere hier
|
||||
* Aufwand ohne Nutzen — und muesste dauerhaft gepflegt werden.
|
||||
*
|
||||
* 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 { DEFAULT_PAGE_ID, findPage } from './pages';
|
||||
|
||||
/** Präfix aller Seitenpfade. */
|
||||
/** Praefix aller Seitenpfade. */
|
||||
const PAGE_PATH_PREFIX = '/';
|
||||
|
||||
/** Liest den Seitenbezeichner aus der aktuellen Adresse. */
|
||||
function readPageIdentifierFromLocation(): string {
|
||||
// noUncheckedIndexedAccess: Der erste Eintrag eines split-Ergebnisses ist für
|
||||
// TypeScript möglicherweise undefined, auch wenn er es nie ist.
|
||||
// noUncheckedIndexedAccess: Der erste Eintrag eines split-Ergebnisses ist fuer
|
||||
// TypeScript moeglicherweise undefined, auch wenn er es nie ist.
|
||||
const pathSegment = window.location.pathname.replace(PAGE_PATH_PREFIX, '').split('/')[0] ?? '';
|
||||
|
||||
if (pathSegment === '' || findPage(pathSegment) === undefined) {
|
||||
// Eine unbekannte Adresse führt zur Startseite statt zu einer leeren
|
||||
// Ansicht. Ein Tippfehler im Pfad ist kein Grund für eine kaputte Seite.
|
||||
// Eine unbekannte Adresse fuehrt zur Startseite statt zu einer leeren
|
||||
// Ansicht. Ein Tippfehler im Pfad ist kein Grund fuer eine kaputte Seite.
|
||||
return DEFAULT_PAGE_ID;
|
||||
}
|
||||
|
||||
@ -43,15 +43,15 @@ export interface CurrentPageNavigation {
|
||||
/**
|
||||
* 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
|
||||
* Link ließe sich nicht weitergeben.
|
||||
* Link liesse sich nicht weitergeben.
|
||||
*/
|
||||
export function useCurrentPage(): CurrentPageNavigation {
|
||||
const [currentPageId, setCurrentPageId] = useState<string>(readPageIdentifierFromLocation);
|
||||
|
||||
useEffect(() => {
|
||||
/** Übernimmt eine Navigation über den Zurück-Knopf des Browsers. */
|
||||
/** Uebernimmt eine Navigation ueber den Zurueck-Knopf des Browsers. */
|
||||
function handlePopState(): void {
|
||||
setCurrentPageId(readPageIdentifierFromLocation());
|
||||
}
|
||||
@ -66,8 +66,8 @@ export function useCurrentPage(): CurrentPageNavigation {
|
||||
return;
|
||||
}
|
||||
|
||||
// pushState statt einer Zuweisung an location: Ein vollständiges Neuladen
|
||||
// würfe die Sitzung im Speicher weg und ließe die Anwendung neu anlaufen.
|
||||
// pushState statt einer Zuweisung an location: Ein vollstaendiges Neuladen
|
||||
// wuerfe die Sitzung im Speicher weg und liesse die Anwendung neu anlaufen.
|
||||
window.history.pushState(null, '', PAGE_PATH_PREFIX + pageIdentifier);
|
||||
setCurrentPageId(pageIdentifier);
|
||||
}, []);
|
||||
|
||||
@ -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
|
||||
* `b5vnF8SMi` (tweakcn). Es ist bewusst kantig (Radius 0), schattenlos und
|
||||
* durchgehend in einer dicktengleichen Schrift — ein technischer, ruhiger Look.
|
||||
* Der Grundsatz aus PROMPT.md §106 gilt unveraendert und ist der Grund fuer den
|
||||
* Zuschnitt dieser Datei: Semantische Farben ausschliesslich fuer Status. Die
|
||||
* 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:
|
||||
*
|
||||
* 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.
|
||||
* Deshalb gibt es hier keine "Markenfarbe" als Flaeche. Der Akzent ist ein
|
||||
* gedecktes Blau und wird nur fuer Bedienelemente verwendet, nie fuer Zustaende.
|
||||
*/
|
||||
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* Geist Mono liegt **im Paket**, nicht auf einem fremden Host.
|
||||
*
|
||||
* Vite bündelt die drei Schnitte mit und vergibt ihnen einen Hash; ausgeliefert
|
||||
* 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 *));
|
||||
/* Dark Mode wird ueber ein Attribut geschaltet, nicht ueber die Medienabfrage
|
||||
* allein: Der Betreiber soll die Wahl haben, und eine Konsole, die nachts von
|
||||
* selbst umschaltet, waehrend jemand an einer Stoerung arbeitet, ist laestig. */
|
||||
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
|
||||
|
||||
@theme {
|
||||
/* Geist Mono für alles — die Wahl des Presets. In einer Konsole voller
|
||||
* Zahlen, Pfade und Kennungen ist das kein Stilbruch, sondern hilfreich:
|
||||
* Ziffern stehen untereinander, und ein Pfad bricht dort, wo er soll. */
|
||||
--font-sans: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
/* --- Schrift --- */
|
||||
--font-sans:
|
||||
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Radius 0 — das prägendste Merkmal des Presets. */
|
||||
--radius-sm: 0;
|
||||
--radius-md: 0;
|
||||
--radius-lg: 0;
|
||||
--radius-xl: 0;
|
||||
--radius-2xl: 0;
|
||||
|
||||
/* Das Preset ist schattenlos. Tiefe entsteht über Flächenkontrast. */
|
||||
--shadow-card: none;
|
||||
--shadow-raised: none;
|
||||
/* --- Radien --- */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Farben liegen als CSS-Variablen vor, damit dieselbe Komponente in beiden
|
||||
* Themen funktioniert, ohne dass jede Klasse eine dark:-Variante braucht.
|
||||
*/
|
||||
:root,
|
||||
[data-theme='light'],
|
||||
.light {
|
||||
/* --- Flächen --- */
|
||||
--surface-page: oklch(1 0 0);
|
||||
--surface-card: oklch(1 0 0);
|
||||
--surface-raised: oklch(1 0 0);
|
||||
--surface-sunken: oklch(0.9702 0 0);
|
||||
--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);
|
||||
[data-theme='light'] {
|
||||
/* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */
|
||||
--surface-page: 0 0% 98%;
|
||||
--surface-card: 0 0% 100%;
|
||||
--surface-raised: 0 0% 100%;
|
||||
--surface-sunken: 240 5% 96%;
|
||||
--surface-hover: 240 5% 94%;
|
||||
|
||||
--border-default: oklch(0.9219 0 0);
|
||||
--border-strong: oklch(0.709 0 0);
|
||||
--border-default: 240 6% 87%;
|
||||
--border-strong: 240 5% 78%;
|
||||
|
||||
--text-primary: oklch(0.1448 0 0);
|
||||
--text-secondary: oklch(0.5555 0 0);
|
||||
--text-muted: oklch(0.709 0 0);
|
||||
--text-inverted: oklch(0.9691 0.0161 293.7558);
|
||||
--text-primary: 240 10% 10%;
|
||||
--text-secondary: 240 4% 42%;
|
||||
--text-muted: 240 4% 56%;
|
||||
--text-inverted: 0 0% 100%;
|
||||
|
||||
/* Akzent = `primary` des Presets, nicht dessen `accent`.
|
||||
*
|
||||
* In shadcn ist `accent` die dezente Hover-Fläche und `primary` die Farbe der
|
||||
* Handlung. Die beiden zu verwechseln machte jede Schaltfläche grau. */
|
||||
--accent: oklch(0.4907 0.2699 292.5633);
|
||||
--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);
|
||||
/* Akzent — Bedienelemente, niemals Zustand. */
|
||||
--accent: 221 70% 45%;
|
||||
--accent-hover: 221 70% 39%;
|
||||
--accent-text: 0 0% 100%;
|
||||
--accent-subtle: 221 70% 96%;
|
||||
|
||||
/* --- Statusfarben (PROMPT §106) ---
|
||||
* Grün = Healthy, Gelb = Warning, Orange = High, Rot = Critical,
|
||||
* Blau/Neutral = Information. Rot stammt aus `destructive` des Presets. */
|
||||
--status-healthy: oklch(0.52 0.14 152);
|
||||
--status-healthy-bg: oklch(0.96 0.03 152);
|
||||
--status-warning: oklch(0.55 0.12 85);
|
||||
--status-warning-bg: oklch(0.96 0.04 85);
|
||||
--status-high: oklch(0.56 0.17 45);
|
||||
--status-high-bg: oklch(0.96 0.04 45);
|
||||
--status-critical: oklch(0.583 0.2387 28.4765);
|
||||
--status-critical-bg: oklch(0.96 0.03 28);
|
||||
--status-info: oklch(0.5405 0.2811 292.8523);
|
||||
--status-info-bg: oklch(0.96 0.02 293);
|
||||
--status-neutral: oklch(0.5555 0 0);
|
||||
--status-neutral-bg: oklch(0.9702 0 0);
|
||||
/* --- Statusfarben (PROMPT.md §106) ---
|
||||
* Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical,
|
||||
* Blau/Neutral = Information. Sonst nichts. */
|
||||
--status-healthy: 142 72% 29%;
|
||||
--status-healthy-bg: 142 60% 95%;
|
||||
--status-warning: 38 92% 33%;
|
||||
--status-warning-bg: 45 90% 94%;
|
||||
--status-high: 21 90% 40%;
|
||||
--status-high-bg: 24 90% 95%;
|
||||
--status-critical: 0 74% 42%;
|
||||
--status-critical-bg: 0 85% 96%;
|
||||
--status-info: 221 83% 45%;
|
||||
--status-info-bg: 214 95% 96%;
|
||||
--status-neutral: 240 4% 42%;
|
||||
--status-neutral-bg: 240 5% 95%;
|
||||
|
||||
--focus-ring: oklch(0.4907 0.2699 292.5633);
|
||||
--focus-ring: 221 83% 53%;
|
||||
}
|
||||
|
||||
[data-theme='dark'],
|
||||
.dark {
|
||||
--surface-page: oklch(0.1448 0 0);
|
||||
--surface-card: oklch(0.2046 0 0);
|
||||
--surface-raised: oklch(0.2046 0 0);
|
||||
--surface-sunken: oklch(0.2686 0 0);
|
||||
--surface-hover: oklch(0.2686 0 0);
|
||||
--surface-sidebar: oklch(0.2046 0 0);
|
||||
[data-theme='dark'] {
|
||||
--surface-page: 240 8% 8%;
|
||||
--surface-card: 240 7% 11%;
|
||||
--surface-raised: 240 6% 14%;
|
||||
--surface-sunken: 240 9% 6%;
|
||||
--surface-hover: 240 6% 17%;
|
||||
|
||||
/* Das Preset setzt im dunklen Thema reines Schwarz als Rand: Flächen trennen
|
||||
* sich über ihre Helligkeit, nicht über Linien. Übernommen. */
|
||||
--border-default: oklch(0 0 0);
|
||||
--border-strong: oklch(0.3 0 0);
|
||||
--border-default: 240 5% 22%;
|
||||
--border-strong: 240 5% 32%;
|
||||
|
||||
--text-primary: oklch(0.9851 0 0);
|
||||
--text-secondary: oklch(0.709 0 0);
|
||||
--text-muted: oklch(0.5555 0 0);
|
||||
--text-inverted: oklch(0.9691 0.0161 293.7558);
|
||||
--text-primary: 0 0% 96%;
|
||||
--text-secondary: 240 5% 68%;
|
||||
--text-muted: 240 4% 52%;
|
||||
--text-inverted: 240 10% 8%;
|
||||
|
||||
--accent: oklch(0.6006 0.2425 293.8885);
|
||||
--accent-hover: oklch(0.8101 0.1055 293.6966);
|
||||
--accent-text: oklch(0.1448 0 0);
|
||||
--accent-subtle: oklch(0.2739 0.0055 286.0326);
|
||||
--accent: 217 85% 62%;
|
||||
--accent-hover: 217 85% 70%;
|
||||
--accent-text: 240 10% 8%;
|
||||
--accent-subtle: 221 50% 18%;
|
||||
|
||||
/* Aufgehellt, sonst wären die Töne auf dunklem Grund nicht unterscheidbar —
|
||||
* und ein nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */
|
||||
--status-healthy: oklch(0.75 0.16 152);
|
||||
--status-healthy-bg: oklch(0.28 0.05 152);
|
||||
--status-warning: oklch(0.82 0.15 85);
|
||||
--status-warning-bg: oklch(0.29 0.05 85);
|
||||
--status-high: oklch(0.75 0.16 45);
|
||||
--status-high-bg: oklch(0.29 0.06 45);
|
||||
--status-critical: oklch(0.7022 0.1892 22.2279);
|
||||
--status-critical-bg: oklch(0.29 0.07 22);
|
||||
--status-info: oklch(0.8101 0.1055 293.6966);
|
||||
--status-info-bg: oklch(0.29 0.06 293);
|
||||
--status-neutral: oklch(0.709 0 0);
|
||||
--status-neutral-bg: oklch(0.2686 0 0);
|
||||
/* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des
|
||||
* hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein
|
||||
* nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */
|
||||
--status-healthy: 142 65% 55%;
|
||||
--status-healthy-bg: 142 45% 15%;
|
||||
--status-warning: 45 90% 60%;
|
||||
--status-warning-bg: 40 50% 15%;
|
||||
--status-high: 25 92% 60%;
|
||||
--status-high-bg: 22 55% 15%;
|
||||
--status-critical: 0 85% 65%;
|
||||
--status-critical-bg: 0 50% 16%;
|
||||
--status-info: 217 90% 65%;
|
||||
--status-info-bg: 220 50% 17%;
|
||||
--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`,
|
||||
* `text-fg` und `text-critical` unmittelbar funktionieren. */
|
||||
/* Die Variablen werden Tailwind als Farben bekannt gemacht, damit
|
||||
* bg-surface-card, text-status-critical usw. unmittelbar funktionieren. */
|
||||
@theme inline {
|
||||
--color-page: var(--surface-page);
|
||||
--color-card: var(--surface-card);
|
||||
--color-raised: var(--surface-raised);
|
||||
--color-sunken: var(--surface-sunken);
|
||||
--color-hover: var(--surface-hover);
|
||||
--color-sidebar: var(--surface-sidebar);
|
||||
--color-page: hsl(var(--surface-page));
|
||||
--color-card: hsl(var(--surface-card));
|
||||
--color-raised: hsl(var(--surface-raised));
|
||||
--color-sunken: hsl(var(--surface-sunken));
|
||||
--color-hover: hsl(var(--surface-hover));
|
||||
|
||||
--color-line: var(--border-default);
|
||||
--color-line-strong: var(--border-strong);
|
||||
--color-line: hsl(var(--border-default));
|
||||
--color-line-strong: hsl(var(--border-strong));
|
||||
|
||||
--color-fg: var(--text-primary);
|
||||
--color-fg-muted: var(--text-secondary);
|
||||
--color-fg-subtle: var(--text-muted);
|
||||
--color-fg-inverted: var(--text-inverted);
|
||||
--color-fg: hsl(var(--text-primary));
|
||||
--color-fg-muted: hsl(var(--text-secondary));
|
||||
--color-fg-subtle: hsl(var(--text-muted));
|
||||
--color-fg-inverted: hsl(var(--text-inverted));
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-fg: var(--accent-text);
|
||||
--color-accent-subtle: var(--accent-subtle);
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-hover: hsl(var(--accent-hover));
|
||||
--color-accent-fg: hsl(var(--accent-text));
|
||||
--color-accent-subtle: hsl(var(--accent-subtle));
|
||||
|
||||
--color-healthy: var(--status-healthy);
|
||||
--color-healthy-bg: var(--status-healthy-bg);
|
||||
--color-warning: var(--status-warning);
|
||||
--color-warning-bg: var(--status-warning-bg);
|
||||
--color-high: var(--status-high);
|
||||
--color-high-bg: var(--status-high-bg);
|
||||
--color-critical: var(--status-critical);
|
||||
--color-critical-bg: var(--status-critical-bg);
|
||||
--color-info: var(--status-info);
|
||||
--color-info-bg: var(--status-info-bg);
|
||||
--color-neutral: var(--status-neutral);
|
||||
--color-neutral-bg: var(--status-neutral-bg);
|
||||
--color-healthy: hsl(var(--status-healthy));
|
||||
--color-healthy-bg: hsl(var(--status-healthy-bg));
|
||||
--color-warning: hsl(var(--status-warning));
|
||||
--color-warning-bg: hsl(var(--status-warning-bg));
|
||||
--color-high: hsl(var(--status-high));
|
||||
--color-high-bg: hsl(var(--status-high-bg));
|
||||
--color-critical: hsl(var(--status-critical));
|
||||
--color-critical-bg: hsl(var(--status-critical-bg));
|
||||
--color-info: hsl(var(--status-info));
|
||||
--color-info-bg: hsl(var(--status-info-bg));
|
||||
--color-neutral: hsl(var(--status-neutral));
|
||||
--color-neutral-bg: hsl(var(--status-neutral-bg));
|
||||
|
||||
--color-ring: var(--focus-ring);
|
||||
--color-ring: hsl(var(--focus-ring));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border-default);
|
||||
border-color: hsl(var(--border-default));
|
||||
}
|
||||
|
||||
html {
|
||||
@ -231,22 +167,21 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--surface-page);
|
||||
color: var(--text-primary);
|
||||
background-color: hsl(var(--surface-page));
|
||||
color: hsl(var(--text-primary));
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole für
|
||||
* Tastaturbedienung unbrauchbar — und an einer Störung arbeitet niemand mit
|
||||
/* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole fuer
|
||||
* Tastaturbedienung unbrauchbar — und an einer Stoerung arbeitet niemand mit
|
||||
* der Maus. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline: 2px solid hsl(var(--focus-ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Bei einer dicktengleichen Schrift stehen Ziffern ohnehin untereinander;
|
||||
* die Klasse bleibt, damit die Aufrufstellen unverändert gelten. */
|
||||
/* Zahlen in Tabellen sollen untereinander stehen. */
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/** Gemeinsame Testeinrichtung für alle Frontend-Tests. */
|
||||
/** Gemeinsame Testeinrichtung fuer alle Frontend-Tests. */
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Typen des Syncova-API-Vertrags (SYNCOVA_API.md §1).
|
||||
*
|
||||
* Die Struktur spiegelt exakt die Antworthülle des Backends wider, damit
|
||||
* Abweichungen bereits beim Übersetzen auffallen.
|
||||
* Die Struktur spiegelt exakt die Antworthuelle des Backends wider, damit
|
||||
* Abweichungen bereits beim Uebersetzen auffallen.
|
||||
*/
|
||||
|
||||
/** Kontextinformationen jeder Antwort. */
|
||||
@ -11,13 +11,13 @@ export interface ResponseMeta {
|
||||
request_id: string;
|
||||
/** Aktuelle Seitennummer; nur bei paginierten Listen gesetzt. */
|
||||
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;
|
||||
/** Gesamtzahl verfügbarer Einträge; nur bei paginierten Listen gesetzt. */
|
||||
/** Gesamtzahl verfuegbarer Eintraege; nur bei paginierten Listen gesetzt. */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/** Hülle einer erfolgreichen Antwort. */
|
||||
/** Huelle einer erfolgreichen Antwort. */
|
||||
export interface SuccessResponse<TPayload> {
|
||||
/** Die eigentliche Nutzlast. */
|
||||
data: TPayload;
|
||||
@ -25,11 +25,11 @@ export interface SuccessResponse<TPayload> {
|
||||
meta: ResponseMeta;
|
||||
}
|
||||
|
||||
/** Fehlerkörper einer fehlgeschlagenen Antwort. */
|
||||
/** Fehlerkoerper einer fehlgeschlagenen Antwort. */
|
||||
export interface ErrorBody {
|
||||
/** Stabiler maschinenlesbarer Fehlercode, z. B. REPOSITORY_UNAVAILABLE. */
|
||||
code: string;
|
||||
/** Verständliche Erklärung des Fehlers. */
|
||||
/** Verstaendliche Erklaerung des Fehlers. */
|
||||
message: string;
|
||||
/** Optionale unbedenkliche Zusatzinformationen. */
|
||||
details?: Record<string, unknown>;
|
||||
@ -37,7 +37,7 @@ export interface ErrorBody {
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
/** Hülle einer fehlgeschlagenen Antwort. */
|
||||
/** Huelle einer fehlgeschlagenen Antwort. */
|
||||
export interface ErrorResponse {
|
||||
/** Beschreibung des aufgetretenen Fehlers. */
|
||||
error: ErrorBody;
|
||||
@ -50,25 +50,25 @@ export interface ErrorResponse {
|
||||
*/
|
||||
export type HealthStatus = 'healthy' | 'degraded' | 'warning' | 'critical' | 'offline';
|
||||
|
||||
/** Ergebnis der Zustandsprüfung einer einzelnen Komponente. */
|
||||
/** Ergebnis der Zustandspruefung einer einzelnen Komponente. */
|
||||
export interface ComponentHealth {
|
||||
/** Ermittelter Zustand der Komponente. */
|
||||
status: HealthStatus;
|
||||
/** Verständliche Erklärung des Zustands. */
|
||||
/** Verstaendliche Erklaerung des Zustands. */
|
||||
message?: string;
|
||||
/** Empfohlener nächster Schritt bei Problemen. */
|
||||
/** Empfohlener naechster Schritt bei Problemen. */
|
||||
recommended_action?: string;
|
||||
/** Dauer der Prüfung in Millisekunden. */
|
||||
/** Dauer der Pruefung in Millisekunden. */
|
||||
latency_ms: number;
|
||||
/** Zeitpunkt der Prüfung in UTC (ISO 8601). */
|
||||
/** Zeitpunkt der Pruefung in UTC (ISO 8601). */
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
/** Antwort von GET /api/v1/health. */
|
||||
export interface SystemHealth {
|
||||
/** Schlechtester Zustand über alle Komponenten hinweg. */
|
||||
/** Schlechtester Zustand ueber alle Komponenten hinweg. */
|
||||
status: HealthStatus;
|
||||
/** Ergebnis je geprüfter Komponente. */
|
||||
/** Ergebnis je geprueter Komponente. */
|
||||
components: Record<string, ComponentHealth>;
|
||||
/** Laufende Programmversion des Backends. */
|
||||
version: string;
|
||||
|
||||
@ -5,7 +5,7 @@ export type UserStatus = 'active' | 'disabled' | 'locked';
|
||||
|
||||
/** Ein Benutzerkonto. */
|
||||
export interface CurrentUser {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
/** Oeffentlicher Bezeichner. */
|
||||
id: string;
|
||||
/** Anmeldename. */
|
||||
username: string;
|
||||
@ -13,7 +13,7 @@ export interface CurrentUser {
|
||||
email?: string;
|
||||
/** Zustand des Kontos. */
|
||||
status: UserStatus;
|
||||
/** Meldet, ob ein bestätigter zweiter Faktor vorliegt. */
|
||||
/** Meldet, ob ein bestaetigter zweiter Faktor vorliegt. */
|
||||
mfa_enabled: boolean;
|
||||
/** Zugewiesene Rollen. */
|
||||
roles: string[];
|
||||
@ -27,7 +27,7 @@ export interface CurrentUser {
|
||||
export interface TokenPair {
|
||||
/** Kurzlebiges Zugriffstoken. */
|
||||
access_token: string;
|
||||
/** Länger gültiges Erneuerungstoken. */
|
||||
/** Laenger gueltiges Erneuerungstoken. */
|
||||
refresh_token: string;
|
||||
/** Ablaufzeit des Zugriffstokens. */
|
||||
access_expires_at: string;
|
||||
@ -42,7 +42,7 @@ export interface TokenPair {
|
||||
* der zweite Faktor (mfa_required gesetzt).
|
||||
*/
|
||||
export interface LoginResult {
|
||||
/** Meldet, dass noch ein zweiter Faktor nötig ist. */
|
||||
/** Meldet, dass noch ein zweiter Faktor noetig ist. */
|
||||
mfa_required: boolean;
|
||||
/** Benennt die offene MFA-Herausforderung. */
|
||||
challenge_id?: string;
|
||||
|
||||
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:
|
||||
|
||||
@ -187,17 +187,12 @@ ExecStart=/opt/syncova/bin/syncova-api
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Härtung: Der Dienst braucht Netz, sein Repository und eine Fläche für
|
||||
# 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.
|
||||
# Härtung: Der Dienst braucht Netz und sein Repository, sonst nichts.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/srv/syncova-repository /srv/syncova-restore
|
||||
ReadWritePaths=/srv/syncova-repository
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
@ -290,25 +285,6 @@ sudo firewall-cmd --permanent --add-service=https # firewalld
|
||||
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
|
||||
|
||||
Ein Repository entsteht **auf einem Datenträger**, nicht in einer
|
||||
|
||||
@ -51,44 +51,9 @@ Original nicht mehr.
|
||||
|
||||
## 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
|
||||
|
||||
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen*.
|
||||
|
||||
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.**
|
||||
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen* → Zielpfad angeben.
|
||||
|
||||
### Ü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
|
||||
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'
|
||||
```
|
||||
`path_prefix` beschränkt auf einen Teilbaum. Ohne ihn kommt alles zurück.
|
||||
|
||||
Fortschritt:
|
||||
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
- **Tailwind v4 und Radix-Primitive** nach shadcn-Muster. Alles gebündelt; die
|
||||
CSP der Auslieferung lässt externe Ressourcen ohnehin nicht zu.
|
||||
- **Farben als CSS-Variablen**, damit dieselbe Komponente in beiden Themen
|
||||
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
|
||||
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
|
||||
@ -168,27 +138,6 @@ Presets fasst keine einzige Komponente an.
|
||||
Fehlercode. Sie kennt den Einzelfall, und diese Genauigkeit ist mehr wert.
|
||||
- **Berechtigungen im Menü sind Anzeige, keine Sicherung.** Sie verhindern
|
||||
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
|
||||
nicht funktioniert".
|
||||
|
||||
@ -222,8 +171,6 @@ Konsole macht:
|
||||
Listen.
|
||||
- **Die Suche im Ereignisprotokoll filtert im Browser** über die letzten 100
|
||||
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`,
|
||||
ist aber **auch serverseitig nicht umgesetzt** — es fehlt nicht nur die
|
||||
Anbindung. Laufende Sicherungen und Wiederherstellungen aktualisieren sich
|
||||
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -37,5 +37,3 @@ e559f3d4443b9057a3f40a685141d92f3e05817da725857d2f0cba231aef3035 000005_restore
|
||||
e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql
|
||||
f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql
|
||||
ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql
|
||||
f25274d148996038f5716f315470c4f9371629ad734a03f0ee49ee760ea4c2d5 000014_backup_mode.up.sql
|
||||
9a48a9de411d6af07461c236967525469c73d820a4d282fab8a61ed6eb56f9f3 000014_backup_mode.down.sql
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -423,13 +423,6 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
|
||||
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)
|
||||
|
||||
startTime := time.Now().UTC()
|
||||
@ -446,7 +439,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
|
||||
EncryptionEnabled: executor.options.SecretStore != nil,
|
||||
ChainID: chainIdentifier.String(),
|
||||
CreatedByVersion: executor.options.CreatedByVersion,
|
||||
Incremental: parentBackupInRepository != "" && !forceFullBackup,
|
||||
Incremental: parentBackupInRepository != "",
|
||||
ParentBackupID: parentBackupInRepository,
|
||||
BandwidthLimiter: backupRequest.Limiter,
|
||||
}
|
||||
@ -698,41 +691,3 @@ func hasServerSideSource(executionJob *jobs.Job) bool {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -82,22 +82,6 @@ type JobSource struct {
|
||||
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.
|
||||
type Job struct {
|
||||
// ID ist der öffentliche Bezeichner.
|
||||
@ -126,18 +110,6 @@ type Job struct {
|
||||
RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
|
||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
|
||||
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 int `json:"max_concurrency"`
|
||||
// RetryPolicy beschreibt das Wiederholungsverhalten.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user