diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py index 8154f17..247291b 100644 --- a/backend/src/backup_tool/api/app.py +++ b/backend/src/backup_tool/api/app.py @@ -337,6 +337,15 @@ class JobInput(BaseModel): allow_empty: bool = False +class JobPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + requested_mode: str | None = None + exclusions: list[str] | None = None + retention: dict[str, Any] | None = None + enabled: bool | None = None + allow_empty: bool | None = None + + class TokenInput(BaseModel): scopes: list[str] = Field(min_length=1) expires_at: datetime | None = None @@ -1152,6 +1161,40 @@ def create_app(settings: Settings) -> FastAPI: "state": job.state, } + @app.patch("/api/v2/jobs/{job_id}") + async def patch_job( + job_id: str, + input_: JobPatch, + request: Request, + db: Annotated[AsyncSession, Depends(session)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], + ) -> dict[str, Any]: + job = await db.get(Job, job_id) + if job is None or job.state != "active": + raise Problem(404, "resource_not_found", "Job was not found.") + changes = input_.model_dump(exclude_unset=True) + if not changes: + raise Problem(422, "validation_failed", "No mutable fields supplied.") + if changes.get("requested_mode") not in {None, "full", "incremental"}: + raise Problem(422, "validation_failed", "Invalid requested mode.") + for field, value in changes.items(): + setattr(job, field, value) + await audit(db, request, "update", "job", job.id, "success", identity[0].id) + await db.commit() + await db.refresh(job) + return { + "id": job.id, + "name": job.name, + "source_id": job.source_id, + "repository_id": job.repository_id, + "requested_mode": job.requested_mode, + "exclusions": job.exclusions, + "retention": job.retention, + "enabled": job.enabled, + "allow_empty": job.allow_empty, + "state": job.state, + } + @app.post("/api/v2/jobs/{job_id}/schedule", status_code=201) async def create_schedule( job_id: str, diff --git a/docs/release/m16-routing-evidence.md b/docs/release/m16-routing-evidence.md new file mode 100644 index 0000000..8c4fab5 --- /dev/null +++ b/docs/release/m16-routing-evidence.md @@ -0,0 +1,21 @@ +# M16 routed operator UI evidence + +- The authenticated operator console now has protected, URL-addressable routes for the existing Dashboard, Sources, Repositories, Jobs, Executions, Backups, Security, Notifications, and Audit views. `/` and authenticated unknown routes redirect to `/dashboard` without adding a dead Back-history entry. +- Authentication discovery happens before the route shell renders, so an unauthenticated deep link is retained through sign-in rather than prematurely redirected. +- Route components load through `React.lazy` with an announced `Suspense` state and an in-shell error boundary. `OperatorPages.tsx` splits the existing resource pages into a separate production chunk. +- Navigation uses semantic `NavLink` anchors with current-page semantics. The shell provides one main landmark, a functional skip link, route-based document titles, and focus restoration after route navigation. +- Route-level tests cover direct `/sources` navigation, title/current-link state, focus restoration, authenticated unknown-route redirect, and the skip link. Existing workflow tests were adapted to the semantic navigation links. + +## Green M16 checks + +```text +npm --prefix frontend run api:check # generated client current +npm --prefix frontend run typecheck # passed +npm --prefix frontend run build # passed; emitted OperatorPages and M13Workflows chunks +npm --prefix frontend test -- --run # 11 passed +npx --prefix frontend playwright test --config frontend/playwright.config.ts # 1 passed +.venv/bin/python tools/export_openapi.py --check openapi/v2.json # current +make check # passed +``` + +Known boundary: this evidence covers delivery slice 1's routed shell over the pre-existing read/workflow views. The create/edit/administration workflows in later slices of `docs/plans/full-operator-ui.md` remain unimplemented. diff --git a/docs/release/m17-operator-workflows-evidence.md b/docs/release/m17-operator-workflows-evidence.md new file mode 100644 index 0000000..cca2126 --- /dev/null +++ b/docs/release/m17-operator-workflows-evidence.md @@ -0,0 +1,27 @@ +# M17 operator workflow evidence + +## Delivered workflows + +- Routed shell: protected, lazy, URL-addressable dashboard, resource, operation, notification, security, audit, and administration views. +- Resources: repository create/inspect; local and SSH source create, probe, typed archive; job create/enable/disable; schedule create/update/delete; manual enqueue. +- Operations: named execution SSE events, reconnect status, polling fallback, state-gated retry/cancel; backup verification/deletion impact preview; restore dry-run and exact-destination overwrite confirmation. +- Notifications: subscription creation, selected-only test, signing-key rotation input, delivery attempts/retry; webhook signing secrets are write-only. +- Administration: write-only secret creation, one-time token rendering, and user-state update. + +## Deliberate boundaries and remaining API gaps + +- Backup deletion has no v2 delete endpoint, so the UI stops at its impact preview and directs the operator to the CLI. +- Recovery bundles, passphrases, and recovery imports/exports remain CLI-only. +- User/token metadata list endpoints and generated response ETags are not available. Therefore user/token discovery and ETag-required subscription edit/disable/archive controls are not exposed rather than implemented unsafely. +- Audit filtering and cursor navigation await their corresponding authenticated API query parameters. + +## Verification + +```text +npm --prefix frontend test -- --run # 8 passed +npm --prefix frontend run typecheck # passed +.venv/bin/python -m pytest tests/integration/test_sources_jobs.py -q # 4 passed +make check # passed +``` + +The expected generated artifacts (`openapi/v2.json` and `frontend/src/api/generated/client.ts`) are staged so `make check` can verify API generation drift without staging unrelated workspace changes. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e77def9..c25a30e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "2.0.0-dev.0", "dependencies": { "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "react-router-dom": "^7.18.2" }, "devDependencies": { "@playwright/test": "^1.57.0", @@ -1645,6 +1646,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2917,6 +2931,44 @@ "license": "MIT", "peer": true }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3074,6 +3126,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b79dcbd..89c9e4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,8 @@ }, "dependencies": { "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "react-router-dom": "^7.18.2" }, "devDependencies": { "@playwright/test": "^1.57.0", diff --git a/frontend/src/api/generated/client.ts b/frontend/src/api/generated/client.ts index f05a9f9..caa6d63 100644 --- a/frontend/src/api/generated/client.ts +++ b/frontend/src/api/generated/client.ts @@ -18,6 +18,7 @@ export interface Components { HTTPValidationError: { detail?: Array }; JobInput: { allow_empty?: boolean; enabled?: boolean; exclusions?: Array; name : string; repository_id : string; requested_mode?: string; retention?: Record; source_id : string }; JobList: { items : Array }; + JobPatch: { allow_empty?: boolean | null; enabled?: boolean | null; exclusions?: Array | null; name?: string | null; requested_mode?: string | null; retention?: Record | null }; 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 }; LoginInput: { password : string; username : string }; @@ -82,6 +83,8 @@ export type RetryExecutionParams = { path: { execution_id: string } }; export type CreateJobParams = { body : Components['schemas']["JobInput"] }; +export type PatchJobParams = { path: { job_id: string }; body : Components['schemas']["JobPatch"] }; + export type EnqueueExecutionParams = { path: { job_id: string } }; export type DeleteScheduleParams = { path: { job_id: string } }; @@ -346,6 +349,13 @@ export class BackupToolClient { ); } + async patchJob(params: PatchJobParams, options: RequestOptions = {}): Promise> { + const url = new URL("/api/v2/jobs/{job_id}".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); + return request>( + url, "PATCH", options, params.body + ); + } + async enqueueExecution(params: EnqueueExecutionParams, options: RequestOptions = {}): Promise> { const url = new URL("/api/v2/jobs/{job_id}/executions".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl); return request>( diff --git a/frontend/src/app/AdministrationPage.tsx b/frontend/src/app/AdministrationPage.tsx new file mode 100644 index 0000000..7049764 --- /dev/null +++ b/frontend/src/app/AdministrationPage.tsx @@ -0,0 +1,191 @@ +import { type FormEvent, useState } from "react"; + +import { isApiError } from "../api/generated/client"; +import type { OperatorPageProps } from "./OperatorPages"; + +function message(error: unknown) { + return isApiError(error) + ? (error.problem?.detail ?? "The request failed.") + : "The service could not be reached."; +} +function csrfHeaders(): HeadersInit { + const token = document.cookie + .split("; ") + .find((item) => item.startsWith("backup_tool_csrf=")) + ?.split("=", 2)[1]; + return token ? { "X-CSRF-Token": token } : {}; +} + +export function AdministrationPage({ + client, + onSessionExpired, +}: OperatorPageProps) { + const [secretPurpose, setSecretPurpose] = useState("ssh_private_key"); + const [secretValue, setSecretValue] = useState(""); + const [scopes, setScopes] = useState("audit:read"); + const [token, setToken] = useState(); + const [userId, setUserId] = useState(""); + const [userState, setUserState] = useState("active"); + const [status, setStatus] = useState(); + const fail = (error: unknown) => { + if (isApiError(error) && error.status === 401) onSessionExpired(); + else setStatus(message(error)); + }; + async function createSecret(event: FormEvent) { + event.preventDefault(); + try { + await client.createSecret( + { body: { purpose: secretPurpose, value: secretValue } }, + { headers: csrfHeaders() }, + ); + setSecretValue(""); + setStatus( + "Secret stored. Its value is no longer available in the browser.", + ); + } catch (error) { + fail(error); + } + } + async function createToken(event: FormEvent) { + event.preventDefault(); + try { + const result: unknown = await client.createToken( + { body: { scopes: scopes.split(/\s*,\s*/).filter(Boolean) } }, + { headers: csrfHeaders() }, + ); + const tokenValue = + result && + typeof result === "object" && + "token" in result && + typeof result.token === "string" + ? result.token + : undefined; + setToken(tokenValue); + setStatus("Token created. Copy it now; it will not be shown again."); + } catch (error) { + fail(error); + } + } + async function updateUser(event: FormEvent) { + event.preventDefault(); + try { + await client.patchUser( + { path: { user_id: userId }, body: { state: userState } }, + { headers: csrfHeaders() }, + ); + setStatus(`User ${userState}.`); + } catch (error) { + fail(error); + } + } + return ( +
+

+ Administration +

+

+ Secret values are write-only. User and token discovery remain + unavailable until their authenticated list APIs are added. +

+
+
+

Store secret

+ + + +
+
+

Create API token

+ + + {token ? ( +

+ {token} +

+ ) : null} +
+
+

Change user state

+ + + +
+
+ {status ? ( +

+ {status} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index bfe512d..a900aff 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; @@ -11,166 +17,236 @@ function response(body: unknown, status = 200): Response { 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 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(); + window.history.replaceState({}, "", "/"); }); 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(); - - 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)) + const fetch = vi + .fn() + .mockResolvedValueOnce(session()) .mockResolvedValueOnce(repositories()); vi.stubGlobal("fetch", fetch); render(); - - 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"); + 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(); + 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(); + expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe("/api/v2/setup"); }); - it("signs in after setup is complete", async () => { - const fetch = vi.fn() + 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(); - - 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" } }); + 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"); + expect( + await screen.findByRole("heading", { name: "Dashboard" }), + ).toBeInTheDocument(); + expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe( + "/api/v2/auth/login", + ); }); - it("shows retryable dashboard errors", async () => { - const fetch = vi.fn() + const fetch = vi + .fn() .mockResolvedValueOnce(session()) - .mockResolvedValueOnce(problem(500, "service_unavailable", "Dashboard data is unavailable.")) + .mockResolvedValueOnce( + problem(500, "service_unavailable", "Dashboard data is unavailable."), + ) .mockResolvedValueOnce(repositories()); vi.stubGlobal("fetch", fetch); render(); - - expect(await screen.findByRole("alert")).toHaveTextContent("Dashboard data is unavailable."); + 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()); + 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() + const fetch = vi + .fn() .mockResolvedValueOnce(session()) .mockResolvedValueOnce(repositories()) .mockResolvedValueOnce(response({ items: [] })) - .mockResolvedValueOnce(problem(500, "service_unavailable", "Jobs are unavailable.")); + .mockResolvedValueOnce( + problem(500, "service_unavailable", "Jobs are unavailable."), + ); vi.stubGlobal("fetch", fetch); render(); - 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."); + fireEvent.click(screen.getByRole("link", { name: "Sources" })); + expect( + await screen.findByText("No sources have been configured."), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("link", { 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() + 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(); - 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(); + fireEvent.click(screen.getByRole("link", { 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 () => { + it("cancels a running execution from its detail view", async () => { + window.history.replaceState({}, "", "/executions"); + const execution = { + id: "execution-1", + state: "running", + attempt: 1, + revision: 1, + reason_code: null, + progress: {}, + }; class EventSourceMock { - static instances: EventSourceMock[] = []; onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; - constructor() { EventSourceMock.instances.push(this); } - close = vi.fn(); + addEventListener() {} + close() {} } 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(); - 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); - expect(await screen.findByText("committed")).toBeInTheDocument(); - }); - - it("sends an idempotency key when retrying a failed notification delivery", async () => { - const fetch = vi.fn() + 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)); + .mockResolvedValueOnce(response({ items: [execution] })) + .mockResolvedValueOnce(response(execution)) + .mockResolvedValueOnce( + response({ ...execution, state: "cancelling" }, 202), + ) + .mockResolvedValueOnce(response({ ...execution, state: "cancelling" })) + .mockResolvedValueOnce( + response({ items: [{ ...execution, state: "cancelling" }] }), + ); vi.stubGlobal("fetch", fetch); render(); - 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(); + fireEvent.click(await screen.findByRole("link", { name: "Executions" })); + fireEvent.click( + await screen.findByRole("button", { name: "Execution execution-1" }), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Cancel execution" }), + ); + expect(await screen.findByRole("status")).toHaveTextContent( + "Execution cancel requested.", + ); + expect((fetch.mock.calls[3]?.[0] as URL).pathname).toBe( + "/api/v2/executions/execution-1/cancel", + ); }); 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")); + const fetch = vi + .fn() + .mockResolvedValueOnce(session()) + .mockResolvedValueOnce(problem(401, "authentication_required")); vi.stubGlobal("fetch", fetch); render(); - - expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument(); - expect(screen.getByRole("status")).toHaveTextContent("Your session expired"); + expect( + await screen.findByRole("heading", { name: "Sign in" }), + ).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + "Your session expired", + ); }); }); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 92ef59f..d2b5993 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,4 +1,5 @@ import { type FormEvent, useEffect, useRef, useState } from "react"; +import { BrowserRouter } from "react-router-dom"; import { BackupToolClient, @@ -17,12 +18,19 @@ type Screen = const defaultClient = new BackupToolClient(); function errorMessage(error: unknown): string { - if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request."; + 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"; + return ( + isApiError(error) && + error.status === 503 && + error.problem?.code === "setup_required" + ); } function isSessionExpired(error: unknown): boolean { @@ -56,10 +64,92 @@ function AuthForm({ } } - return

Backup Tool

{isSetup ? "Set up your administrator account" : "Sign in"}

{isSetup ? "Create the first administrator account to begin operating this backup service." : "Use an administrator account to continue."}

{sessionExpired ?

Your session expired. Sign in again to continue.

: null}{error ?

{error}

: null}
setUsername(event.target.value)} required value={username} />
setPassword(event.target.value)} required type="password" value={password} />{isSetup ?

Use at least 12 characters.

: null}
; + return ( +
+
+

+ Backup Tool +

+

+ {isSetup ? "Set up your administrator account" : "Sign in"} +

+

+ {isSetup + ? "Create the first administrator account to begin operating this backup service." + : "Use an administrator account to continue."} +

+ {sessionExpired ? ( +

+ Your session expired. Sign in again to continue. +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + setUsername(event.target.value)} + required + value={username} + /> +
+
+ + setPassword(event.target.value)} + required + type="password" + value={password} + /> + {isSetup ? ( +

+ Use at least 12 characters. +

+ ) : null} +
+ +
+
+
+ ); } -export function App({ client = defaultClient }: { client?: BackupToolClient }) { +function AppContent({ client = defaultClient }: { client?: BackupToolClient }) { const [screen, setScreen] = useState({ kind: "loading" }); const clientRef = useRef(client); clientRef.current = client; @@ -67,7 +157,10 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) { async function discover() { setScreen({ kind: "loading" }); try { - setScreen({ kind: "operator", user: await clientRef.current.getSession() }); + setScreen({ + kind: "operator", + user: await clientRef.current.getSession(), + }); } catch (error) { if (!isSessionExpired(error)) { setScreen({ kind: "auth", mode: "login", error: errorMessage(error) }); @@ -77,21 +170,77 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) { 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) }); + setScreen({ + kind: "auth", + mode: isSetupRequired(readinessError) ? "setup" : "login", + error: isSetupRequired(readinessError) + ? undefined + : errorMessage(readinessError), + }); } } } - useEffect(() => { void discover(); }, []); - if (screen.kind === "loading") return
Checking your session…
; - if (screen.kind === "auth") return { - 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 setScreen({ kind: "auth", mode: "login", sessionExpired: true })} user={screen.user} />; + useEffect(() => { + void discover(); + }, []); + if (screen.kind === "loading") + return ( +
+ Checking your session… +
+ ); + if (screen.kind === "auth") + return ( + { + 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 ( + + setScreen({ kind: "auth", mode: "login", sessionExpired: true }) + } + user={screen.user} + /> + ); +} + +export function App({ client }: { client?: BackupToolClient }) { + return ( + + + + ); } diff --git a/frontend/src/app/M13Workflows.tsx b/frontend/src/app/M13Workflows.tsx index 626aa9c..0cbf44b 100644 --- a/frontend/src/app/M13Workflows.tsx +++ b/frontend/src/app/M13Workflows.tsx @@ -1,6 +1,10 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type FormEvent, type ReactNode, useEffect, useState } from "react"; -import { type BackupToolClient, type Components, isApiError } from "../api/generated/client"; +import { + type BackupToolClient, + type Components, + isApiError, +} from "../api/generated/client"; type BackupSummary = Components["schemas"]["BackupSummary"]; type Delivery = Components["schemas"]["NotificationDeliverySummary"]; @@ -10,24 +14,648 @@ type State = { 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

{title}

{children}
; } -function Retry({ error, load }: { error?: string; load: () => void }) { return error ?

{error}

: null; } -function Loading({ label }: { label: string }) { return

Loading {label}…

; } -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>({loading:true}); const [selected,setSelected]=useState(); const [preview,setPreview]=useState(); const [destination,setDestination]=useState(""); const [restoreStatus,setRestoreStatus]=useState(); - 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 {state.loading?:null}void load()}/>{state.data?.length===0?

No backups have been committed.

:null}
    {state.data?.map(item=>
  • {item.integrity} · {item.logical_bytes} logical bytes

  • )}
{selected?

Backup detail

Integrity: {selected.integrity}

{preview?

{preview}

:null}
{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));}}}>setDestination(event.target.value)} required value={destination}/>{restoreStatus?

{restoreStatus}

:null}
:null}
; +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 ( +
+

+ {title} +

+ {children} +
+ ); +} +function Retry({ error, load }: { error?: string; load: () => void }) { + return error ? ( +
+

{error}

+ +
+ ) : null; +} +function Loading({ label }: { label: string }) { + return ( +

+ Loading {label}… +

+ ); +} +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 SecurityPage({ client,onSessionExpired }: Props) { const [state,setState]=useState>({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 {state.loading?:null}void load()}/>{state.data?

Recovery is {state.data.recovery_mode.replace("_"," ")}.

Encrypted repositories: {state.data.encrypted_repository_count}

Use the CLI and the recovery runbook: {state.data.runbook}. Passphrases and recovery bundles never enter the browser.

:null}
; } +export function BackupsPage({ client, onSessionExpired }: Props) { + const [state, setState] = useState>({ loading: true }); + const [selected, setSelected] = useState(); + const [preview, setPreview] = useState(); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const [destination, setDestination] = useState(""); + const [overwrite, setOverwrite] = useState(false); + const [overwriteConfirmation, setOverwriteConfirmation] = useState(""); + const [restoreStatus, setRestoreStatus] = useState(); + 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 ( + + {state.loading ? : null} + void load()} /> + {state.data?.length === 0 ? ( +

No backups have been committed.

+ ) : null} +
    + {state.data?.map((item) => ( +
  • + +

    + {item.integrity} · {item.logical_bytes} logical bytes +

    +
  • + ))} +
+ {selected ? ( +
+

+ Backup detail +

+

Integrity: {selected.integrity}

+
+ + +
+ {preview ? ( + <> +

+ {preview} +

+ + {deleteConfirmation === selected.id ? ( +

+ Deletion is confirmed locally, but this v2 API intentionally + exposes no delete endpoint. Use the CLI after reviewing this + impact preview. +

+ ) : null} + + ) : null} +
{ + event.preventDefault(); + if (overwrite && overwriteConfirmation !== destination) { + setRestoreStatus( + "Type the exact destination to confirm overwrite.", + ); + return; + } + try { + await client.createRestore( + { + path: { backup_id: selected.id }, + body: { + destination, + dry_run: !overwrite, + selection: [], + overwrite_policy: overwrite ? "overwrite" : "fail", + }, + }, + { headers }, + ); + setRestoreStatus( + overwrite + ? "Restore queued with confirmed overwrite." + : "Restore dry run queued.", + ); + } catch (error) { + setRestoreStatus(message(error)); + } + }} + > + + setDestination(event.target.value)} + required + value={destination} + /> + + {overwrite ? ( + + ) : null} + + {restoreStatus ?

{restoreStatus}

: null} +
+
+ ) : null} +
+ ); +} -export function NotificationsPage({client,onSessionExpired}:Props){const [state,setState]=useState>({loading:true});const [history,setHistory]=useState();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 {state.loading?:null}void load()}/>{state.data?<>

Subscriptions

{state.data.subscriptions.length?
    {state.data.subscriptions.map(item=>
  • {item.channel} · {item.state}
  • )}
:

No notification subscriptions.

}

Delivery history

{state.data.deliveries.length?
    {state.data.deliveries.map(item=>
  • {item.state} · {item.attempt_count} attempts {item.state==="failed"?:null}
  • )}
:

No notification deliveries.

}{history?

{history}

:null}:null}
} +export function SecurityPage({ client, onSessionExpired }: Props) { + const [state, setState] = useState< + State + >({ 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 ( + + {state.loading ? : null} + void load()} /> + {state.data ? ( +
+

Recovery is {state.data.recovery_mode.replace("_", " ")}.

+

+ Encrypted repositories: {state.data.encrypted_repository_count} +

+

+ Use the CLI and the recovery runbook:{" "} + {state.data.runbook}. Passphrases and recovery bundles + never enter the browser. +

+
+ ) : null} +
+ ); +} -export function AuditPage({client,onSessionExpired}:Props){const [state,setState]=useState>({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 {state.loading?:null}void load()}/>{state.data?.length===0?

No audit events.

:null}
    {state.data?.map(item=>
  • {item.action} {item.resource_type} · {item.outcome}
  • )}
} +function NotificationControls({ + client, + onDone, +}: { + client: BackupToolClient; + onDone: () => void; +}) { + const [channel, setChannel] = useState<"webhook" | "email">("webhook"); + const [destination, setDestination] = useState(""); + const [filters, setFilters] = useState("backup.committed"); + const [secret, setSecret] = useState(""); + const [status, setStatus] = useState(); + async function create(event: FormEvent) { + event.preventDefault(); + try { + await client.createNotificationSubscription( + { + body: { + channel, + destination: + channel === "webhook" + ? { url: destination } + : { address: destination }, + event_filters: filters.split(/\s*,\s*/).filter(Boolean), + signing_secret: channel === "webhook" && secret ? secret : null, + }, + }, + { headers: csrfHeaders() }, + ); + setDestination(""); + setSecret(""); + setStatus("Subscription created. Signing secrets are write-only."); + onDone(); + } catch (error) { + setStatus(message(error)); + } + } + return ( +
+

Create notification subscription

+
+ + + + {channel === "webhook" ? ( + + ) : null} +
+ + {status ? ( +

+ {status} +

+ ) : null} +
+ ); +} + +function SubscriptionActions({ + client, + subscriptions, +}: { + client: BackupToolClient; + subscriptions: Subscription[]; +}) { + const [id, setId] = useState(""); + const [rotationSecret, setRotationSecret] = useState(""); + const [status, setStatus] = useState(); + async function test() { + if (!id) return; + try { + await client.testNotificationSubscription( + { path: { subscription_id: id } }, + { headers: csrfHeaders() }, + ); + setStatus("Selected subscription test queued."); + } catch (error) { + setStatus(message(error)); + } + } + async function rotate(event: FormEvent) { + event.preventDefault(); + if (!id) return; + try { + await client.rotateNotificationSigningKey( + { path: { subscription_id: id }, body: { secret: rotationSecret } }, + { headers: csrfHeaders() }, + ); + setRotationSecret(""); + setStatus("Signing key rotation started with overlap."); + } catch (error) { + setStatus(message(error)); + } + } + if (!subscriptions.length) return null; + return ( +
+

Subscription actions

+ + +
+ + +
+ {status ? ( +

+ {status} +

+ ) : null} +
+ ); +} + +export function NotificationsPage({ client, onSessionExpired }: Props) { + const [state, setState] = useState< + State<{ subscriptions: Subscription[]; deliveries: Delivery[] }> + >({ loading: true }); + const [history, setHistory] = useState(); + 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 ( + + void load()} /> + + {state.loading ? : null} + void load()} /> + {state.data ? ( + <> +

Subscriptions

+ {state.data.subscriptions.length ? ( +
    + {state.data.subscriptions.map((item) => ( +
  • + {item.channel} · {item.state} +
  • + ))} +
+ ) : ( +

+ No notification subscriptions. +

+ )} +

Delivery history

+ {state.data.deliveries.length ? ( +
    + {state.data.deliveries.map((item) => ( +
  • + {item.state} · {item.attempt_count} attempts{" "} + + {item.state === "failed" ? ( + + ) : null} +
  • + ))} +
+ ) : ( +

No notification deliveries.

+ )} + {history ? ( +

+ {history} +

+ ) : null} + + ) : null} +
+ ); +} + +export function AuditPage({ client, onSessionExpired }: Props) { + const [state, setState] = useState>({ 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 ( + + {state.loading ? : null} + void load()} /> + {state.data?.length === 0 ? ( +

No audit events.

+ ) : null} +
    + {state.data?.map((item) => ( +
  • + {item.action} {item.resource_type} · {item.outcome} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/app/OperatorPages.tsx b/frontend/src/app/OperatorPages.tsx new file mode 100644 index 0000000..7ce8e21 --- /dev/null +++ b/frontend/src/app/OperatorPages.tsx @@ -0,0 +1,1077 @@ +import { type FormEvent, type ReactNode, useEffect, useState } from "react"; + +import { + type BackupToolClient, + type Components, + isApiError, +} from "../api/generated/client"; + +type SourceSummary = Components["schemas"]["SourceSummary"]; +type RepositorySummary = Components["schemas"]["RepositorySummary"]; +type JobSummary = Components["schemas"]["JobSummary"]; +type ExecutionSummary = Components["schemas"]["ExecutionSummary"]; +type LoadState = + | { kind: "loading" } + | { kind: "ready"; items: T } + | { kind: "error"; message: string }; + +export type OperatorPageProps = { + client: BackupToolClient; + onSessionExpired: () => void; +}; + +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 csrfHeaders(): HeadersInit { + const token = document.cookie + .split("; ") + .find((item) => item.startsWith("backup_tool_csrf=")) + ?.split("=", 2)[1]; + return token ? { "X-CSRF-Token": token } : {}; +} + +function ErrorPanel({ + message, + retry, +}: { + message: string; + retry: () => void; +}) { + return ( +
+

{message}

+ +
+ ); +} + +function Loading({ label }: { label: string }) { + return ( +

+ Loading {label}… +

+ ); +} + +function ResourceSection({ + children, + title, +}: { + children: ReactNode; + title: string; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +export function DashboardPage({ client, onSessionExpired }: OperatorPageProps) { + const [state, setState] = useState>({ + 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 ( + +

+ Repository availability at a glance. +

+ {state.kind === "loading" ? : null} + {state.kind === "error" ? ( + { + void load(); + }} + /> + ) : null} + {state.kind === "ready" && state.items.length === 0 ? ( +

+ No repositories have been configured. +

+ ) : null} + {state.kind === "ready" && state.items.length > 0 ? ( +
    + {state.items.map((repository) => ( +
  • +

    {repository.name}

    +

    + {repository.state} · {repository.encryption} +

    +
  • + ))} +
+ ) : null} +
+ ); +} + +export function SourcesPage({ client, onSessionExpired }: OperatorPageProps) { + const [state, setState] = useState>({ + kind: "loading", + }); + const [kind, setKind] = useState<"local" | "ssh">("local"); + const [name, setName] = useState(""); + const [root, setRoot] = useState("/"); + const [hostname, setHostname] = useState(""); + const [username, setUsername] = useState(""); + const [hostKey, setHostKey] = useState(""); + const [secretId, setSecretId] = useState(""); + const [sshSecrets, setSshSecrets] = useState>>( + [], + ); + const [result, setResult] = useState(); + 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) }); + } + } + async function create(event: FormEvent) { + event.preventDefault(); + setResult(undefined); + const body: + | Components["schemas"]["LocalSourceInput"] + | Components["schemas"]["SSHSourceInput"] = + kind === "local" + ? { kind: "local", name, public_config: { root } } + : { + kind: "ssh", + name, + private_key_secret_id: secretId, + public_config: { + hostname, + username, + port: 22, + host_key: hostKey, + root: "/", + }, + }; + try { + await client.createSource({ body }, { headers: csrfHeaders() }); + setName(""); + setSecretId(""); + setResult( + "Source created. SSH private-key references remain write-only.", + ); + await load(); + } catch (error) { + setResult(errorMessage(error)); + } + } + async function probe(sourceId: string) { + try { + await client.probeSource( + { path: { source_id: sourceId } }, + { headers: csrfHeaders() }, + ); + setResult("Source probe completed."); + } catch (error) { + setResult(errorMessage(error)); + } + } + async function archive(sourceId: string) { + if (window.prompt("Type the source ID to archive it.") !== sourceId) return; + try { + await client.archiveSource( + { path: { source_id: sourceId } }, + { headers: csrfHeaders() }, + ); + setResult("Source archived."); + await load(); + } catch (error) { + setResult(errorMessage(error)); + } + } + useEffect(() => { + void load(); + }, []); + useEffect(() => { + if (kind !== "ssh") return; + void client + .listSecrets() + .then((items) => + setSshSecrets( + items.filter((item) => item.purpose === "ssh_private_key"), + ), + ) + .catch((error) => setResult(errorMessage(error))); + }, [client, kind]); + return ( + +
+

Create source

+
+ + + {kind === "local" ? ( + + ) : ( + <> + + +