feat(v2): complete v2 reimplementation

This commit is contained in:
2026-07-31 13:33:39 +02:00
parent 396219e776
commit bd107d6a30
137 changed files with 20737 additions and 155 deletions
+567
View File
@@ -0,0 +1,567 @@
/* eslint-disable */
/*
* Generated by tools/generate_api_client.py from openapi/v2.json.
* Do not edit this file directly. Run `npm --prefix frontend run api:generate`.
*/
export interface Components {
schemas: {
AuditList: { items : Array<Components['schemas']["AuditSummary"]>; next_cursor : string | null };
AuditSummary: { action : string; created_at : string; details : Record<string, unknown>; id : string; outcome : string; request_id : string; resource_id : string | null; resource_type : string };
AuthenticatedUser: { id : string; username : string };
BackupDeletePreview: { backup_id : string; destructive_action : string; eligible : boolean; reason : string | null };
BackupList: { items : Array<Components['schemas']["BackupSummary"]> };
BackupSummary: { created_at : string; execution_id : string; id : string; integrity : string; logical_bytes : number; manifest_id : string; pinned : boolean; stored_bytes : number; tombstoned_at : string | null };
EmailSettingsInput: { host : string; max_attempts?: number; password : string; port?: number; rate_limit_per_minute?: number; sender : string; username : string };
ExecutionList: { items : Array<Components['schemas']["ExecutionSummary"]> };
ExecutionSummary: { attempt : number; id : string; progress : Record<string, unknown>; reason_code : string | null; revision : number; state : string };
HTTPValidationError: { detail?: Array<Components['schemas']["ValidationError"]> };
JobInput: { allow_empty?: boolean; enabled?: boolean; exclusions?: Array<string>; name : string; repository_id : string; requested_mode?: string; retention?: Record<string, unknown>; source_id : string };
JobList: { items : Array<Components['schemas']["JobSummary"]> };
JobSummary: { enabled : boolean; id : string; name : string; repository_id : string; requested_mode : string; schedule : Components['schemas']["ScheduleSummary"] | null; source_id : string; state : string };
LocalSourceInput: { kind : string; name : string; public_config : Record<string, unknown> };
LoginInput: { password : string; username : string };
NotificationAttemptList: { items : Array<Components['schemas']["NotificationAttemptSummary"]> };
NotificationAttemptSummary: { completed_at : string | null; diagnostic : string | null; number : number; outcome : string; response_class : string | null; started_at : string };
NotificationDeliveryList: { items : Array<Components['schemas']["NotificationDeliverySummary"]> };
NotificationDeliverySummary: { attempt_count : number; due_at : string; event_id : string; id : string; response_class : string | null; response_summary : string | null; state : string; subscription_id : string; terminal_reason : string | null };
NotificationSubscriptionInput: { channel : "webhook" | "email"; destination : Record<string, unknown>; event_filters : Array<string>; rate_limit_per_minute?: number; signing_secret?: string | null };
NotificationSubscriptionList: { items : Array<Components['schemas']["NotificationSubscriptionSummary"]> };
NotificationSubscriptionPatch: { destination?: Record<string, unknown> | null; event_filters?: Array<string> | null; rate_limit_per_minute?: number | null; state?: "active" | "disabled" | "archived" | null };
NotificationSubscriptionSummary: { channel : string; created_at : string; destination : Record<string, unknown>; event_filters : Array<string>; id : string; rate_limit_per_minute : number; revision : number; state : string; updated_at : string };
RecoveryStatus: { encrypted_repository_count : number; recovery_mode : string; runbook : string };
RepositoryInput: { compression?: string; encryption?: string; name : string; relative_path : string };
RepositoryList: { items : Array<Components['schemas']["RepositorySummary"]> };
RepositoryPatch: { compression?: string | null; encryption?: string | null };
RepositorySummary: { compression : string; encryption : string; format_version : number; id : string; name : string; state : string };
RestoreInput: { destination : string; dry_run?: boolean; overwrite_policy?: string; selection?: Array<string> };
SSHSourceInput: { kind : string; name : string; private_key_secret_id : string; public_config : Components['schemas']["SSHSourcePublicConfig"] };
SSHSourcePublicConfig: { host_key : string; hostname : string; port : number; root : string; username : string };
ScheduleInput: { cron : string; enabled?: boolean; misfire_grace_seconds?: number; timezone : string };
ScheduleSummary: { cron : string; enabled : boolean; id : string; last_enqueue_outcome : string | null; next_nominal_at : string | null; timezone : string };
SecretInput: { purpose : string; value : string };
SessionUser: { id : string; state : string; username : string };
SetupInput: { bootstrap_secret?: string | null; password : string; username : string };
SigningKeyRotateInput: { overlap_seconds?: number; secret : string };
SourceList: { items : Array<Components['schemas']["SourceSummary"]> };
SourceSummary: { id : string; kind : string; name : string; public_config : Record<string, unknown>; state : string };
TokenInput: { expires_at?: string | null; scopes : Array<string> };
UserPatch: { state : string };
ValidationError: { ctx?: Record<string, unknown>; input?: unknown; loc : Array<string | number>; msg : string; type : string };
};
}
export type CreateSecretParams = { body : Components['schemas']["SecretInput"] };
export type GetUserParams = { path: { user_id: string } };
export type PatchUserParams = { path: { user_id: string }; body : Components['schemas']["UserPatch"] };
export type ListAuditParams = { query?: { limit?: number; cursor?: string | null } };
export type LoginParams = { body : Components['schemas']["LoginInput"] };
export type CreateTokenParams = { body : Components['schemas']["TokenInput"] };
export type RevokeTokenParams = { path: { token_id: string } };
export type GetBackupParams = { path: { backup_id: string } };
export type BackupDeletePreviewParams = { path: { backup_id: string } };
export type CreateRestoreParams = { path: { backup_id: string }; body : Components['schemas']["RestoreInput"] };
export type VerifyBackupParams = { path: { backup_id: string } };
export type GetExecutionParams = { path: { execution_id: string } };
export type CancelExecutionParams = { path: { execution_id: string } };
export type ExecutionEventsParams = { path: { execution_id: string } };
export type RetryExecutionParams = { path: { execution_id: string } };
export type CreateJobParams = { body : Components['schemas']["JobInput"] };
export type EnqueueExecutionParams = { path: { job_id: string } };
export type DeleteScheduleParams = { path: { job_id: string } };
export type GetScheduleParams = { path: { job_id: string } };
export type PatchScheduleParams = { path: { job_id: string }; body : Components['schemas']["ScheduleInput"] };
export type CreateScheduleParams = { path: { job_id: string }; body : Components['schemas']["ScheduleInput"] };
export type ListNotificationDeliveriesParams = { query?: { limit?: number } };
export type ListNotificationAttemptsParams = { path: { delivery_id: string } };
export type RetryNotificationDeliveryParams = { path: { delivery_id: string } };
export type PutNotificationEmailSettingsParams = { body : Components['schemas']["EmailSettingsInput"] };
export type CreateNotificationSubscriptionParams = { body : Components['schemas']["NotificationSubscriptionInput"] };
export type GetNotificationSubscriptionParams = { path: { subscription_id: string } };
export type PatchNotificationSubscriptionParams = { path: { subscription_id: string }; body : Components['schemas']["NotificationSubscriptionPatch"] };
export type RotateNotificationSigningKeyParams = { path: { subscription_id: string }; body : Components['schemas']["SigningKeyRotateInput"] };
export type TestNotificationSubscriptionParams = { path: { subscription_id: string } };
export type CreateRepositoryParams = { body : Components['schemas']["RepositoryInput"] };
export type GetRepositoryParams = { path: { repository_id: string } };
export type PatchRepositoryParams = { path: { repository_id: string }; body : Components['schemas']["RepositoryPatch"] };
export type InspectRepositoryEndpointParams = { path: { repository_id: string } };
export type GetRestoreParams = { path: { restore_id: string } };
export type SetupParams = { body : Components['schemas']["SetupInput"] };
export type CreateSourceParams = { body : Components['schemas']["LocalSourceInput"] | Components['schemas']["SSHSourceInput"] };
export type ArchiveSourceParams = { path: { source_id: string } };
export type ProbeSourceParams = { path: { source_id: string } };
export type RequestOptions = Omit<RequestInit, "body">;
export type Problem = {
type: string;
title: string;
status: number;
detail: string;
instance: string;
code: string;
};
export class ApiError extends Error {
readonly status: number;
readonly problem?: Problem;
constructor(status: number, problem?: Problem) {
super(problem?.detail ?? `Request failed with status ${status}.`);
this.name = "ApiError";
this.status = status;
this.problem = problem;
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
function appendQuery(search: URLSearchParams, query: Record<string, unknown> | undefined): void {
if (!query) return;
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
for (const item of Array.isArray(value) ? value : [value]) search.append(key, String(item));
}
}
async function request<T>(
url: URL,
method: string,
options: RequestOptions,
body?: unknown,
): Promise<T> {
const headers = new Headers(options.headers);
if (body !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(url, {
...options,
method,
headers,
credentials: options.credentials ?? "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 204) return undefined as T;
const contentType = response.headers.get("content-type") ?? "";
const isJson = contentType.includes("application/json")
|| contentType.includes("application/problem+json");
const payload: unknown = isJson ? await response.json() : undefined;
if (!response.ok) throw new ApiError(response.status, payload as Problem | undefined);
return payload as T;
}
export class BackupToolClient {
constructor(readonly baseUrl = window.location.origin) {}
async listSecrets(options: RequestOptions = {}): Promise<Array<Record<string, unknown>>> {
const url = new URL("/api/v2/admin/secrets", this.baseUrl);
return request<Array<Record<string, unknown>>>(
url, "GET", options
);
}
async createSecret(params: CreateSecretParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/admin/secrets", this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async getUser(params: GetUserParams, options: RequestOptions = {}): Promise<Record<string, string>> {
const url = new URL("/api/v2/admin/users/{user_id}".replace("{user_id}", encodeURIComponent(String(params.path.user_id))), this.baseUrl);
return request<Record<string, string>>(
url, "GET", options
);
}
async patchUser(params: PatchUserParams, options: RequestOptions = {}): Promise<Record<string, string>> {
const url = new URL("/api/v2/admin/users/{user_id}".replace("{user_id}", encodeURIComponent(String(params.path.user_id))), this.baseUrl);
return request<Record<string, string>>(
url, "PATCH", options, params.body
);
}
async listAudit(params: ListAuditParams, options: RequestOptions = {}): Promise<Components['schemas']["AuditList"]> {
const url = new URL("/api/v2/audit", this.baseUrl);
appendQuery(url.searchParams, params.query);
return request<Components['schemas']["AuditList"]>(
url, "GET", options
);
}
async login(params: LoginParams, options: RequestOptions = {}): Promise<Components['schemas']["AuthenticatedUser"]> {
const url = new URL("/api/v2/auth/login", this.baseUrl);
return request<Components['schemas']["AuthenticatedUser"]>(
url, "POST", options, params.body
);
}
async logout(options: RequestOptions = {}): Promise<void> {
const url = new URL("/api/v2/auth/logout", this.baseUrl);
return request<void>(
url, "POST", options
);
}
async getSession(options: RequestOptions = {}): Promise<Components['schemas']["SessionUser"]> {
const url = new URL("/api/v2/auth/session", this.baseUrl);
return request<Components['schemas']["SessionUser"]>(
url, "GET", options
);
}
async createToken(params: CreateTokenParams, options: RequestOptions = {}): Promise<unknown> {
const url = new URL("/api/v2/auth/tokens", this.baseUrl);
return request<unknown>(
url, "POST", options, params.body
);
}
async revokeToken(params: RevokeTokenParams, options: RequestOptions = {}): Promise<void> {
const url = new URL("/api/v2/auth/tokens/{token_id}".replace("{token_id}", encodeURIComponent(String(params.path.token_id))), this.baseUrl);
return request<void>(
url, "DELETE", options
);
}
async listBackups(options: RequestOptions = {}): Promise<Components['schemas']["BackupList"]> {
const url = new URL("/api/v2/backups", this.baseUrl);
return request<Components['schemas']["BackupList"]>(
url, "GET", options
);
}
async getBackup(params: GetBackupParams, options: RequestOptions = {}): Promise<Components['schemas']["BackupSummary"]> {
const url = new URL("/api/v2/backups/{backup_id}".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl);
return request<Components['schemas']["BackupSummary"]>(
url, "GET", options
);
}
async backupDeletePreview(params: BackupDeletePreviewParams, options: RequestOptions = {}): Promise<Components['schemas']["BackupDeletePreview"]> {
const url = new URL("/api/v2/backups/{backup_id}/delete-preview".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl);
return request<Components['schemas']["BackupDeletePreview"]>(
url, "GET", options
);
}
async createRestore(params: CreateRestoreParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/backups/{backup_id}/restores".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async verifyBackup(params: VerifyBackupParams, options: RequestOptions = {}): Promise<Components['schemas']["BackupSummary"]> {
const url = new URL("/api/v2/backups/{backup_id}/verify".replace("{backup_id}", encodeURIComponent(String(params.path.backup_id))), this.baseUrl);
return request<Components['schemas']["BackupSummary"]>(
url, "POST", options
);
}
async listExecutions(options: RequestOptions = {}): Promise<Components['schemas']["ExecutionList"]> {
const url = new URL("/api/v2/executions", this.baseUrl);
return request<Components['schemas']["ExecutionList"]>(
url, "GET", options
);
}
async getExecution(params: GetExecutionParams, options: RequestOptions = {}): Promise<Components['schemas']["ExecutionSummary"]> {
const url = new URL("/api/v2/executions/{execution_id}".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl);
return request<Components['schemas']["ExecutionSummary"]>(
url, "GET", options
);
}
async cancelExecution(params: CancelExecutionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/executions/{execution_id}/cancel".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options
);
}
/** EventSource transport; intentionally not a JSON fetch Promise. */
executionEventsUrl(params: ExecutionEventsParams): URL {
const url = new URL("/api/v2/executions/{execution_id}/events".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl);
return url;
}
async retryExecution(params: RetryExecutionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/executions/{execution_id}/retry".replace("{execution_id}", encodeURIComponent(String(params.path.execution_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options
);
}
async listJobs(options: RequestOptions = {}): Promise<Components['schemas']["JobList"]> {
const url = new URL("/api/v2/jobs", this.baseUrl);
return request<Components['schemas']["JobList"]>(
url, "GET", options
);
}
async createJob(params: CreateJobParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/jobs", this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async enqueueExecution(params: EnqueueExecutionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/jobs/{job_id}/executions".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options
);
}
async deleteSchedule(params: DeleteScheduleParams, options: RequestOptions = {}): Promise<void> {
const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
return request<void>(
url, "DELETE", options
);
}
async getSchedule(params: GetScheduleParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async patchSchedule(params: PatchScheduleParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "PATCH", options, params.body
);
}
async createSchedule(params: CreateScheduleParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/jobs/{job_id}/schedule".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async listNotificationDeliveries(params: ListNotificationDeliveriesParams, options: RequestOptions = {}): Promise<Components['schemas']["NotificationDeliveryList"]> {
const url = new URL("/api/v2/notifications/deliveries", this.baseUrl);
appendQuery(url.searchParams, params.query);
return request<Components['schemas']["NotificationDeliveryList"]>(
url, "GET", options
);
}
async listNotificationAttempts(params: ListNotificationAttemptsParams, options: RequestOptions = {}): Promise<Components['schemas']["NotificationAttemptList"]> {
const url = new URL("/api/v2/notifications/deliveries/{delivery_id}/attempts".replace("{delivery_id}", encodeURIComponent(String(params.path.delivery_id))), this.baseUrl);
return request<Components['schemas']["NotificationAttemptList"]>(
url, "GET", options
);
}
async retryNotificationDelivery(params: RetryNotificationDeliveryParams, options: RequestOptions = {}): Promise<Record<string, string>> {
const url = new URL("/api/v2/notifications/deliveries/{delivery_id}/retry".replace("{delivery_id}", encodeURIComponent(String(params.path.delivery_id))), this.baseUrl);
return request<Record<string, string>>(
url, "POST", options
);
}
async getNotificationEmailSettings(options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/email-settings", this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async putNotificationEmailSettings(params: PutNotificationEmailSettingsParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/email-settings", this.baseUrl);
return request<Record<string, unknown>>(
url, "PUT", options, params.body
);
}
async notificationCatalog(options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/event-catalog", this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async listNotificationSubscriptions(options: RequestOptions = {}): Promise<Components['schemas']["NotificationSubscriptionList"]> {
const url = new URL("/api/v2/notifications/subscriptions", this.baseUrl);
return request<Components['schemas']["NotificationSubscriptionList"]>(
url, "GET", options
);
}
async createNotificationSubscription(params: CreateNotificationSubscriptionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/subscriptions", this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async getNotificationSubscription(params: GetNotificationSubscriptionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async patchNotificationSubscription(params: PatchNotificationSubscriptionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "PATCH", options, params.body
);
}
async rotateNotificationSigningKey(params: RotateNotificationSigningKeyParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}/signing-keys/rotate".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async testNotificationSubscription(params: TestNotificationSubscriptionParams, options: RequestOptions = {}): Promise<Record<string, string>> {
const url = new URL("/api/v2/notifications/subscriptions/{subscription_id}/test".replace("{subscription_id}", encodeURIComponent(String(params.path.subscription_id))), this.baseUrl);
return request<Record<string, string>>(
url, "POST", options
);
}
async listRepositories(options: RequestOptions = {}): Promise<Components['schemas']["RepositoryList"]> {
const url = new URL("/api/v2/repositories", this.baseUrl);
return request<Components['schemas']["RepositoryList"]>(
url, "GET", options
);
}
async createRepository(params: CreateRepositoryParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/repositories", this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async getRepository(params: GetRepositoryParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/repositories/{repository_id}".replace("{repository_id}", encodeURIComponent(String(params.path.repository_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async patchRepository(params: PatchRepositoryParams, options: RequestOptions = {}): Promise<unknown> {
const url = new URL("/api/v2/repositories/{repository_id}".replace("{repository_id}", encodeURIComponent(String(params.path.repository_id))), this.baseUrl);
return request<unknown>(
url, "PATCH", options, params.body
);
}
async inspectRepositoryEndpoint(params: InspectRepositoryEndpointParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/repositories/{repository_id}/inspection".replace("{repository_id}", encodeURIComponent(String(params.path.repository_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async getRestore(params: GetRestoreParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/restores/{restore_id}".replace("{restore_id}", encodeURIComponent(String(params.path.restore_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "GET", options
);
}
async recoveryStatus(options: RequestOptions = {}): Promise<Components['schemas']["RecoveryStatus"]> {
const url = new URL("/api/v2/security/recovery/status", this.baseUrl);
return request<Components['schemas']["RecoveryStatus"]>(
url, "GET", options
);
}
async setup(params: SetupParams, options: RequestOptions = {}): Promise<Components['schemas']["AuthenticatedUser"]> {
const url = new URL("/api/v2/setup", this.baseUrl);
return request<Components['schemas']["AuthenticatedUser"]>(
url, "POST", options, params.body
);
}
async listSources(options: RequestOptions = {}): Promise<Components['schemas']["SourceList"]> {
const url = new URL("/api/v2/sources", this.baseUrl);
return request<Components['schemas']["SourceList"]>(
url, "GET", options
);
}
async createSource(params: CreateSourceParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/sources", this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options, params.body
);
}
async archiveSource(params: ArchiveSourceParams, options: RequestOptions = {}): Promise<void> {
const url = new URL("/api/v2/sources/{source_id}".replace("{source_id}", encodeURIComponent(String(params.path.source_id))), this.baseUrl);
return request<void>(
url, "DELETE", options
);
}
async probeSource(params: ProbeSourceParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
const url = new URL("/api/v2/sources/{source_id}/probe".replace("{source_id}", encodeURIComponent(String(params.path.source_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "POST", options
);
}
async livez(options: RequestOptions = {}): Promise<Record<string, string>> {
const url = new URL("/livez", this.baseUrl);
return request<Record<string, string>>(
url, "GET", options
);
}
async readyz(options: RequestOptions = {}): Promise<Record<string, string>> {
const url = new URL("/readyz", this.baseUrl);
return request<Record<string, string>>(
url, "GET", options
);
}
}
+176
View File
@@ -0,0 +1,176 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
function response(body: unknown, status = 200): Response {
return {
headers: new Headers({ "content-type": "application/json" }),
json: async () => body,
ok: status >= 200 && status < 300,
status,
} as Response;
}
function problem(status: number, code: string, detail = "Request failed."): Response {
return response({ type: `https://backup-tool.invalid/problems/${code}`, title: code, status, detail, instance: "/", code }, status);
}
function session() {
return response({ id: "user-1", username: "operator", state: "active" });
}
function repositories(items: unknown[] = []) {
return response({ items });
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("operator foundation", () => {
it("shows loading then the accessible empty dashboard", async () => {
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch);
render(<App />);
expect(screen.getByRole("status")).toHaveTextContent("Checking your session");
expect(await screen.findByText("No repositories have been configured.")).toBeInTheDocument();
expect(fetch).toHaveBeenCalledTimes(2);
});
it("selects setup and creates a session for the first administrator", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(problem(401, "authentication_required"))
.mockResolvedValueOnce(problem(503, "setup_required"))
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }, 201))
.mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch);
render(<App />);
expect(await screen.findByRole("heading", { name: "Set up your administrator account" })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } });
fireEvent.click(screen.getByRole("button", { name: "Create administrator account" }));
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
const setupRequest = fetch.mock.calls[2]?.[0] as URL;
expect(setupRequest.pathname).toBe("/api/v2/setup");
});
it("signs in after setup is complete", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(problem(401, "authentication_required"))
.mockResolvedValueOnce(response({ status: "ready" }))
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }))
.mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch);
render(<App />);
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
const loginRequest = fetch.mock.calls[2]?.[0] as URL;
expect(loginRequest.pathname).toBe("/api/v2/auth/login");
});
it("shows retryable dashboard errors", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(session())
.mockResolvedValueOnce(problem(500, "service_unavailable", "Dashboard data is unavailable."))
.mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch);
render(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent("Dashboard data is unavailable.");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(screen.getByText("No repositories have been configured.")).toBeInTheDocument());
});
it("loads source and job states from generated client methods", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(session())
.mockResolvedValueOnce(repositories())
.mockResolvedValueOnce(response({ items: [] }))
.mockResolvedValueOnce(problem(500, "service_unavailable", "Jobs are unavailable."));
vi.stubGlobal("fetch", fetch);
render(<App />);
await screen.findByText("No repositories have been configured.");
fireEvent.click(screen.getByRole("button", { name: "Sources" }));
expect(await screen.findByText("No sources have been configured.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Jobs & schedules" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Jobs are unavailable.");
});
it("loads execution detail after selecting an execution", async () => {
const execution = { id: "execution-1", state: "failed", attempt: 2, revision: 3, reason_code: "timeout", progress: {} };
const fetch = vi.fn()
.mockResolvedValueOnce(session())
.mockResolvedValueOnce(repositories())
.mockResolvedValueOnce(response({ items: [execution] }))
.mockResolvedValueOnce(response(execution));
vi.stubGlobal("fetch", fetch);
render(<App />);
await screen.findByText("No repositories have been configured.");
fireEvent.click(screen.getByRole("button", { name: "Executions" }));
expect(await screen.findByRole("button", { name: "Execution execution-1" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Execution execution-1" }));
expect(await screen.findByRole("heading", { name: "Execution detail" })).toBeInTheDocument();
expect(screen.getByText("timeout")).toBeInTheDocument();
});
it("announces SSE reconnects and applies live execution updates", async () => {
class EventSourceMock {
static instances: EventSourceMock[] = [];
onmessage: ((event: MessageEvent<string>) => void) | null = null;
onerror: (() => void) | null = null;
constructor() { EventSourceMock.instances.push(this); }
close = vi.fn();
}
vi.stubGlobal("EventSource", EventSourceMock);
const execution = { id: "execution-1", state: "running", attempt: 1, revision: 1, reason_code: null, progress: {} };
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()).mockResolvedValueOnce(response({ items: [execution] })).mockResolvedValueOnce(response(execution));
vi.stubGlobal("fetch", fetch);
render(<App />);
await screen.findByText("No repositories have been configured.");
fireEvent.click(screen.getByRole("button", { name: "Executions" }));
fireEvent.click(await screen.findByRole("button", { name: "Execution execution-1" }));
await screen.findByRole("heading", { name: "Execution detail" });
EventSourceMock.instances[0]?.onerror?.();
expect(await screen.findByRole("alert")).toHaveTextContent("Live updates disconnected");
EventSourceMock.instances[0]?.onmessage?.({ data: JSON.stringify({ ...execution, state: "committed", revision: 2 }) } as MessageEvent<string>);
expect(await screen.findByText("committed")).toBeInTheDocument();
});
it("sends an idempotency key when retrying a failed notification delivery", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce(session())
.mockResolvedValueOnce(repositories())
.mockResolvedValueOnce(response({ items: [] }))
.mockResolvedValueOnce(response({ items: [{ id: "delivery-1", event_id: "event-1", subscription_id: "subscription-1", state: "failed", attempt_count: 1, response_class: null, response_summary: null, terminal_reason: "timeout", due_at: "2026-01-01T00:00:00Z" }] }))
.mockResolvedValueOnce(response({ delivery_id: "delivery-1" }, 202));
vi.stubGlobal("fetch", fetch);
render(<App />);
await screen.findByText("No repositories have been configured.");
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
fireEvent.click(await screen.findByRole("button", { name: "Retry delivery" }));
expect(await screen.findByRole("status")).toHaveTextContent("Delivery retry queued.");
const options = fetch.mock.calls[4]?.[1] as RequestInit;
expect(new Headers(options.headers).get("Idempotency-Key")).toBeTruthy();
});
it("returns to sign-in when the dashboard request finds an expired session", async () => {
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(problem(401, "authentication_required"));
vi.stubGlobal("fetch", fetch);
render(<App />);
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Your session expired");
});
});
+96 -15
View File
@@ -1,16 +1,97 @@
export function App() {
return (
<main className="min-h-screen bg-slate-950 p-8 text-slate-100">
<section className="mx-auto max-w-3xl rounded-xl border border-slate-800 bg-slate-900 p-8">
<p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">
Backup Tool v2
</p>
<h1 className="mt-3 text-3xl font-bold">Protocol foundation ready</h1>
<p className="mt-4 text-slate-300">
Operator workflows are added as their versioned API contracts become
executable.
</p>
</section>
</main>
);
import { type FormEvent, useEffect, useRef, useState } from "react";
import {
BackupToolClient,
type Components,
isApiError,
} from "../api/generated/client";
import { OperatorViews } from "./OperatorViews";
type SessionUser = Components["schemas"]["SessionUser"];
type AuthMode = "setup" | "login";
type Screen =
| { kind: "loading" }
| { kind: "auth"; mode: AuthMode; error?: string; sessionExpired?: boolean }
| { kind: "operator"; user: SessionUser };
const defaultClient = new BackupToolClient();
function errorMessage(error: unknown): string {
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
return "We could not reach the Backup Tool service. Check your connection and try again.";
}
function isSetupRequired(error: unknown): boolean {
return isApiError(error) && error.status === 503 && error.problem?.code === "setup_required";
}
function isSessionExpired(error: unknown): boolean {
return isApiError(error) && error.status === 401;
}
function AuthForm({
mode,
error,
sessionExpired,
onSubmit,
}: {
mode: AuthMode;
error?: string;
sessionExpired?: boolean;
onSubmit: (username: string, password: string) => Promise<void>;
}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const isSetup = mode === "setup";
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSetup && password.length < 12) return;
setSubmitting(true);
try {
await onSubmit(username, password);
} finally {
setSubmitting(false);
}
}
return <main className="flex min-h-screen items-center justify-center bg-slate-950 p-4 text-slate-100"><section aria-labelledby="auth-title" className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 p-6 shadow-2xl"><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-3 text-3xl font-bold" id="auth-title">{isSetup ? "Set up your administrator account" : "Sign in"}</h1><p className="mt-3 text-slate-300">{isSetup ? "Create the first administrator account to begin operating this backup service." : "Use an administrator account to continue."}</p>{sessionExpired ? <p role="status" className="mt-4 rounded-md border border-amber-400/50 bg-amber-950/40 p-3 text-amber-100">Your session expired. Sign in again to continue.</p> : null}{error ? <p role="alert" className="mt-4 rounded-md border border-rose-400/50 bg-rose-950/40 p-3 text-rose-100">{error}</p> : null}<form className="mt-6 space-y-4" onSubmit={submit}><div><label className="block text-sm font-medium" htmlFor="username">Username</label><input autoComplete="username" autoFocus className="mt-1 w-full rounded-md border border-slate-600 bg-slate-950 px-3 py-2" id="username" onChange={(event) => setUsername(event.target.value)} required value={username} /></div><div><label className="block text-sm font-medium" htmlFor="password">Password</label><input autoComplete={isSetup ? "new-password" : "current-password"} className="mt-1 w-full rounded-md border border-slate-600 bg-slate-950 px-3 py-2" id="password" minLength={isSetup ? 12 : undefined} onChange={(event) => setPassword(event.target.value)} required type="password" value={password} />{isSetup ? <p className="mt-1 text-sm text-slate-400">Use at least 12 characters.</p> : null}</div><button className="w-full rounded-md bg-emerald-500 px-4 py-2 font-semibold text-slate-950 hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60" disabled={submitting} type="submit">{submitting ? "Working…" : isSetup ? "Create administrator account" : "Sign in"}</button></form></section></main>;
}
export function App({ client = defaultClient }: { client?: BackupToolClient }) {
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
const clientRef = useRef(client);
clientRef.current = client;
async function discover() {
setScreen({ kind: "loading" });
try {
setScreen({ kind: "operator", user: await clientRef.current.getSession() });
} catch (error) {
if (!isSessionExpired(error)) {
setScreen({ kind: "auth", mode: "login", error: errorMessage(error) });
return;
}
try {
await clientRef.current.readyz();
setScreen({ kind: "auth", mode: "login" });
} catch (readinessError) {
setScreen({ kind: "auth", mode: isSetupRequired(readinessError) ? "setup" : "login", error: isSetupRequired(readinessError) ? undefined : errorMessage(readinessError) });
}
}
}
useEffect(() => { void discover(); }, []);
if (screen.kind === "loading") return <main aria-live="polite" className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100" role="status">Checking your session</main>;
if (screen.kind === "auth") return <AuthForm {...screen} onSubmit={async (username, password) => {
try {
const user = screen.mode === "setup" ? await clientRef.current.setup({ body: { username, password } }) : await clientRef.current.login({ body: { username, password } });
setScreen({ kind: "operator", user: { ...user, state: "active" } });
} catch (error) {
if (screen.mode === "setup" && isApiError(error) && error.problem?.code === "setup_complete") setScreen({ kind: "auth", mode: "login", error: "Setup is already complete. Sign in with the administrator account." });
else setScreen({ ...screen, error: errorMessage(error) });
}
}} />;
return <OperatorViews client={clientRef.current} onSessionExpired={() => setScreen({ kind: "auth", mode: "login", sessionExpired: true })} user={screen.user} />;
}
+33
View File
@@ -0,0 +1,33 @@
import { type ReactNode, useEffect, useState } from "react";
import { type BackupToolClient, type Components, isApiError } from "../api/generated/client";
type BackupSummary = Components["schemas"]["BackupSummary"];
type Delivery = Components["schemas"]["NotificationDeliverySummary"];
type Subscription = Components["schemas"]["NotificationSubscriptionSummary"];
type Audit = Components["schemas"]["AuditSummary"];
type State<T> = { loading: boolean; data?: T; error?: string };
type Props = { client: BackupToolClient; onSessionExpired: () => void };
function message(error: unknown) { return isApiError(error) ? error.problem?.detail ?? "The request failed." : "The service could not be reached."; }
function expired(error: unknown) { return isApiError(error) && error.status === 401; }
function Panel({ children, title }: { children: ReactNode; title: string }) { return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>; }
function Retry({ error, load }: { error?: string; load: () => void }) { return error ? <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{error}</p><button className="mt-3 rounded border border-slate-500 px-3 py-2" onClick={load} type="button">Try again</button></div> : null; }
function Loading({ label }: { label: string }) { return <p className="mt-4 text-slate-300" role="status">Loading {label}</p>; }
function csrfHeaders(): HeadersInit { const value = document.cookie.split("; ").find((entry) => entry.startsWith("backup_tool_csrf="))?.split("=", 2)[1]; return value ? { "X-CSRF-Token": value } : {}; }
function idempotencyKey(): string { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; }
export function BackupsPage({ client, onSessionExpired }: Props) {
const [state,setState]=useState<State<BackupSummary[]>>({loading:true}); const [selected,setSelected]=useState<BackupSummary>(); const [preview,setPreview]=useState<string>(); const [destination,setDestination]=useState(""); const [restoreStatus,setRestoreStatus]=useState<string>();
const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listBackups()).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};
useEffect(()=>{void load();},[]);
const headers=csrfHeaders();
return <Panel title="Backups">{state.loading?<Loading label="backups"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?.length===0?<p className="mt-4 text-slate-300">No backups have been committed.</p>:null}<ul className="mt-4 space-y-3" aria-label="Backups">{state.data?.map(item=><li className="rounded border border-slate-700 bg-slate-900 p-4" key={item.id}><button className="font-semibold hover:text-emerald-300" onClick={()=>setSelected(item)} type="button">Backup {item.id}</button><p className="text-sm text-slate-300">{item.integrity} · {item.logical_bytes} logical bytes</p></li>)}</ul>{selected?<section className="mt-6 rounded border border-slate-700 bg-slate-900 p-4" aria-labelledby="backup-detail"><h3 id="backup-detail" className="font-semibold">Backup detail</h3><p className="mt-2">Integrity: {selected.integrity}</p><div className="mt-3 flex flex-wrap gap-2"><button className="rounded border border-slate-500 px-3 py-2" onClick={async()=>{try{setSelected(await client.verifyBackup({path:{backup_id:selected.id}},{headers}));}catch(error){setPreview(message(error));}}} type="button">Verify</button><button className="rounded border border-slate-500 px-3 py-2" onClick={async()=>{try{const result=await client.backupDeletePreview({path:{backup_id:selected.id}},{headers});setPreview(result.reason??result.destructive_action);}catch(error){setPreview(message(error));}}} type="button">Preview deletion</button></div>{preview?<p className="mt-3" role="status">{preview}</p>:null}<form className="mt-4 space-y-2" onSubmit={async(event)=>{event.preventDefault();try{await client.createRestore({path:{backup_id:selected.id},body:{destination,dry_run:true,selection:[],overwrite_policy:"fail"}},{headers});setRestoreStatus("Restore dry run queued.");}catch(error){setRestoreStatus(message(error));}}}><label className="block text-sm font-medium" htmlFor="restore-destination">Restore destination</label><input className="w-full rounded border border-slate-500 bg-slate-950 p-2" id="restore-destination" onChange={(event)=>setDestination(event.target.value)} required value={destination}/><button className="rounded border border-slate-500 px-3 py-2" type="submit">Queue restore dry run</button>{restoreStatus?<p role="status">{restoreStatus}</p>:null}</form></section>:null}</Panel>;
}
export function SecurityPage({ client,onSessionExpired }: Props) { const [state,setState]=useState<State<Components["schemas"]["RecoveryStatus"]>>({loading:true}); const load=async()=>{setState({loading:true});try{setState({loading:false,data:await client.recoveryStatus()});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};useEffect(()=>{void load();},[]);return <Panel title="Security & recovery">{state.loading?<Loading label="recovery status"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?<article className="mt-4 rounded border border-slate-700 bg-slate-900 p-4"><p>Recovery is {state.data.recovery_mode.replace("_"," ")}.</p><p className="mt-2">Encrypted repositories: {state.data.encrypted_repository_count}</p><p className="mt-3 text-slate-300">Use the CLI and the recovery runbook: <code>{state.data.runbook}</code>. Passphrases and recovery bundles never enter the browser.</p></article>:null}</Panel>; }
export function NotificationsPage({client,onSessionExpired}:Props){const [state,setState]=useState<State<{subscriptions:Subscription[];deliveries:Delivery[]}>>({loading:true});const [history,setHistory]=useState<string>();const load=async()=>{setState({loading:true});const results=await Promise.allSettled([client.listNotificationSubscriptions(),client.listNotificationDeliveries({query:{limit:20}})]);if(results.some((result)=>result.status==="rejected"&&expired(result.reason))){onSessionExpired();return;}const errors=results.filter((result)=>result.status==="rejected");setState({loading:false,data:{subscriptions:results[0].status==="fulfilled"?results[0].value.items:[],deliveries:results[1].status==="fulfilled"?results[1].value.items:[]},error:errors.length?"Some notification history could not be loaded.":undefined});};useEffect(()=>{void load();},[]);return <Panel title="Notifications & history">{state.loading?<Loading label="notifications"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?<><h3 className="mt-4 font-semibold">Subscriptions</h3>{state.data.subscriptions.length?<ul className="mt-2">{state.data.subscriptions.map(item=><li key={item.id}>{item.channel} · {item.state}</li>)}</ul>:<p className="mt-2 text-slate-300">No notification subscriptions.</p>}<h3 className="mt-5 font-semibold">Delivery history</h3>{state.data.deliveries.length?<ul className="mt-2">{state.data.deliveries.map(item=><li key={item.id}>{item.state} · {item.attempt_count} attempts <button className="ml-2 underline" onClick={async()=>{try{const attempts=await client.listNotificationAttempts({path:{delivery_id:item.id}});setHistory(`${attempts.items.length} delivery attempts loaded.`);}catch(error){setHistory(message(error));}}} type="button">View attempts</button>{item.state==="failed"?<button className="ml-2 underline" onClick={async()=>{try{await client.retryNotificationDelivery({path:{delivery_id:item.id}},{headers:{...csrfHeaders(),"Idempotency-Key":idempotencyKey()}});setHistory("Delivery retry queued.");}catch(error){setHistory(message(error));}}} type="button">Retry delivery</button>:null}</li>)}</ul>:<p className="mt-2 text-slate-300">No notification deliveries.</p>}{history?<p className="mt-3" role="status">{history}</p>:null}</>:null}</Panel>}
export function AuditPage({client,onSessionExpired}:Props){const [state,setState]=useState<State<Audit[]>>({loading:true});const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listAudit({query:{limit:50}})).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};useEffect(()=>{void load();},[]);return <Panel title="Audit">{state.loading?<Loading label="audit events"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?.length===0?<p className="mt-4 text-slate-300">No audit events.</p>:null}<ul className="mt-4 space-y-2">{state.data?.map(item=><li className="rounded border border-slate-700 p-3" key={item.id}>{item.action} {item.resource_type} · {item.outcome}</li>)}</ul></Panel>}
+159
View File
@@ -0,0 +1,159 @@
import { type ReactNode, useEffect, useState } from "react";
import {
type BackupToolClient,
type Components,
isApiError,
} from "../api/generated/client";
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows";
type SessionUser = Components["schemas"]["SessionUser"];
type SourceSummary = Components["schemas"]["SourceSummary"];
type RepositorySummary = Components["schemas"]["RepositorySummary"];
type JobSummary = Components["schemas"]["JobSummary"];
type ExecutionSummary = Components["schemas"]["ExecutionSummary"];
type Page = "dashboard" | "sources" | "repositories" | "jobs" | "executions" | "backups" | "security" | "notifications" | "audit";
type LoadState<T> =
| { kind: "loading" }
| { kind: "ready"; items: T }
| { kind: "error"; message: string };
const pages: Array<{ id: Page; label: string }> = [
{ id: "dashboard", label: "Dashboard" },
{ id: "sources", label: "Sources" },
{ id: "repositories", label: "Repositories" },
{ id: "jobs", label: "Jobs & schedules" },
{ id: "executions", label: "Executions" },
{ id: "backups", label: "Backups" },
{ id: "security", label: "Security & recovery" },
{ id: "notifications", label: "Notifications" },
{ id: "audit", label: "Audit" },
];
function errorMessage(error: unknown): string {
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
return "We could not reach the Backup Tool service. Check your connection and try again.";
}
function isSessionExpired(error: unknown): boolean {
return isApiError(error) && error.status === 401;
}
function ErrorPanel({ message, retry }: { message: string; retry: () => void }) {
return <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{message}</p><button className="mt-3 rounded-md border border-slate-500 px-3 py-2 font-semibold hover:bg-slate-800" onClick={retry} type="button">Try again</button></div>;
}
function Loading({ label }: { label: string }) {
return <p aria-live="polite" className="mt-4 text-slate-300" role="status">Loading {label}</p>;
}
function ResourceSection({ children, title }: { children: ReactNode; title: string }) {
return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>;
}
function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Dashboard">
<p className="mt-2 text-slate-300">Repository availability at a glance.</p>
{state.kind === "loading" ? <Loading label="dashboard data" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 grid gap-3 sm:grid-cols-2">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-2 text-sm text-slate-300">{repository.state} · {repository.encryption}</p></li>)}</ul> : null}
</ResourceSection>;
}
function SourcesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<SourceSummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listSources()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Sources">
{state.kind === "loading" ? <Loading label="sources" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No sources have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured sources" className="mt-4 space-y-3">{state.items.map((source) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={source.id}><h3 className="font-semibold">{source.name}</h3><p className="mt-1 text-sm text-slate-300">{source.kind} · {source.state}</p></li>)}</ul> : null}
</ResourceSection>;
}
function RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Repositories">
{state.kind === "loading" ? <Loading label="repositories" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 space-y-3">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-1 text-sm text-slate-300">Format {repository.format_version} · {repository.encryption} · {repository.state}</p></li>)}</ul> : null}
</ResourceSection>;
}
function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listJobs()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Jobs & schedules">
{state.kind === "loading" ? <Loading label="jobs" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No jobs have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured jobs" className="mt-4 space-y-3">{state.items.map((job) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={job.id}><h3 className="font-semibold">{job.name}</h3><p className="mt-1 text-sm text-slate-300">{job.requested_mode} · {job.enabled ? "enabled" : "disabled"}</p><p className="mt-2 text-sm text-slate-300">{job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}</p></li>)}</ul> : null}
</ResourceSection>;
}
function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" });
const [selectedId, setSelectedId] = useState<string>();
const [detail, setDetail] = useState<LoadState<ExecutionSummary> | undefined>();
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listExecutions()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
async function loadDetail(executionId: string) {
setSelectedId(executionId); setDetail({ kind: "loading" });
try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
useEffect(() => {
if (!selectedId || typeof EventSource === "undefined") return;
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } }));
stream.onmessage = (event) => {
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); }
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); }
};
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); };
return () => stream.close();
}, [client, selectedId]);
return <ResourceSection title="Executions">
{state.kind === "loading" ? <Loading label="executions" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No executions have been queued.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Executions" className="mt-4 space-y-3">{state.items.map((execution) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={execution.id}><button aria-current={selectedId === execution.id ? "true" : undefined} className="text-left font-semibold hover:text-emerald-300" onClick={() => { void loadDetail(execution.id); }} type="button">Execution {execution.id}</button><p className="mt-1 text-sm text-slate-300">{execution.state} · attempt {execution.attempt}</p></li>)}</ul> : null}
{detail?.kind === "loading" ? <Loading label="execution details" /> : null}
{detail?.kind === "error" && selectedId ? <ErrorPanel message={detail.message} retry={() => { void loadDetail(selectedId); }} /> : null}
{detail?.kind === "ready" ? <section aria-labelledby="execution-detail-heading" className="mt-6 rounded-lg border border-slate-700 bg-slate-900 p-4"><h3 id="execution-detail-heading" className="text-lg font-semibold">Execution detail</h3><dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-slate-400">State</dt><dd>{detail.items.state}</dd></div><div><dt className="text-slate-400">Attempt</dt><dd>{detail.items.attempt}</dd></div><div><dt className="text-slate-400">Reason</dt><dd>{detail.items.reason_code ?? "None"}</dd></div></dl></section> : null}
</ResourceSection>;
}
export function OperatorViews({ client, onSessionExpired, user }: { client: BackupToolClient; onSessionExpired: () => void; user: SessionUser }) {
const [page, setPage] = useState<Page>("dashboard");
const common = { client, onSessionExpired };
return <main className="min-h-screen bg-slate-950 text-slate-100"><header className="border-b border-slate-800 bg-slate-900"><div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6"><div><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-1 text-2xl font-bold">Operator console</h1></div><p className="text-sm text-slate-300"><span className="sr-only">Signed in as </span>{user.username}</p></div><nav aria-label="Primary" className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6">{pages.map((item) => <button aria-current={page === item.id ? "page" : undefined} className="whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800" key={item.id} onClick={() => setPage(item.id)} type="button">{item.label}</button>)}</nav></header>{page === "dashboard" ? <DashboardPage {...common} /> : null}{page === "sources" ? <SourcesPage {...common} /> : null}{page === "repositories" ? <RepositoriesPage {...common} /> : null}{page === "jobs" ? <JobsPage {...common} /> : null}{page === "executions" ? <ExecutionsPage {...common} /> : null}{page === "backups" ? <BackupsPage {...common} /> : null}{page === "security" ? <SecurityPage {...common} /> : null}{page === "notifications" ? <NotificationsPage {...common} /> : null}{page === "audit" ? <AuditPage {...common} /> : null}</main>;
}
+20
View File
@@ -1,3 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
:focus-visible {
outline: 3px solid #34d399;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";