feat(ui): add routed operator workflows
This commit is contained in:
@@ -337,6 +337,15 @@ class JobInput(BaseModel):
|
||||
allow_empty: bool = False
|
||||
|
||||
|
||||
class JobPatch(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
requested_mode: str | None = None
|
||||
exclusions: list[str] | None = None
|
||||
retention: dict[str, Any] | None = None
|
||||
enabled: bool | None = None
|
||||
allow_empty: bool | None = None
|
||||
|
||||
|
||||
class TokenInput(BaseModel):
|
||||
scopes: list[str] = Field(min_length=1)
|
||||
expires_at: datetime | None = None
|
||||
@@ -1152,6 +1161,40 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
"state": job.state,
|
||||
}
|
||||
|
||||
@app.patch("/api/v2/jobs/{job_id}")
|
||||
async def patch_job(
|
||||
job_id: str,
|
||||
input_: JobPatch,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(session)],
|
||||
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||
) -> dict[str, Any]:
|
||||
job = await db.get(Job, job_id)
|
||||
if job is None or job.state != "active":
|
||||
raise Problem(404, "resource_not_found", "Job was not found.")
|
||||
changes = input_.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
raise Problem(422, "validation_failed", "No mutable fields supplied.")
|
||||
if changes.get("requested_mode") not in {None, "full", "incremental"}:
|
||||
raise Problem(422, "validation_failed", "Invalid requested mode.")
|
||||
for field, value in changes.items():
|
||||
setattr(job, field, value)
|
||||
await audit(db, request, "update", "job", job.id, "success", identity[0].id)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return {
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"source_id": job.source_id,
|
||||
"repository_id": job.repository_id,
|
||||
"requested_mode": job.requested_mode,
|
||||
"exclusions": job.exclusions,
|
||||
"retention": job.retention,
|
||||
"enabled": job.enabled,
|
||||
"allow_empty": job.allow_empty,
|
||||
"state": job.state,
|
||||
}
|
||||
|
||||
@app.post("/api/v2/jobs/{job_id}/schedule", status_code=201)
|
||||
async def create_schedule(
|
||||
job_id: str,
|
||||
|
||||
@@ -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.
|
||||
Generated
+59
-1
@@ -9,7 +9,8 @@
|
||||
"version": "2.0.0-dev.0",
|
||||
"dependencies": {
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
@@ -1645,6 +1646,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -2917,6 +2931,44 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -3074,6 +3126,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Components {
|
||||
HTTPValidationError: { detail?: Array<Components['schemas']["ValidationError"]> };
|
||||
JobInput: { allow_empty?: boolean; enabled?: boolean; exclusions?: Array<string>; name : string; repository_id : string; requested_mode?: string; retention?: Record<string, unknown>; source_id : string };
|
||||
JobList: { items : Array<Components['schemas']["JobSummary"]> };
|
||||
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 };
|
||||
LocalSourceInput: { kind : string; name : string; public_config : Record<string, unknown> };
|
||||
LoginInput: { password : string; username : string };
|
||||
@@ -82,6 +83,8 @@ export type RetryExecutionParams = { path: { execution_id: string } };
|
||||
|
||||
export type CreateJobParams = { body : Components['schemas']["JobInput"] };
|
||||
|
||||
export type PatchJobParams = { path: { job_id: string }; body : Components['schemas']["JobPatch"] };
|
||||
|
||||
export type EnqueueExecutionParams = { path: { job_id: string } };
|
||||
|
||||
export type DeleteScheduleParams = { path: { job_id: string } };
|
||||
@@ -346,6 +349,13 @@ export class BackupToolClient {
|
||||
);
|
||||
}
|
||||
|
||||
async patchJob(params: PatchJobParams, options: RequestOptions = {}): Promise<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>> {
|
||||
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>>(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+174
-98
@@ -1,4 +1,10 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
@@ -11,166 +17,236 @@ function response(body: unknown, status = 200): Response {
|
||||
status,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function problem(status: number, code: string, detail = "Request failed."): Response {
|
||||
return response({ type: `https://backup-tool.invalid/problems/${code}`, title: code, status, detail, instance: "/", code }, status);
|
||||
function problem(
|
||||
status: number,
|
||||
code: string,
|
||||
detail = "Request failed.",
|
||||
): Response {
|
||||
return response(
|
||||
{
|
||||
type: `https://backup-tool.invalid/problems/${code}`,
|
||||
title: code,
|
||||
status,
|
||||
detail,
|
||||
instance: "/",
|
||||
code,
|
||||
},
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function session() {
|
||||
return response({ id: "user-1", username: "operator", state: "active" });
|
||||
}
|
||||
|
||||
function repositories(items: unknown[] = []) {
|
||||
return response({ items });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
describe("operator foundation", () => {
|
||||
it("shows loading then the accessible empty dashboard", async () => {
|
||||
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Checking your session");
|
||||
expect(await screen.findByText("No repositories have been configured.")).toBeInTheDocument();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("selects setup and creates a session for the first administrator", async () => {
|
||||
const fetch = vi.fn()
|
||||
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
||||
.mockResolvedValueOnce(problem(503, "setup_required"))
|
||||
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }, 201))
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(repositories());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Set up your administrator account" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create administrator account" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||
const setupRequest = fetch.mock.calls[2]?.[0] as URL;
|
||||
expect(setupRequest.pathname).toBe("/api/v2/setup");
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Checking your session",
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("No repositories have been configured."),
|
||||
).toBeInTheDocument();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("selects setup and creates a session for the first administrator", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
||||
.mockResolvedValueOnce(problem(503, "setup_required"))
|
||||
.mockResolvedValueOnce(
|
||||
response({ id: "user-1", username: "operator" }, 201),
|
||||
)
|
||||
.mockResolvedValueOnce(repositories());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Set up your administrator account",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Username"), {
|
||||
target: { value: "operator" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Password"), {
|
||||
target: { value: "correct horse battery staple" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create administrator account" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Dashboard" }),
|
||||
).toBeInTheDocument();
|
||||
expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe("/api/v2/setup");
|
||||
});
|
||||
|
||||
it("signs in after setup is complete", async () => {
|
||||
const fetch = vi.fn()
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
||||
.mockResolvedValueOnce(response({ status: "ready" }))
|
||||
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }))
|
||||
.mockResolvedValueOnce(repositories());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||
const loginRequest = fetch.mock.calls[2]?.[0] as URL;
|
||||
expect(loginRequest.pathname).toBe("/api/v2/auth/login");
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Sign in" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Username"), {
|
||||
target: { value: "operator" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Password"), {
|
||||
target: { value: "correct horse battery staple" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Dashboard" }),
|
||||
).toBeInTheDocument();
|
||||
expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe(
|
||||
"/api/v2/auth/login",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows retryable dashboard errors", async () => {
|
||||
const fetch = vi.fn()
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(problem(500, "service_unavailable", "Dashboard data is unavailable."))
|
||||
.mockResolvedValueOnce(
|
||||
problem(500, "service_unavailable", "Dashboard data is unavailable."),
|
||||
)
|
||||
.mockResolvedValueOnce(repositories());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Dashboard data is unavailable.");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Dashboard data is unavailable.",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await waitFor(() => expect(screen.getByText("No repositories have been configured.")).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("No repositories have been configured."),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads source and job states from generated client methods", async () => {
|
||||
const fetch = vi.fn()
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(repositories())
|
||||
.mockResolvedValueOnce(response({ items: [] }))
|
||||
.mockResolvedValueOnce(problem(500, "service_unavailable", "Jobs are unavailable."));
|
||||
.mockResolvedValueOnce(
|
||||
problem(500, "service_unavailable", "Jobs are unavailable."),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("No repositories have been configured.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sources" }));
|
||||
expect(await screen.findByText("No sources have been configured.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Jobs & schedules" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Jobs are unavailable.");
|
||||
fireEvent.click(screen.getByRole("link", { name: "Sources" }));
|
||||
expect(
|
||||
await screen.findByText("No sources have been configured."),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("link", { name: "Jobs & schedules" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Jobs are unavailable.",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads execution detail after selecting an execution", async () => {
|
||||
const execution = { id: "execution-1", state: "failed", attempt: 2, revision: 3, reason_code: "timeout", progress: {} };
|
||||
const fetch = vi.fn()
|
||||
const execution = {
|
||||
id: "execution-1",
|
||||
state: "failed",
|
||||
attempt: 2,
|
||||
revision: 3,
|
||||
reason_code: "timeout",
|
||||
progress: {},
|
||||
};
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(repositories())
|
||||
.mockResolvedValueOnce(response({ items: [execution] }))
|
||||
.mockResolvedValueOnce(response(execution));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
await screen.findByText("No repositories have been configured.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Executions" }));
|
||||
expect(await screen.findByRole("button", { name: "Execution execution-1" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Execution execution-1" }));
|
||||
expect(await screen.findByRole("heading", { name: "Execution detail" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("link", { name: "Executions" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Execution execution-1" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Execution execution-1" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Execution detail" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("timeout")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces SSE reconnects and applies live execution updates", async () => {
|
||||
it("cancels a running execution from its detail view", async () => {
|
||||
window.history.replaceState({}, "", "/executions");
|
||||
const execution = {
|
||||
id: "execution-1",
|
||||
state: "running",
|
||||
attempt: 1,
|
||||
revision: 1,
|
||||
reason_code: null,
|
||||
progress: {},
|
||||
};
|
||||
class EventSourceMock {
|
||||
static instances: EventSourceMock[] = [];
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
constructor() { EventSourceMock.instances.push(this); }
|
||||
close = vi.fn();
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal("EventSource", EventSourceMock);
|
||||
const execution = { id: "execution-1", state: "running", attempt: 1, revision: 1, reason_code: null, progress: {} };
|
||||
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()).mockResolvedValueOnce(response({ items: [execution] })).mockResolvedValueOnce(response(execution));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<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()
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(repositories())
|
||||
.mockResolvedValueOnce(response({ items: [] }))
|
||||
.mockResolvedValueOnce(response({ items: [{ id: "delivery-1", event_id: "event-1", subscription_id: "subscription-1", state: "failed", attempt_count: 1, response_class: null, response_summary: null, terminal_reason: "timeout", due_at: "2026-01-01T00:00:00Z" }] }))
|
||||
.mockResolvedValueOnce(response({ delivery_id: "delivery-1" }, 202));
|
||||
.mockResolvedValueOnce(response({ items: [execution] }))
|
||||
.mockResolvedValueOnce(response(execution))
|
||||
.mockResolvedValueOnce(
|
||||
response({ ...execution, state: "cancelling" }, 202),
|
||||
)
|
||||
.mockResolvedValueOnce(response({ ...execution, state: "cancelling" }))
|
||||
.mockResolvedValueOnce(
|
||||
response({ items: [{ ...execution, state: "cancelling" }] }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
await screen.findByText("No repositories have been configured.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Retry delivery" }));
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Delivery retry queued.");
|
||||
const options = fetch.mock.calls[4]?.[1] as RequestInit;
|
||||
expect(new Headers(options.headers).get("Idempotency-Key")).toBeTruthy();
|
||||
fireEvent.click(await screen.findByRole("link", { name: "Executions" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Execution execution-1" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Cancel execution" }),
|
||||
);
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||
"Execution cancel requested.",
|
||||
);
|
||||
expect((fetch.mock.calls[3]?.[0] as URL).pathname).toBe(
|
||||
"/api/v2/executions/execution-1/cancel",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to sign-in when the dashboard request finds an expired session", async () => {
|
||||
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(problem(401, "authentication_required"));
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(problem(401, "authentication_required"));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Your session expired");
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Sign in" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Your session expired",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+162
-13
@@ -1,4 +1,5 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import {
|
||||
BackupToolClient,
|
||||
@@ -17,12 +18,19 @@ type Screen =
|
||||
const defaultClient = new BackupToolClient();
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
|
||||
if (isApiError(error))
|
||||
return (
|
||||
error.problem?.detail ?? "The server could not complete that request."
|
||||
);
|
||||
return "We could not reach the Backup Tool service. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function isSetupRequired(error: unknown): boolean {
|
||||
return isApiError(error) && error.status === 503 && error.problem?.code === "setup_required";
|
||||
return (
|
||||
isApiError(error) &&
|
||||
error.status === 503 &&
|
||||
error.problem?.code === "setup_required"
|
||||
);
|
||||
}
|
||||
|
||||
function isSessionExpired(error: unknown): boolean {
|
||||
@@ -56,10 +64,92 @@ function AuthForm({
|
||||
}
|
||||
}
|
||||
|
||||
return <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 clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
@@ -67,7 +157,10 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) {
|
||||
async function discover() {
|
||||
setScreen({ kind: "loading" });
|
||||
try {
|
||||
setScreen({ kind: "operator", user: await clientRef.current.getSession() });
|
||||
setScreen({
|
||||
kind: "operator",
|
||||
user: await clientRef.current.getSession(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionExpired(error)) {
|
||||
setScreen({ kind: "auth", mode: "login", error: errorMessage(error) });
|
||||
@@ -77,21 +170,77 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) {
|
||||
await clientRef.current.readyz();
|
||||
setScreen({ kind: "auth", mode: "login" });
|
||||
} catch (readinessError) {
|
||||
setScreen({ kind: "auth", mode: isSetupRequired(readinessError) ? "setup" : "login", error: isSetupRequired(readinessError) ? undefined : errorMessage(readinessError) });
|
||||
setScreen({
|
||||
kind: "auth",
|
||||
mode: isSetupRequired(readinessError) ? "setup" : "login",
|
||||
error: isSetupRequired(readinessError)
|
||||
? undefined
|
||||
: errorMessage(readinessError),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void discover(); }, []);
|
||||
if (screen.kind === "loading") return <main aria-live="polite" className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100" role="status">Checking your session…</main>;
|
||||
if (screen.kind === "auth") return <AuthForm {...screen} onSubmit={async (username, password) => {
|
||||
useEffect(() => {
|
||||
void discover();
|
||||
}, []);
|
||||
if (screen.kind === "loading")
|
||||
return (
|
||||
<main
|
||||
aria-live="polite"
|
||||
className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100"
|
||||
role="status"
|
||||
>
|
||||
Checking your session…
|
||||
</main>
|
||||
);
|
||||
if (screen.kind === "auth")
|
||||
return (
|
||||
<AuthForm
|
||||
{...screen}
|
||||
onSubmit={async (username, password) => {
|
||||
try {
|
||||
const user = screen.mode === "setup" ? await clientRef.current.setup({ body: { username, password } }) : await clientRef.current.login({ body: { username, password } });
|
||||
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." });
|
||||
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} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { type BackupToolClient, type Components, isApiError } from "../api/generated/client";
|
||||
import {
|
||||
type BackupToolClient,
|
||||
type Components,
|
||||
isApiError,
|
||||
} from "../api/generated/client";
|
||||
|
||||
type BackupSummary = Components["schemas"]["BackupSummary"];
|
||||
type Delivery = Components["schemas"]["NotificationDeliverySummary"];
|
||||
@@ -10,24 +14,648 @@ type State<T> = { loading: boolean; data?: T; error?: string };
|
||||
|
||||
type Props = { client: BackupToolClient; onSessionExpired: () => void };
|
||||
|
||||
function message(error: unknown) { return isApiError(error) ? error.problem?.detail ?? "The request failed." : "The service could not be reached."; }
|
||||
function expired(error: unknown) { return isApiError(error) && error.status === 401; }
|
||||
function Panel({ children, title }: { children: ReactNode; title: string }) { return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>; }
|
||||
function Retry({ error, load }: { error?: string; load: () => void }) { return error ? <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{error}</p><button className="mt-3 rounded border border-slate-500 px-3 py-2" onClick={load} type="button">Try again</button></div> : null; }
|
||||
function Loading({ label }: { label: string }) { return <p className="mt-4 text-slate-300" role="status">Loading {label}…</p>; }
|
||||
function csrfHeaders(): HeadersInit { const value = document.cookie.split("; ").find((entry) => entry.startsWith("backup_tool_csrf="))?.split("=", 2)[1]; return value ? { "X-CSRF-Token": value } : {}; }
|
||||
function idempotencyKey(): string { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; }
|
||||
|
||||
export function BackupsPage({ client, onSessionExpired }: Props) {
|
||||
const [state,setState]=useState<State<BackupSummary[]>>({loading:true}); const [selected,setSelected]=useState<BackupSummary>(); const [preview,setPreview]=useState<string>(); const [destination,setDestination]=useState(""); const [restoreStatus,setRestoreStatus]=useState<string>();
|
||||
const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listBackups()).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};
|
||||
useEffect(()=>{void load();},[]);
|
||||
const headers=csrfHeaders();
|
||||
return <Panel title="Backups">{state.loading?<Loading label="backups"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?.length===0?<p className="mt-4 text-slate-300">No backups have been committed.</p>:null}<ul className="mt-4 space-y-3" aria-label="Backups">{state.data?.map(item=><li className="rounded border border-slate-700 bg-slate-900 p-4" key={item.id}><button className="font-semibold hover:text-emerald-300" onClick={()=>setSelected(item)} type="button">Backup {item.id}</button><p className="text-sm text-slate-300">{item.integrity} · {item.logical_bytes} logical bytes</p></li>)}</ul>{selected?<section className="mt-6 rounded border border-slate-700 bg-slate-900 p-4" aria-labelledby="backup-detail"><h3 id="backup-detail" className="font-semibold">Backup detail</h3><p className="mt-2">Integrity: {selected.integrity}</p><div className="mt-3 flex flex-wrap gap-2"><button className="rounded border border-slate-500 px-3 py-2" onClick={async()=>{try{setSelected(await client.verifyBackup({path:{backup_id:selected.id}},{headers}));}catch(error){setPreview(message(error));}}} type="button">Verify</button><button className="rounded border border-slate-500 px-3 py-2" onClick={async()=>{try{const result=await client.backupDeletePreview({path:{backup_id:selected.id}},{headers});setPreview(result.reason??result.destructive_action);}catch(error){setPreview(message(error));}}} type="button">Preview deletion</button></div>{preview?<p className="mt-3" role="status">{preview}</p>:null}<form className="mt-4 space-y-2" onSubmit={async(event)=>{event.preventDefault();try{await client.createRestore({path:{backup_id:selected.id},body:{destination,dry_run:true,selection:[],overwrite_policy:"fail"}},{headers});setRestoreStatus("Restore dry run queued.");}catch(error){setRestoreStatus(message(error));}}}><label className="block text-sm font-medium" htmlFor="restore-destination">Restore destination</label><input className="w-full rounded border border-slate-500 bg-slate-950 p-2" id="restore-destination" onChange={(event)=>setDestination(event.target.value)} required value={destination}/><button className="rounded border border-slate-500 px-3 py-2" type="submit">Queue restore dry run</button>{restoreStatus?<p role="status">{restoreStatus}</p>:null}</form></section>:null}</Panel>;
|
||||
function message(error: unknown) {
|
||||
return isApiError(error)
|
||||
? (error.problem?.detail ?? "The request failed.")
|
||||
: "The service could not be reached.";
|
||||
}
|
||||
function expired(error: unknown) {
|
||||
return isApiError(error) && error.status === 401;
|
||||
}
|
||||
function Panel({ children, title }: { children: ReactNode; title: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="page-title"
|
||||
className="mx-auto max-w-6xl p-4 sm:p-6"
|
||||
>
|
||||
<h2 className="text-xl font-semibold" id="page-title">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function Retry({ error, load }: { error?: string; load: () => void }) {
|
||||
return error ? (
|
||||
<div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4">
|
||||
<p role="alert">{error}</p>
|
||||
<button
|
||||
className="mt-3 rounded border border-slate-500 px-3 py-2"
|
||||
onClick={load}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
function Loading({ label }: { label: string }) {
|
||||
return (
|
||||
<p className="mt-4 text-slate-300" role="status">
|
||||
Loading {label}…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
function csrfHeaders(): HeadersInit {
|
||||
const value = document.cookie
|
||||
.split("; ")
|
||||
.find((entry) => entry.startsWith("backup_tool_csrf="))
|
||||
?.split("=", 2)[1];
|
||||
return value ? { "X-CSRF-Token": value } : {};
|
||||
}
|
||||
function idempotencyKey(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
|
||||
export function 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
+253
-136
@@ -1,159 +1,276 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
type BackupToolClient,
|
||||
type Components,
|
||||
isApiError,
|
||||
} from "../api/generated/client";
|
||||
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows";
|
||||
Component,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
lazy,
|
||||
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 SourceSummary = Components["schemas"]["SourceSummary"];
|
||||
type RepositorySummary = Components["schemas"]["RepositorySummary"];
|
||||
type JobSummary = Components["schemas"]["JobSummary"];
|
||||
type ExecutionSummary = Components["schemas"]["ExecutionSummary"];
|
||||
type Page = "dashboard" | "sources" | "repositories" | "jobs" | "executions" | "backups" | "security" | "notifications" | "audit";
|
||||
type LoadState<T> =
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; items: T }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
const pages: Array<{ id: Page; label: string }> = [
|
||||
{ id: "dashboard", label: "Dashboard" },
|
||||
{ id: "sources", label: "Sources" },
|
||||
{ id: "repositories", label: "Repositories" },
|
||||
{ id: "jobs", label: "Jobs & schedules" },
|
||||
{ id: "executions", label: "Executions" },
|
||||
{ id: "backups", label: "Backups" },
|
||||
{ id: "security", label: "Security & recovery" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "audit", label: "Audit" },
|
||||
type RouteDefinition = {
|
||||
path: string;
|
||||
label: string;
|
||||
title: string;
|
||||
component: React.LazyExoticComponent<React.ComponentType<OperatorPageProps>>;
|
||||
};
|
||||
|
||||
const DashboardPage = lazy(() =>
|
||||
import("./OperatorPages").then(({ DashboardPage: Page }) => ({
|
||||
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 {
|
||||
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.";
|
||||
class RouteErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
function isSessionExpired(error: unknown): boolean {
|
||||
return isApiError(error) && error.status === 401;
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo) {}
|
||||
|
||||
componentDidUpdate(previousProps: Readonly<{ children: ReactNode }>) {
|
||||
if (this.state.error && previousProps.children !== this.props.children)
|
||||
this.setState({ error: undefined });
|
||||
}
|
||||
|
||||
function ErrorPanel({ message, retry }: { message: string; retry: () => void }) {
|
||||
return <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{message}</p><button className="mt-3 rounded-md border border-slate-500 px-3 py-2 font-semibold hover:bg-slate-800" onClick={retry} type="button">Try again</button></div>;
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<h2 className="text-xl font-semibold">Page unavailable</h2>
|
||||
<p className="mt-3" role="alert">
|
||||
This page could not be loaded. Try another page or refresh your
|
||||
browser.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function Loading({ label }: { label: string }) {
|
||||
return <p aria-live="polite" className="mt-4 text-slate-300" role="status">Loading {label}…</p>;
|
||||
function RouteLoading() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<p aria-live="polite" role="status">
|
||||
Loading page…
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSection({ children, title }: { children: ReactNode; title: string }) {
|
||||
return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>;
|
||||
function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
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"
|
||||
href="#main-content"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document.getElementById("main-content")?.focus();
|
||||
}}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Dashboard">
|
||||
<p className="mt-2 text-slate-300">Repository availability at a glance.</p>
|
||||
{state.kind === "loading" ? <Loading label="dashboard data" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 grid gap-3 sm:grid-cols-2">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-2 text-sm text-slate-300">{repository.state} · {repository.encryption}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
function PageRoute({
|
||||
component: Page,
|
||||
...props
|
||||
}: RouteDefinition & OperatorPageProps) {
|
||||
return (
|
||||
<RouteErrorBoundary>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Page {...props} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<SourceSummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listSources()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Sources">
|
||||
{state.kind === "loading" ? <Loading label="sources" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No sources have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured sources" className="mt-4 space-y-3">{state.items.map((source) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={source.id}><h3 className="font-semibold">{source.name}</h3><p className="mt-1 text-sm text-slate-300">{source.kind} · {source.state}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
function RouteEffects() {
|
||||
const { pathname } = useLocation();
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
function RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Repositories">
|
||||
{state.kind === "loading" ? <Loading label="repositories" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 space-y-3">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-1 text-sm text-slate-300">Format {repository.format_version} · {repository.encryption} · {repository.state}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
|
||||
function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listJobs()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Jobs & schedules">
|
||||
{state.kind === "loading" ? <Loading label="jobs" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No jobs have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured jobs" className="mt-4 space-y-3">{state.items.map((job) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={job.id}><h3 className="font-semibold">{job.name}</h3><p className="mt-1 text-sm text-slate-300">{job.requested_mode} · {job.enabled ? "enabled" : "disabled"}</p><p className="mt-2 text-sm text-slate-300">{job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
|
||||
function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" });
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [detail, setDetail] = useState<LoadState<ExecutionSummary> | undefined>();
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listExecutions()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
async function loadDetail(executionId: string) {
|
||||
setSelectedId(executionId); setDetail({ kind: "loading" });
|
||||
try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
useEffect(() => {
|
||||
if (!selectedId || typeof EventSource === "undefined") return;
|
||||
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } }));
|
||||
stream.onmessage = (event) => {
|
||||
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); }
|
||||
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); }
|
||||
};
|
||||
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); };
|
||||
return () => stream.close();
|
||||
}, [client, selectedId]);
|
||||
return <ResourceSection title="Executions">
|
||||
{state.kind === "loading" ? <Loading label="executions" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No executions have been queued.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Executions" className="mt-4 space-y-3">{state.items.map((execution) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={execution.id}><button aria-current={selectedId === execution.id ? "true" : undefined} className="text-left font-semibold hover:text-emerald-300" onClick={() => { void loadDetail(execution.id); }} type="button">Execution {execution.id}</button><p className="mt-1 text-sm text-slate-300">{execution.state} · attempt {execution.attempt}</p></li>)}</ul> : null}
|
||||
{detail?.kind === "loading" ? <Loading label="execution details" /> : null}
|
||||
{detail?.kind === "error" && selectedId ? <ErrorPanel message={detail.message} retry={() => { void loadDetail(selectedId); }} /> : null}
|
||||
{detail?.kind === "ready" ? <section aria-labelledby="execution-detail-heading" className="mt-6 rounded-lg border border-slate-700 bg-slate-900 p-4"><h3 id="execution-detail-heading" className="text-lg font-semibold">Execution detail</h3><dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-slate-400">State</dt><dd>{detail.items.state}</dd></div><div><dt className="text-slate-400">Attempt</dt><dd>{detail.items.attempt}</dd></div><div><dt className="text-slate-400">Reason</dt><dd>{detail.items.reason_code ?? "None"}</dd></div></dl></section> : null}
|
||||
</ResourceSection>;
|
||||
const route = routes.find((item) => item.path === pathname);
|
||||
document.title = `${route?.title ?? "Dashboard"} | Backup Tool`;
|
||||
if (hasMounted.current) document.getElementById("main-content")?.focus();
|
||||
hasMounted.current = true;
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function OperatorViews({ client, onSessionExpired, user }: { client: BackupToolClient; onSessionExpired: () => void; user: SessionUser }) {
|
||||
const [page, setPage] = useState<Page>("dashboard");
|
||||
const common = { client, onSessionExpired };
|
||||
return <main className="min-h-screen bg-slate-950 text-slate-100"><header className="border-b border-slate-800 bg-slate-900"><div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6"><div><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-1 text-2xl font-bold">Operator console</h1></div><p className="text-sm text-slate-300"><span className="sr-only">Signed in as </span>{user.username}</p></div><nav aria-label="Primary" className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6">{pages.map((item) => <button aria-current={page === item.id ? "page" : undefined} className="whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800" key={item.id} onClick={() => setPage(item.id)} type="button">{item.label}</button>)}</nav></header>{page === "dashboard" ? <DashboardPage {...common} /> : null}{page === "sources" ? <SourcesPage {...common} /> : null}{page === "repositories" ? <RepositoriesPage {...common} /> : null}{page === "jobs" ? <JobsPage {...common} /> : null}{page === "executions" ? <ExecutionsPage {...common} /> : null}{page === "backups" ? <BackupsPage {...common} /> : null}{page === "security" ? <SecurityPage {...common} /> : null}{page === "notifications" ? <NotificationsPage {...common} /> : null}{page === "audit" ? <AuditPage {...common} /> : null}</main>;
|
||||
export function OperatorViews({
|
||||
client,
|
||||
onSessionExpired,
|
||||
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
@@ -416,6 +416,84 @@
|
||||
"title": "JobList",
|
||||
"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": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@@ -2835,6 +2913,89 @@
|
||||
"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": {
|
||||
"post": {
|
||||
"operationId": "enqueue_execution_api_v2_jobs__job_id__executions_post",
|
||||
|
||||
@@ -97,6 +97,18 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
|
||||
)
|
||||
assert job.status_code == 201
|
||||
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(
|
||||
f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user