syncova-backup/apps/web/src/features/dashboard/dashboardApi.ts
Jerrit Fritzsche b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

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

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

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

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

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

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

82 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:42:37 +02:00

138 lines
4.4 KiB
TypeScript

/**
* Zugriff auf die Übersicht und die Wiederherstellungspunkte.
*/
import { requestApi } from '../../api/client';
/** Statusfarbe einer Kennzahl. */
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
/** Eine Kennzahl der Übersicht. */
export interface DashboardWidget {
/** Maschinenlesbarer Bezeichner. */
readonly key: string;
/** Überschrift. */
readonly title: string;
/** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean;
/** Erklärt eine fehlende Datengrundlage. */
readonly unavailable_reason?: string;
/** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */
readonly value?: number;
/** Einheit der Hauptzahl. */
readonly unit?: string;
/** Erläuterung in einem Satz. */
readonly detail?: string;
/** Statusfarbe. */
readonly severity: WidgetSeverity;
/** Ergänzende Einzelwerte. */
readonly breakdown?: Record<string, number>;
}
/** Die Übersicht der Anlage. */
export interface Dashboard {
/** Kennzahlen in Anzeigereihenfolge. */
readonly widgets: readonly DashboardWidget[];
/** Zahl der Kennzahlen mit Datengrundlage. */
readonly available_count: number;
/** Zeitpunkt der Erhebung in UTC. */
readonly generated_at: string;
}
/** Ein Wiederherstellungspunkt in der Übersicht. */
export interface RecoveryPoint {
/** Öffentlicher Bezeichner. */
readonly id: string;
/** Repository des Punktes. */
readonly repository_id: string;
/** Name des Repositorys. */
readonly repository_name: string;
/** Erzeugender Auftrag. */
readonly job_id?: string;
/** Name des Auftrags. */
readonly job_name?: string;
/** Kennung innerhalb des Repositorys. */
readonly backup_id_in_repository: string;
/** Art des Backups. */
readonly backup_type: string;
/** Zustand. */
readonly status: string;
/** Erreichte Konsistenz. */
readonly consistency_level?: string;
/** Menge der Ursprungsdaten. */
readonly logical_bytes: number;
/** Abgelegte Datenmenge. */
readonly encrypted_bytes: number;
/** Ende in UTC. */
readonly completed_at?: string;
/** Objektive Einstufung. */
readonly classification?: string;
/** Letzte Integritätsprüfung in UTC. */
readonly last_verified_at?: string;
/** Letzter Wiederherstellungstest in UTC. */
readonly last_restore_test_at?: string;
/** Zuletzt berechnete Bewertung in Prozent. */
readonly assurance_score?: number;
/** Ende der Aufbewahrungspflicht in UTC. */
readonly immutable_until?: string;
/** Meldet einen unbefristeten Schutz. */
readonly legal_hold: boolean;
/** Meldet, ob eine Löschung derzeit unzulässig ist. */
readonly is_protected: boolean;
/** Zeitpunkt der Löschung in UTC. */
readonly deleted_at?: string;
/** Begründung der Löschung. */
readonly deletion_reason?: string;
}
/** Filter der Liste von Wiederherstellungspunkten. */
export interface RecoveryPointFilter {
/** Beschränkung auf ein Repository. */
readonly repositoryId?: string;
/** Beschränkung auf eine Einstufung. */
readonly classification?: string;
/** Nimmt gelöschte Punkte auf. */
readonly includeDeleted?: boolean;
/** Beschränkt auf geschützte Punkte. */
readonly onlyProtected?: boolean;
/** Angeforderte Seite. */
readonly page?: number;
}
/** Lädt die Übersicht. */
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
}
/** Lädt eine Seite von Wiederherstellungspunkten. */
export async function fetchRecoveryPoints(
filter: RecoveryPointFilter = {},
abortSignal?: AbortSignal,
): Promise<readonly RecoveryPoint[]> {
const queryParameters = new URLSearchParams();
if (filter.repositoryId !== undefined && filter.repositoryId !== '') {
queryParameters.set('repository_id', filter.repositoryId);
}
if (filter.classification !== undefined && filter.classification !== '') {
queryParameters.set('classification', filter.classification);
}
if (filter.includeDeleted === true) {
queryParameters.set('include_deleted', 'true');
}
if (filter.onlyProtected === true) {
queryParameters.set('only_protected', 'true');
}
queryParameters.set('page', String(filter.page ?? 1));
queryParameters.set('page_size', '50');
return requestApi<readonly RecoveryPoint[]>(
`/backups?${queryParameters.toString()}`,
abortSignal ? { signal: abortSignal } : {},
);
}