4 Commits

Author SHA1 Message Date
alex e9ceaeffed docs(operators): add console workflow guide 2026-07-31 18:15:58 +02:00
alex 9023963bf1 feat(ui): add routed operator workflows 2026-07-31 17:19:08 +02:00
alex 22567c5fb1 feat(v2): release v2.0.0
# Conflicts:
#	README.md
2026-07-31 14:41:39 +02:00
alex 3965aca42f docs: refresh README 2026-07-27 15:24:01 +02:00
17 changed files with 2921 additions and 275 deletions
+43
View File
@@ -337,6 +337,15 @@ class JobInput(BaseModel):
allow_empty: bool = False 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): class TokenInput(BaseModel):
scopes: list[str] = Field(min_length=1) scopes: list[str] = Field(min_length=1)
expires_at: datetime | None = None expires_at: datetime | None = None
@@ -1152,6 +1161,40 @@ def create_app(settings: Settings) -> FastAPI:
"state": job.state, "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) @app.post("/api/v2/jobs/{job_id}/schedule", status_code=201)
async def create_schedule( async def create_schedule(
job_id: str, job_id: str,
+2
View File
@@ -7,6 +7,7 @@ This index points operators and contributors to the authoritative v2.0 procedure
## Start here ## Start here
- [Project quick start](../README.md) - [Project quick start](../README.md)
- [Operator console guide](runbooks/operator-console.md)
- [Upgrade and rollback](runbooks/upgrade.md) - [Upgrade and rollback](runbooks/upgrade.md)
- [Observability and alert response](runbooks/observability.md) - [Observability and alert response](runbooks/observability.md)
- [Disaster recovery](runbooks/disaster-recovery.md) - [Disaster recovery](runbooks/disaster-recovery.md)
@@ -15,6 +16,7 @@ This index points operators and contributors to the authoritative v2.0 procedure
| Need | Authoritative guide | | Need | Authoritative guide |
| --- | --- | | --- | --- |
| Operator console | [operator console](runbooks/operator-console.md) |
| Repository lifecycle | [repositories](runbooks/repositories.md) | | Repository lifecycle | [repositories](runbooks/repositories.md) |
| Metadata protection | [metadata](runbooks/metadata.md) | | Metadata protection | [metadata](runbooks/metadata.md) |
| Master and repository keys | [keys](runbooks/keys.md) | | Master and repository keys | [keys](runbooks/keys.md) |
+21
View File
@@ -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.
@@ -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.
@@ -0,0 +1,12 @@
# M18 operator guide evidence
- Added `docs/runbooks/operator-console.md`, a task-oriented guide for first setup,
repositories, sources, jobs, executions, backups/restores, notifications, and
administration.
- Linked the guide from the documentation index without removing the existing
operator-UI-plan entry.
- Documents write-only-secret handling and CLI-only recovery/deletion boundaries.
Verification: `make check` passed (107 unit/contract, 73 integration with one
skipped, 15 fault, and 33 security tests; lint, typecheck, and frontend build
passed).
+61
View File
@@ -0,0 +1,61 @@
# Operator console guide
Use the Backup Tool console for routine v2 operator work. It is served by the
same Compose deployment as the API, normally at `http://127.0.0.1:8080`.
## Before you begin
1. Start and check the deployment as described in the [project quick start](../../README.md).
2. On a new installation, open the console and create the first administrator.
Production setup should be protected with `BACKUP_TOOL_BOOTSTRAP_SECRET`.
3. Sign in with an active administrator account. If the session expires, the
console returns to sign-in; sign in again and reopen the saved URL.
## Typical backup workflow
1. **Repositories** — Create a repository. Choose its compression and encryption
policy carefully: it is immutable after creation. Use **Inspect repository**
to check its recorded policy.
2. **Sources** — Create a local source with an allowed root, or an SSH source.
SSH requires a stored private-key secret, hostname, username, exact host key,
and uses a forced-SFTP chroot at `/`. Probe the source before creating a job.
3. **Jobs & schedules** — Create a job with the source and repository IDs,
exclusions, and backup mode. Add a cron schedule or use **Run now** for a
one-off execution. Disable a job before changing its operational use.
4. **Executions** — Select an execution to watch its state. The page consumes
live updates and polls while reconnecting. Only running/queued executions
can be cancelled; only failed ones can be retried.
5. **Backups** — Verify committed backups. Review a deletion impact preview
before using the CLI procedure for deletion. Test restores as dry runs first;
an overwrite restore requires typing the exact destination.
6. **Notifications** — Create webhook or email subscriptions, choose event
filters, test the selected subscription, and rotate webhook signing keys when
needed. Treat signing secrets as write-only.
## Administration and safety boundaries
The **Administration** page can store write-only secrets, create a token (copy
it immediately), and change a known user's state. Do not paste secret values
into issue trackers, URLs, browser storage, or logs.
Some high-risk actions deliberately remain outside the browser:
- Recovery bundle export, validation, import, passphrases, and key material use
the [recovery-bundle runbook](recovery-bundle.md).
- Backup deletion is CLI-only after its browser impact preview.
- User/token inventory and subscription state changes that require ETags are not
offered until their authenticated list/header API contracts are available.
## Troubleshooting
- Use the in-page retry control for transient API failures.
- A **permission** response means the signed-in account/token lacks the required
scope; use an administrator account rather than retrying.
- For SSH probe failures, confirm the exact host key, private-key secret,
forced-SFTP account, and source root with the [SSH source runbook](ssh-sources.md).
- For delivery failures, inspect delivery attempts, then follow the
[notification runbook](notifications.md).
For deployment health, metrics, alerts, upgrades, or recovery, use the linked
runbooks rather than treating the console as a substitute for incident
procedures.
+59 -1
View File
@@ -9,7 +9,8 @@
"version": "2.0.0-dev.0", "version": "2.0.0-dev.0",
"dependencies": { "dependencies": {
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"react-router-dom": "^7.18.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
@@ -1645,6 +1646,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -2917,6 +2931,44 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "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==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "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": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+2 -1
View File
@@ -18,7 +18,8 @@
}, },
"dependencies": { "dependencies": {
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"react-router-dom": "^7.18.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
+10
View File
@@ -18,6 +18,7 @@ export interface Components {
HTTPValidationError: { detail?: Array<Components['schemas']["ValidationError"]> }; 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 }; 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"]> }; JobList: { items : Array<Components['schemas']["JobSummary"]> };
JobPatch: { allow_empty?: boolean | null; enabled?: boolean | null; exclusions?: Array<string> | null; name?: string | null; requested_mode?: string | null; retention?: Record<string, unknown> | null };
JobSummary: { enabled : boolean; id : string; name : string; repository_id : string; requested_mode : string; schedule : Components['schemas']["ScheduleSummary"] | null; source_id : string; state : string }; 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> }; LocalSourceInput: { kind : string; name : string; public_config : Record<string, unknown> };
LoginInput: { password : string; username : string }; 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 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 EnqueueExecutionParams = { path: { job_id: string } };
export type DeleteScheduleParams = { 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<Record<string, unknown>> {
const url = new URL("/api/v2/jobs/{job_id}".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
return request<Record<string, unknown>>(
url, "PATCH", options, params.body
);
}
async enqueueExecution(params: EnqueueExecutionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> { 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); 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>>( return request<Record<string, unknown>>(
+191
View File
@@ -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<string>();
const [userId, setUserId] = useState("");
const [userState, setUserState] = useState("active");
const [status, setStatus] = useState<string>();
const fail = (error: unknown) => {
if (isApiError(error) && error.status === 401) onSessionExpired();
else setStatus(message(error));
};
async function createSecret(event: FormEvent<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
event.preventDefault();
try {
await client.patchUser(
{ path: { user_id: userId }, body: { state: userState } },
{ headers: csrfHeaders() },
);
setStatus(`User ${userState}.`);
} catch (error) {
fail(error);
}
}
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">
Administration
</h2>
<p className="mt-2 text-slate-300">
Secret values are write-only. User and token discovery remain
unavailable until their authenticated list APIs are added.
</p>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<form
className="rounded border border-slate-700 bg-slate-900 p-4"
onSubmit={createSecret}
>
<h3 className="font-semibold">Store secret</h3>
<label className="mt-3 block">
Purpose
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setSecretPurpose(event.target.value)}
required
value={secretPurpose}
/>
</label>
<label className="mt-3 block">
Value
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setSecretValue(event.target.value)}
required
type="password"
value={secretValue}
/>
</label>
<button
className="mt-4 rounded border border-slate-500 px-3 py-2"
type="submit"
>
Store secret
</button>
</form>
<form
className="rounded border border-slate-700 bg-slate-900 p-4"
onSubmit={createToken}
>
<h3 className="font-semibold">Create API token</h3>
<label className="mt-3 block">
Scopes (comma separated)
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setScopes(event.target.value)}
required
value={scopes}
/>
</label>
<button
className="mt-4 rounded border border-slate-500 px-3 py-2"
type="submit"
>
Create token
</button>
{token ? (
<p className="mt-3 break-all" role="status">
{token}
</p>
) : null}
</form>
<form
className="rounded border border-slate-700 bg-slate-900 p-4"
onSubmit={updateUser}
>
<h3 className="font-semibold">Change user state</h3>
<label className="mt-3 block">
User ID
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setUserId(event.target.value)}
required
value={userId}
/>
</label>
<label className="mt-3 block">
State
<select
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setUserState(event.target.value)}
value={userState}
>
<option value="active">Active</option>
<option value="disabled">Disabled</option>
</select>
</label>
<button
className="mt-4 rounded border border-slate-500 px-3 py-2"
type="submit"
>
Update user
</button>
</form>
</div>
{status ? (
<p className="mt-4" role="status">
{status}
</p>
) : null}
</section>
);
}
+173 -97
View File
@@ -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 { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App"; import { App } from "./App";
@@ -11,166 +17,236 @@ function response(body: unknown, status = 200): Response {
status, status,
} as Response; } as Response;
} }
function problem(
function problem(status: number, code: string, detail = "Request failed."): Response { status: number,
return response({ type: `https://backup-tool.invalid/problems/${code}`, title: code, status, detail, instance: "/", code }, status); code: string,
detail = "Request failed.",
): Response {
return response(
{
type: `https://backup-tool.invalid/problems/${code}`,
title: code,
status,
detail,
instance: "/",
code,
},
status,
);
} }
function session() { function session() {
return response({ id: "user-1", username: "operator", state: "active" }); return response({ id: "user-1", username: "operator", state: "active" });
} }
function repositories(items: unknown[] = []) { function repositories(items: unknown[] = []) {
return response({ items }); return response({ items });
} }
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
}); });
describe("operator foundation", () => { describe("operator foundation", () => {
it("shows loading then the accessible empty dashboard", async () => { it("shows loading then the accessible empty dashboard", async () => {
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()); const fetch = vi
vi.stubGlobal("fetch", fetch); .fn()
render(<App />); .mockResolvedValueOnce(session())
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()); .mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
expect(screen.getByRole("status")).toHaveTextContent(
expect(await screen.findByRole("heading", { name: "Set up your administrator account" })).toBeInTheDocument(); "Checking your session",
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); );
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } }); expect(
fireEvent.click(screen.getByRole("button", { name: "Create administrator account" })); await screen.findByText("No repositories have been configured."),
).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); expect(fetch).toHaveBeenCalledTimes(2);
const setupRequest = fetch.mock.calls[2]?.[0] as URL; });
expect(setupRequest.pathname).toBe("/api/v2/setup"); 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();
expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe("/api/v2/setup");
}); });
it("signs in after setup is complete", async () => { it("signs in after setup is complete", async () => {
const fetch = vi.fn() const fetch = vi
.fn()
.mockResolvedValueOnce(problem(401, "authentication_required")) .mockResolvedValueOnce(problem(401, "authentication_required"))
.mockResolvedValueOnce(response({ status: "ready" })) .mockResolvedValueOnce(response({ status: "ready" }))
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" })) .mockResolvedValueOnce(response({ id: "user-1", username: "operator" }))
.mockResolvedValueOnce(repositories()); .mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
expect(
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument(); await screen.findByRole("heading", { name: "Sign in" }),
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); ).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } }); 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" })); fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); await screen.findByRole("heading", { name: "Dashboard" }),
const loginRequest = fetch.mock.calls[2]?.[0] as URL; ).toBeInTheDocument();
expect(loginRequest.pathname).toBe("/api/v2/auth/login"); expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe(
"/api/v2/auth/login",
);
}); });
it("shows retryable dashboard errors", async () => { it("shows retryable dashboard errors", async () => {
const fetch = vi.fn() const fetch = vi
.fn()
.mockResolvedValueOnce(session()) .mockResolvedValueOnce(session())
.mockResolvedValueOnce(problem(500, "service_unavailable", "Dashboard data is unavailable.")) .mockResolvedValueOnce(
problem(500, "service_unavailable", "Dashboard data is unavailable."),
)
.mockResolvedValueOnce(repositories()); .mockResolvedValueOnce(repositories());
vi.stubGlobal("fetch", fetch); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent(
expect(await screen.findByRole("alert")).toHaveTextContent("Dashboard data is unavailable."); "Dashboard data is unavailable.",
);
fireEvent.click(screen.getByRole("button", { name: "Try again" })); 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 () => { it("loads source and job states from generated client methods", async () => {
const fetch = vi.fn() const fetch = vi
.fn()
.mockResolvedValueOnce(session()) .mockResolvedValueOnce(session())
.mockResolvedValueOnce(repositories()) .mockResolvedValueOnce(repositories())
.mockResolvedValueOnce(response({ items: [] })) .mockResolvedValueOnce(response({ items: [] }))
.mockResolvedValueOnce(problem(500, "service_unavailable", "Jobs are unavailable.")); .mockResolvedValueOnce(
problem(500, "service_unavailable", "Jobs are unavailable."),
);
vi.stubGlobal("fetch", fetch); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
await screen.findByText("No repositories have been configured."); await screen.findByText("No repositories have been configured.");
fireEvent.click(screen.getByRole("button", { name: "Sources" })); fireEvent.click(screen.getByRole("link", { name: "Sources" }));
expect(await screen.findByText("No sources have been configured.")).toBeInTheDocument(); expect(
fireEvent.click(screen.getByRole("button", { name: "Jobs & schedules" })); await screen.findByText("No sources have been configured."),
expect(await screen.findByRole("alert")).toHaveTextContent("Jobs are unavailable."); ).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 () => { 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 execution = {
const fetch = vi.fn() id: "execution-1",
state: "failed",
attempt: 2,
revision: 3,
reason_code: "timeout",
progress: {},
};
const fetch = vi
.fn()
.mockResolvedValueOnce(session()) .mockResolvedValueOnce(session())
.mockResolvedValueOnce(repositories()) .mockResolvedValueOnce(repositories())
.mockResolvedValueOnce(response({ items: [execution] })) .mockResolvedValueOnce(response({ items: [execution] }))
.mockResolvedValueOnce(response(execution)); .mockResolvedValueOnce(response(execution));
vi.stubGlobal("fetch", fetch); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
await screen.findByText("No repositories have been configured."); await screen.findByText("No repositories have been configured.");
fireEvent.click(screen.getByRole("button", { name: "Executions" })); fireEvent.click(screen.getByRole("link", { name: "Executions" }));
expect(await screen.findByRole("button", { name: "Execution execution-1" })).toBeInTheDocument(); expect(
fireEvent.click(screen.getByRole("button", { name: "Execution execution-1" })); await screen.findByRole("button", { name: "Execution execution-1" }),
expect(await screen.findByRole("heading", { name: "Execution detail" })).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Execution execution-1" }),
);
expect(
await screen.findByRole("heading", { name: "Execution detail" }),
).toBeInTheDocument();
expect(screen.getByText("timeout")).toBeInTheDocument(); expect(screen.getByText("timeout")).toBeInTheDocument();
}); });
it("cancels a running execution from its detail view", async () => {
it("announces SSE reconnects and applies live execution updates", async () => { window.history.replaceState({}, "", "/executions");
const execution = {
id: "execution-1",
state: "running",
attempt: 1,
revision: 1,
reason_code: null,
progress: {},
};
class EventSourceMock { class EventSourceMock {
static instances: EventSourceMock[] = [];
onmessage: ((event: MessageEvent<string>) => void) | null = null; onmessage: ((event: MessageEvent<string>) => void) | null = null;
onerror: (() => void) | null = null; onerror: (() => void) | null = null;
constructor() { EventSourceMock.instances.push(this); } addEventListener() {}
close = vi.fn(); close() {}
} }
vi.stubGlobal("EventSource", EventSourceMock); vi.stubGlobal("EventSource", EventSourceMock);
const execution = { id: "execution-1", state: "running", attempt: 1, revision: 1, reason_code: null, progress: {} }; const fetch = vi
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()).mockResolvedValueOnce(response({ items: [execution] })).mockResolvedValueOnce(response(execution)); .fn()
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(session())
.mockResolvedValueOnce(repositories()) .mockResolvedValueOnce(response({ items: [execution] }))
.mockResolvedValueOnce(response({ items: [] })) .mockResolvedValueOnce(response(execution))
.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(
.mockResolvedValueOnce(response({ delivery_id: "delivery-1" }, 202)); response({ ...execution, state: "cancelling" }, 202),
)
.mockResolvedValueOnce(response({ ...execution, state: "cancelling" }))
.mockResolvedValueOnce(
response({ items: [{ ...execution, state: "cancelling" }] }),
);
vi.stubGlobal("fetch", fetch); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
await screen.findByText("No repositories have been configured."); fireEvent.click(await screen.findByRole("link", { name: "Executions" }));
fireEvent.click(screen.getByRole("button", { name: "Notifications" })); fireEvent.click(
fireEvent.click(await screen.findByRole("button", { name: "Retry delivery" })); await screen.findByRole("button", { name: "Execution execution-1" }),
expect(await screen.findByRole("status")).toHaveTextContent("Delivery retry queued."); );
const options = fetch.mock.calls[4]?.[1] as RequestInit; fireEvent.click(
expect(new Headers(options.headers).get("Idempotency-Key")).toBeTruthy(); 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 () => { 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); vi.stubGlobal("fetch", fetch);
render(<App />); render(<App />);
expect(
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument(); await screen.findByRole("heading", { name: "Sign in" }),
expect(screen.getByRole("status")).toHaveTextContent("Your session expired"); ).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Your session expired",
);
}); });
}); });
+167 -18
View File
@@ -1,4 +1,5 @@
import { type FormEvent, useEffect, useRef, useState } from "react"; import { type FormEvent, useEffect, useRef, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import { import {
BackupToolClient, BackupToolClient,
@@ -17,12 +18,19 @@ type Screen =
const defaultClient = new BackupToolClient(); const defaultClient = new BackupToolClient();
function errorMessage(error: unknown): string { 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."; return "We could not reach the Backup Tool service. Check your connection and try again.";
} }
function isSetupRequired(error: unknown): boolean { 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 { function isSessionExpired(error: unknown): boolean {
@@ -56,10 +64,92 @@ function AuthForm({
} }
} }
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>; 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 }) { function AppContent({ client = defaultClient }: { client?: BackupToolClient }) {
const [screen, setScreen] = useState<Screen>({ kind: "loading" }); const [screen, setScreen] = useState<Screen>({ kind: "loading" });
const clientRef = useRef(client); const clientRef = useRef(client);
clientRef.current = client; clientRef.current = client;
@@ -67,7 +157,10 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) {
async function discover() { async function discover() {
setScreen({ kind: "loading" }); setScreen({ kind: "loading" });
try { try {
setScreen({ kind: "operator", user: await clientRef.current.getSession() }); setScreen({
kind: "operator",
user: await clientRef.current.getSession(),
});
} catch (error) { } catch (error) {
if (!isSessionExpired(error)) { if (!isSessionExpired(error)) {
setScreen({ kind: "auth", mode: "login", error: errorMessage(error) }); setScreen({ kind: "auth", mode: "login", error: errorMessage(error) });
@@ -77,21 +170,77 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) {
await clientRef.current.readyz(); await clientRef.current.readyz();
setScreen({ kind: "auth", mode: "login" }); setScreen({ kind: "auth", mode: "login" });
} catch (readinessError) { } 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(); }, []); useEffect(() => {
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>; void discover();
if (screen.kind === "auth") return <AuthForm {...screen} onSubmit={async (username, password) => { }, []);
try { if (screen.kind === "loading")
const user = screen.mode === "setup" ? await clientRef.current.setup({ body: { username, password } }) : await clientRef.current.login({ body: { username, password } }); return (
setScreen({ kind: "operator", user: { ...user, state: "active" } }); <main
} catch (error) { aria-live="polite"
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." }); className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100"
else setScreen({ ...screen, error: errorMessage(error) }); role="status"
} >
}} />; Checking your session
return <OperatorViews client={clientRef.current} onSessionExpired={() => setScreen({ kind: "auth", mode: "login", sessionExpired: true })} user={screen.user} />; </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}
/>
);
}
export function App({ client }: { client?: BackupToolClient }) {
return (
<BrowserRouter>
<AppContent client={client} />
</BrowserRouter>
);
} }
+647 -19
View File
@@ -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 BackupSummary = Components["schemas"]["BackupSummary"];
type Delivery = Components["schemas"]["NotificationDeliverySummary"]; type Delivery = Components["schemas"]["NotificationDeliverySummary"];
@@ -10,24 +14,648 @@ type State<T> = { loading: boolean; data?: T; error?: string };
type Props = { client: BackupToolClient; onSessionExpired: () => void }; 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 message(error: unknown) {
function expired(error: unknown) { return isApiError(error) && error.status === 401; } return isApiError(error)
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>; } ? (error.problem?.detail ?? "The request failed.")
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; } : "The service could not be reached.";
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 expired(error: unknown) {
function idempotencyKey(): string { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; } return isApiError(error) && error.status === 401;
}
export function BackupsPage({ client, onSessionExpired }: Props) { function Panel({ children, title }: { children: ReactNode; title: string }) {
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>(); return (
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)});}}; <section
useEffect(()=>{void load();},[]); aria-labelledby="page-title"
const headers=csrfHeaders(); className="mx-auto max-w-6xl p-4 sm:p-6"
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>; >
<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 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 BackupsPage({ client, onSessionExpired }: Props) {
const [state, setState] = useState<State<BackupSummary[]>>({ loading: true });
const [selected, setSelected] = useState<BackupSummary>();
const [preview, setPreview] = useState<string>();
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [destination, setDestination] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [overwriteConfirmation, setOverwriteConfirmation] = 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>
<label className="mt-3 block text-sm">
Type backup ID to confirm deletion
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) =>
setDeleteConfirmation(event.target.value)
}
value={deleteConfirmation}
/>
</label>
{deleteConfirmation === selected.id ? (
<p className="mt-2 text-sm text-slate-300">
Deletion is confirmed locally, but this v2 API intentionally
exposes no delete endpoint. Use the CLI after reviewing this
impact preview.
</p>
) : null}
</>
) : null}
<form
className="mt-4 space-y-2"
onSubmit={async (event) => {
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));
}
}}
>
<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}
/>
<label className="flex gap-2">
<input
checked={overwrite}
onChange={(event) => setOverwrite(event.target.checked)}
type="checkbox"
/>
Overwrite existing destination
</label>
{overwrite ? (
<label className="block text-sm">
Type the exact destination to confirm overwrite
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) =>
setOverwriteConfirmation(event.target.value)
}
value={overwriteConfirmation}
/>
</label>
) : null}
<button
className="rounded border border-slate-500 px-3 py-2"
type="submit"
>
{overwrite ? "Queue confirmed restore" : "Queue restore dry run"}
</button>
{restoreStatus ? <p role="status">{restoreStatus}</p> : null}
</form>
</section>
) : 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 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 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>} 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<string>();
async function create(event: FormEvent<HTMLFormElement>) {
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 (
<form
className="mt-4 rounded border border-slate-700 bg-slate-900 p-4"
onSubmit={create}
>
<h3 className="font-semibold">Create notification subscription</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label>
Channel
<select
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) =>
setChannel(event.target.value as "webhook" | "email")
}
value={channel}
>
<option value="webhook">Webhook</option>
<option value="email">Email</option>
</select>
</label>
<label>
{channel === "webhook" ? "Webhook URL" : "Email address"}
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setDestination(event.target.value)}
required
type={channel === "webhook" ? "url" : "email"}
value={destination}
/>
</label>
<label className="sm:col-span-2">
Event filters (comma separated)
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setFilters(event.target.value)}
required
value={filters}
/>
</label>
{channel === "webhook" ? (
<label className="sm:col-span-2">
Signing secret (optional)
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setSecret(event.target.value)}
type="password"
value={secret}
/>
</label>
) : null}
</div>
<button
className="mt-4 rounded border border-slate-500 px-3 py-2"
type="submit"
>
Create subscription
</button>
{status ? (
<p className="mt-3" role="status">
{status}
</p>
) : null}
</form>
);
}
function SubscriptionActions({
client,
subscriptions,
}: {
client: BackupToolClient;
subscriptions: Subscription[];
}) {
const [id, setId] = useState("");
const [rotationSecret, setRotationSecret] = useState("");
const [status, setStatus] = useState<string>();
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<HTMLFormElement>) {
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 (
<section className="mt-4 rounded border border-slate-700 bg-slate-900 p-4">
<h3 className="font-semibold">Subscription actions</h3>
<label className="mt-3 block">
Subscription
<select
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
onChange={(event) => setId(event.target.value)}
value={id}
>
<option value="">Select subscription</option>
{subscriptions.map((item) => (
<option key={item.id} value={item.id}>
{item.channel} subscription
</option>
))}
</select>
</label>
<button
className="mt-3 rounded border border-slate-500 px-3 py-2"
disabled={!id}
onClick={() => void test()}
type="button"
>
Test selected subscription
</button>
<form className="mt-3" onSubmit={rotate}>
<label>
New webhook signing secret
<input
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
disabled={!id}
onChange={(event) => setRotationSecret(event.target.value)}
required
type="password"
value={rotationSecret}
/>
</label>
<button
className="mt-3 rounded border border-slate-500 px-3 py-2"
disabled={!id}
type="submit"
>
Rotate signing key
</button>
</form>
{status ? (
<p className="mt-3" role="status">
{status}
</p>
) : null}
</section>
);
}
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">
<NotificationControls client={client} onDone={() => void load()} />
<SubscriptionActions
client={client}
subscriptions={state.data?.subscriptions ?? []}
/>
{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>
);
}
File diff suppressed because it is too large Load Diff
+256 -139
View File
@@ -1,159 +1,276 @@
import { type ReactNode, useEffect, useState } from "react";
import { import {
type BackupToolClient, Component,
type Components, type ErrorInfo,
isApiError, type ReactNode,
} from "../api/generated/client"; lazy,
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows"; Suspense,
useEffect,
useRef,
} from "react";
import {
Navigate,
NavLink,
Route,
Routes,
useLocation,
} from "react-router-dom";
import type { BackupToolClient, Components } from "../api/generated/client";
import type { OperatorPageProps } from "./OperatorPages";
type SessionUser = Components["schemas"]["SessionUser"]; 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 }> = [ type RouteDefinition = {
{ id: "dashboard", label: "Dashboard" }, path: string;
{ id: "sources", label: "Sources" }, label: string;
{ id: "repositories", label: "Repositories" }, title: string;
{ id: "jobs", label: "Jobs & schedules" }, component: React.LazyExoticComponent<React.ComponentType<OperatorPageProps>>;
{ id: "executions", label: "Executions" }, };
{ id: "backups", label: "Backups" },
{ id: "security", label: "Security & recovery" }, const DashboardPage = lazy(() =>
{ id: "notifications", label: "Notifications" }, import("./OperatorPages").then(({ DashboardPage: Page }) => ({
{ id: "audit", label: "Audit" }, default: Page,
})),
);
const SourcesPage = lazy(() =>
import("./OperatorPages").then(({ SourcesPage: Page }) => ({
default: Page,
})),
);
const RepositoriesPage = lazy(() =>
import("./OperatorPages").then(({ RepositoriesPage: Page }) => ({
default: Page,
})),
);
const JobsPage = lazy(() =>
import("./OperatorPages").then(({ JobsPage: Page }) => ({ default: Page })),
);
const ExecutionsPage = lazy(() =>
import("./OperatorPages").then(({ ExecutionsPage: Page }) => ({
default: Page,
})),
);
const BackupsPage = lazy(() =>
import("./M13Workflows").then(({ BackupsPage: Page }) => ({ default: Page })),
);
const SecurityPage = lazy(() =>
import("./M13Workflows").then(({ SecurityPage: Page }) => ({
default: Page,
})),
);
const NotificationsPage = lazy(() =>
import("./M13Workflows").then(({ NotificationsPage: Page }) => ({
default: Page,
})),
);
const AuditPage = lazy(() =>
import("./M13Workflows").then(({ AuditPage: Page }) => ({ default: Page })),
);
const AdministrationPage = lazy(() =>
import("./AdministrationPage").then(({ AdministrationPage: Page }) => ({
default: Page,
})),
);
const routes: RouteDefinition[] = [
{
path: "/dashboard",
label: "Dashboard",
title: "Dashboard",
component: DashboardPage,
},
{
path: "/sources",
label: "Sources",
title: "Sources",
component: SourcesPage,
},
{
path: "/repositories",
label: "Repositories",
title: "Repositories",
component: RepositoriesPage,
},
{
path: "/jobs",
label: "Jobs & schedules",
title: "Jobs & schedules",
component: JobsPage,
},
{
path: "/executions",
label: "Executions",
title: "Executions",
component: ExecutionsPage,
},
{
path: "/backups",
label: "Backups",
title: "Backups",
component: BackupsPage,
},
{
path: "/security",
label: "Security & recovery",
title: "Security & recovery",
component: SecurityPage,
},
{
path: "/notifications",
label: "Notifications",
title: "Notifications",
component: NotificationsPage,
},
{ path: "/audit", label: "Audit", title: "Audit", component: AuditPage },
{
path: "/administration",
label: "Administration",
title: "Administration",
component: AdministrationPage,
},
]; ];
function errorMessage(error: unknown): string { class RouteErrorBoundary extends Component<
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request."; { children: ReactNode },
return "We could not reach the Backup Tool service. Check your connection and try again."; { error?: Error }
} > {
state: { error?: Error } = {};
function isSessionExpired(error: unknown): boolean { static getDerivedStateFromError(error: Error) {
return isApiError(error) && error.status === 401; return { error };
} }
function ErrorPanel({ message, retry }: { message: string; retry: () => void }) { componentDidCatch(_error: Error, _info: ErrorInfo) {}
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>;
} componentDidUpdate(previousProps: Readonly<{ children: ReactNode }>) {
if (this.state.error && previousProps.children !== this.props.children)
function Loading({ label }: { label: string }) { this.setState({ error: undefined });
return <p aria-live="polite" className="mt-4 text-slate-300" role="status">Loading {label}</p>; }
}
render() {
function ResourceSection({ children, title }: { children: ReactNode; title: string }) { if (this.state.error)
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>; return (
} <section className="mx-auto max-w-6xl p-4 sm:p-6">
<h2 className="text-xl font-semibold">Page unavailable</h2>
function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) { <p className="mt-3" role="alert">
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" }); This page could not be loaded. Try another page or refresh your
async function load() { browser.
setState({ kind: "loading" }); </p>
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); } </section>
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } );
return this.props.children;
} }
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 }) { function RouteLoading() {
const [state, setState] = useState<LoadState<SourceSummary[]>>({ kind: "loading" }); return (
async function load() { <section className="mx-auto max-w-6xl p-4 sm:p-6">
setState({ kind: "loading" }); <p aria-live="polite" role="status">
try { setState({ kind: "ready", items: (await client.listSources()).items }); } Loading page
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } </p>
} </section>
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 }) { function SkipLink() {
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" }); return (
async function load() { <a
setState({ kind: "loading" }); className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 focus:rounded focus:bg-slate-100 focus:px-4 focus:py-2 focus:text-slate-950"
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); } href="#main-content"
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } onClick={(event) => {
} event.preventDefault();
useEffect(() => { void load(); }, []); document.getElementById("main-content")?.focus();
return <ResourceSection title="Repositories"> }}
{state.kind === "loading" ? <Loading label="repositories" /> : null} >
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null} Skip to main content
{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} </a>
{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 }) { function PageRoute({
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" }); component: Page,
async function load() { ...props
setState({ kind: "loading" }); }: RouteDefinition & OperatorPageProps) {
try { setState({ kind: "ready", items: (await client.listJobs()).items }); } return (
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); } <RouteErrorBoundary>
} <Suspense fallback={<RouteLoading />}>
useEffect(() => { void load(); }, []); <Page {...props} />
return <ResourceSection title="Jobs & schedules"> </Suspense>
{state.kind === "loading" ? <Loading label="jobs" /> : null} </RouteErrorBoundary>
{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 }) { function RouteEffects() {
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" }); const { pathname } = useLocation();
const [selectedId, setSelectedId] = useState<string>(); const hasMounted = useRef(false);
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(() => { useEffect(() => {
if (!selectedId || typeof EventSource === "undefined") return; const route = routes.find((item) => item.path === pathname);
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } })); document.title = `${route?.title ?? "Dashboard"} | Backup Tool`;
stream.onmessage = (event) => { if (hasMounted.current) document.getElementById("main-content")?.focus();
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); } hasMounted.current = true;
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); } }, [pathname]);
};
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); }; return null;
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 }) { export function OperatorViews({
const [page, setPage] = useState<Page>("dashboard"); client,
const common = { client, onSessionExpired }; 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>; user,
}: {
client: BackupToolClient;
onSessionExpired: () => void;
user: SessionUser;
}) {
const pageProps = { client, onSessionExpired };
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<SkipLink />
<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"
>
{routes.map((item) => (
<NavLink
className={({ isActive }) =>
`whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800 focus-visible:bg-slate-800 ${isActive ? "bg-slate-800 text-emerald-300" : ""}`
}
key={item.path}
to={item.path}
>
{item.label}
</NavLink>
))}
</nav>
</header>
<main id="main-content" tabIndex={-1}>
<RouteEffects />
<Routes>
<Route element={<Navigate replace to="/dashboard" />} path="/" />
{routes.map((route) => (
<Route
element={<PageRoute {...route} {...pageProps} />}
key={route.path}
path={route.path}
/>
))}
<Route element={<Navigate replace to="/dashboard" />} path="*" />
</Routes>
</main>
</div>
);
} }
+161
View File
@@ -416,6 +416,84 @@
"title": "JobList", "title": "JobList",
"type": "object" "type": "object"
}, },
"JobPatch": {
"properties": {
"allow_empty": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Allow Empty"
},
"enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Enabled"
},
"exclusions": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Exclusions"
},
"name": {
"anyOf": [
{
"maxLength": 255,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
},
"requested_mode": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Requested Mode"
},
"retention": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Retention"
}
},
"title": "JobPatch",
"type": "object"
},
"JobSummary": { "JobSummary": {
"properties": { "properties": {
"enabled": { "enabled": {
@@ -2835,6 +2913,89 @@
"summary": "Create Job" "summary": "Create Job"
} }
}, },
"/api/v2/jobs/{job_id}": {
"patch": {
"operationId": "patch_job_api_v2_jobs__job_id__patch",
"parameters": [
{
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"title": "Job Id",
"type": "string"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
},
{
"in": "header",
"name": "X-CSRF-Token",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobPatch"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"title": "Response Patch Job Api V2 Jobs Job Id Patch",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Patch Job"
}
},
"/api/v2/jobs/{job_id}/executions": { "/api/v2/jobs/{job_id}/executions": {
"post": { "post": {
"operationId": "enqueue_execution_api_v2_jobs__job_id__executions_post", "operationId": "enqueue_execution_api_v2_jobs__job_id__executions_post",
+12
View File
@@ -97,6 +97,18 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
) )
assert job.status_code == 201 assert job.status_code == 201
assert "destination_path" not in job.json() assert "destination_path" not in job.json()
invalid_patch = await client.patch(
f"/api/v2/jobs/{job.json()['id']}", json={}, headers=headers
)
assert invalid_patch.status_code == 422
updated_job = await client.patch(
f"/api/v2/jobs/{job.json()['id']}",
json={"name": "renamed-job", "exclusions": ["*.cache"]},
headers=headers,
)
assert updated_job.status_code == 200
assert updated_job.json()["name"] == "renamed-job"
assert updated_job.json()["exclusions"] == ["*.cache"]
execution = await client.post( execution = await client.post(
f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers
) )