syncova-backup/apps/web/src/features/jobs/jobsApi.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

272 lines
8.2 KiB
TypeScript

/**
* API-Anbindung der Sicherungsaufträge (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.
*/
import { requestApi } from '../../api/client';
/** Art eines Zeitplans (packages/scheduler). */
export type ScheduleType =
| 'manual'
| 'interval'
| 'hourly'
| 'daily'
| 'weekly'
| 'monthly'
| 'cron';
/** Dringlichkeit eines Auftrags. */
export type JobPriority = 'critical' | 'high' | 'normal' | 'low';
/** Art einer Sicherungsquelle. */
export type SourceType =
| 'filesystem'
| 'proxmox_vm'
| 'proxmox_container'
| 'windows_system'
| 'linux_system';
/** Zeitplan im Anfrage- und Antwortformat der API. */
export interface ScheduleDescriptor {
/** Art des Zeitplans. */
type: ScheduleType;
/** Abstand in Sekunden bei type=interval. */
interval_seconds?: number;
/** Uhrzeit im Format HH:MM. */
time?: string;
/** Wochentage, 0 = Sonntag. */
weekdays?: number[];
/** Tage des Monats; -1 bedeutet Monatsletzter. */
month_days?: number[];
/** Cron-Ausdruck bei type=cron. */
cron_expression?: string;
/** Zeitzone der Uhrzeiten. */
time_zone?: string;
}
/** Quelle im Anfrage- und Antwortformat der API. */
export interface SourceDescriptor {
/** Art der Quelle. */
type: SourceType;
/** Kennung innerhalb ihrer Art, etwa ein Pfad. */
id: string;
/** Sprechende Bezeichnung. */
name?: string;
/** Einzuschließende Muster. */
include_patterns?: string[];
/** Auszuschließende Muster. */
exclude_patterns?: string[];
}
/** Rumpf beim Anlegen eines Auftrags. */
export interface CreateJobRequest {
/** Eindeutige Bezeichnung. */
name: string;
/** Erläuterung des Zwecks. */
description?: string;
/** Dringlichkeit. */
priority?: JobPriority;
/** Zeitplan. */
schedule: ScheduleDescriptor;
/** Zu sichernde Quellen. */
sources: SourceDescriptor[];
/** Ziel-Repository. */
repository_id: string;
/** Zulässiger Datenverlust in Sekunden. */
rpo_seconds?: number;
/** Zulässige Wiederherstellungsdauer in Sekunden. */
rto_seconds?: number;
/** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number;
}
/** Auftrag in der Antwort der API. */
export interface BackupJob {
/** Öffentlicher Bezeichner. */
id: string;
/** Bezeichnung. */
name: string;
/** Erläuterung. */
description?: string;
/** Zustand. */
status: string;
/** Dringlichkeit. */
priority: JobPriority;
/** Zeitplan. */
schedule: ScheduleDescriptor;
/** Erklärung des Zeitplans in einem Satz. */
schedule_description: string;
/** Quellen. */
sources: SourceDescriptor[];
/** Ziel-Repository. */
repository_id: string;
/** Nächster Zeitpunkt in UTC. */
next_run_at?: string;
/** Beginn des letzten Laufs in UTC. */
last_run_at?: string;
/** Ausgang des letzten Laufs. */
last_outcome?: string;
/** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number;
}
/** Sicherungsziel in der Antwort der API. */
export interface BackupRepository {
/** Öffentlicher Bezeichner. */
id: string;
/** Sprechende Bezeichnung. */
name: string;
/** Ablageart. */
repository_type: string;
/** Pfad oder Adresse der Ablage. */
location: string;
/** Betriebszustand. */
status: string;
/**
* 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.
*/
accepts_backups: boolean;
/** Meldet den gehärteten Modus. */
hardened: boolean;
}
/**
* Lädt die bekannten Sicherungsziele.
*
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
* exactOptionalPropertyTypes ist ein ausdrückliches 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. */
export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> {
return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {});
}
/** Legt einen Sicherungsauftrag an. */
export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob> {
return requestApi<BackupJob>('/jobs', { method: 'POST', body: jobRequest });
}
// ---------------------------------------------------------------------------
// Bedienung der Aufträge
// ---------------------------------------------------------------------------
//
// Bis hierher konnte die Oberfläche einen Auftrag anlegen und ansehen — mehr
// nicht. Eine Liste, deren Einträge sich nicht bedienen lassen, ist ein
// Bericht, keine Konsole.
/** Ein einzelner Lauf eines Auftrags. */
export interface BackupJobRun {
/** Öffentlicher Bezeichner. */
id: string;
/** Zugehöriger Auftrag. */
job_id: string;
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
status: string;
/** Beginn in UTC. */
started_at?: string;
/** Ende in UTC. */
completed_at?: string;
/** Gelesene Bytes. */
bytes_processed?: number;
/** Tatsächlich abgelegte Bytes nach Deduplizierung. */
bytes_written?: number;
/** Anzahl erfasster Dateien. */
files_processed?: number;
/**
* Übergangene Objekte.
*
* Größer als null bedeutet Teilfehler — die Datenbank lässt
* "erfolgreich mit übergangenen Objekten" per CHECK gar nicht zu.
*/
files_skipped?: number;
/** Durchsatz in Byte je Sekunde. */
throughput_bps?: number;
/** Fehlercode bei nicht erfolgreichem Ausgang. */
error_code?: string;
/** Fehlermeldung. */
error_message?: string;
/** Klasse des Fehlers: transient, permanent, integrity, auth, … */
error_class?: string;
}
/** Lädt einen einzelnen Auftrag. */
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
return requestApi<BackupJob>(
`/jobs/${encodeURIComponent(jobIdentifier)}`,
abortSignal ? { signal: abortSignal } : {},
);
}
/** Lädt die Laufhistorie eines Auftrags. */
export async function listJobRuns(
jobIdentifier: string,
abortSignal?: AbortSignal,
): Promise<BackupJobRun[]> {
return requestApi<BackupJobRun[]>(
`/jobs/${encodeURIComponent(jobIdentifier)}/runs?page_size=50`,
abortSignal ? { signal: abortSignal } : {},
);
}
/**
* Stößt einen Lauf an.
*
* Die Antwort ist 202, nicht 201: Der Lauf ist eingereiht, die Sicherung hat
* nicht begonnen. Ein zweiter Anstoß bei laufendem Auftrag ergibt 409 — das
* ist eine Auskunft, kein Fehler, und wird in der Oberfläche als solche
* gezeigt.
*/
export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> {
return requestApi<{ run_id?: string }>(`/jobs/${encodeURIComponent(jobIdentifier)}/run`, {
method: 'POST',
idempotencyKey: true,
});
}
/** Hält einen Auftrag an. Laufende Sicherungen bleiben unberührt. */
export async function pauseJob(jobIdentifier: string): Promise<BackupJob> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
method: 'POST',
});
}
/** Nimmt einen angehaltenen Auftrag wieder auf. */
export async function resumeJob(jobIdentifier: string): Promise<BackupJob> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/resume`, {
method: 'POST',
});
}
/**
* Löscht einen Auftrag.
*
* Die bereits erzeugten Wiederherstellungspunkte bleiben bestehen — sie
* gehören zum Repository, nicht zum Auftrag. Das muss die Oberfläche sagen,
* sonst löscht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
*/
export async function deleteJob(jobIdentifier: string): Promise<void> {
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {
method: 'DELETE',
idempotencyKey: true,
});
}
/** Bricht einen laufenden Sicherungslauf ab. */
export async function cancelJobRun(runIdentifier: string): Promise<void> {
return requestApi<void>(`/backup-runs/${encodeURIComponent(runIdentifier)}/cancel`, {
method: 'POST',
});
}