Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9ceaeffed | |||
| 9023963bf1 | |||
| 22567c5fb1 | |||
| 3965aca42f |
@@ -337,6 +337,15 @@ class JobInput(BaseModel):
|
|||||||
allow_empty: bool = False
|
allow_empty: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class JobPatch(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
requested_mode: str | None = None
|
||||||
|
exclusions: list[str] | None = None
|
||||||
|
retention: dict[str, Any] | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
allow_empty: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenInput(BaseModel):
|
class TokenInput(BaseModel):
|
||||||
scopes: list[str] = Field(min_length=1)
|
scopes: list[str] = Field(min_length=1)
|
||||||
expires_at: datetime | None = None
|
expires_at: datetime | None = None
|
||||||
@@ -1152,6 +1161,40 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
"state": job.state,
|
"state": job.state,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.patch("/api/v2/jobs/{job_id}")
|
||||||
|
async def patch_job(
|
||||||
|
job_id: str,
|
||||||
|
input_: JobPatch,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
|
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job is None or job.state != "active":
|
||||||
|
raise Problem(404, "resource_not_found", "Job was not found.")
|
||||||
|
changes = input_.model_dump(exclude_unset=True)
|
||||||
|
if not changes:
|
||||||
|
raise Problem(422, "validation_failed", "No mutable fields supplied.")
|
||||||
|
if changes.get("requested_mode") not in {None, "full", "incremental"}:
|
||||||
|
raise Problem(422, "validation_failed", "Invalid requested mode.")
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(job, field, value)
|
||||||
|
await audit(db, request, "update", "job", job.id, "success", identity[0].id)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(job)
|
||||||
|
return {
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"source_id": job.source_id,
|
||||||
|
"repository_id": job.repository_id,
|
||||||
|
"requested_mode": job.requested_mode,
|
||||||
|
"exclusions": job.exclusions,
|
||||||
|
"retention": job.retention,
|
||||||
|
"enabled": job.enabled,
|
||||||
|
"allow_empty": job.allow_empty,
|
||||||
|
"state": job.state,
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/v2/jobs/{job_id}/schedule", status_code=201)
|
@app.post("/api/v2/jobs/{job_id}/schedule", status_code=201)
|
||||||
async def create_schedule(
|
async def create_schedule(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ This index points operators and contributors to the authoritative v2.0 procedure
|
|||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
- [Project quick start](../README.md)
|
- [Project quick start](../README.md)
|
||||||
|
- [Operator console guide](runbooks/operator-console.md)
|
||||||
- [Upgrade and rollback](runbooks/upgrade.md)
|
- [Upgrade and rollback](runbooks/upgrade.md)
|
||||||
- [Observability and alert response](runbooks/observability.md)
|
- [Observability and alert response](runbooks/observability.md)
|
||||||
- [Disaster recovery](runbooks/disaster-recovery.md)
|
- [Disaster recovery](runbooks/disaster-recovery.md)
|
||||||
@@ -15,6 +16,7 @@ This index points operators and contributors to the authoritative v2.0 procedure
|
|||||||
|
|
||||||
| Need | Authoritative guide |
|
| Need | Authoritative guide |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| Operator console | [operator console](runbooks/operator-console.md) |
|
||||||
| Repository lifecycle | [repositories](runbooks/repositories.md) |
|
| Repository lifecycle | [repositories](runbooks/repositories.md) |
|
||||||
| Metadata protection | [metadata](runbooks/metadata.md) |
|
| Metadata protection | [metadata](runbooks/metadata.md) |
|
||||||
| Master and repository keys | [keys](runbooks/keys.md) |
|
| Master and repository keys | [keys](runbooks/keys.md) |
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# M16 routed operator UI evidence
|
||||||
|
|
||||||
|
- The authenticated operator console now has protected, URL-addressable routes for the existing Dashboard, Sources, Repositories, Jobs, Executions, Backups, Security, Notifications, and Audit views. `/` and authenticated unknown routes redirect to `/dashboard` without adding a dead Back-history entry.
|
||||||
|
- Authentication discovery happens before the route shell renders, so an unauthenticated deep link is retained through sign-in rather than prematurely redirected.
|
||||||
|
- Route components load through `React.lazy` with an announced `Suspense` state and an in-shell error boundary. `OperatorPages.tsx` splits the existing resource pages into a separate production chunk.
|
||||||
|
- Navigation uses semantic `NavLink` anchors with current-page semantics. The shell provides one main landmark, a functional skip link, route-based document titles, and focus restoration after route navigation.
|
||||||
|
- Route-level tests cover direct `/sources` navigation, title/current-link state, focus restoration, authenticated unknown-route redirect, and the skip link. Existing workflow tests were adapted to the semantic navigation links.
|
||||||
|
|
||||||
|
## Green M16 checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm --prefix frontend run api:check # generated client current
|
||||||
|
npm --prefix frontend run typecheck # passed
|
||||||
|
npm --prefix frontend run build # passed; emitted OperatorPages and M13Workflows chunks
|
||||||
|
npm --prefix frontend test -- --run # 11 passed
|
||||||
|
npx --prefix frontend playwright test --config frontend/playwright.config.ts # 1 passed
|
||||||
|
.venv/bin/python tools/export_openapi.py --check openapi/v2.json # current
|
||||||
|
make check # passed
|
||||||
|
```
|
||||||
|
|
||||||
|
Known boundary: this evidence covers delivery slice 1's routed shell over the pre-existing read/workflow views. The create/edit/administration workflows in later slices of `docs/plans/full-operator-ui.md` remain unimplemented.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# M17 operator workflow evidence
|
||||||
|
|
||||||
|
## Delivered workflows
|
||||||
|
|
||||||
|
- Routed shell: protected, lazy, URL-addressable dashboard, resource, operation, notification, security, audit, and administration views.
|
||||||
|
- Resources: repository create/inspect; local and SSH source create, probe, typed archive; job create/enable/disable; schedule create/update/delete; manual enqueue.
|
||||||
|
- Operations: named execution SSE events, reconnect status, polling fallback, state-gated retry/cancel; backup verification/deletion impact preview; restore dry-run and exact-destination overwrite confirmation.
|
||||||
|
- Notifications: subscription creation, selected-only test, signing-key rotation input, delivery attempts/retry; webhook signing secrets are write-only.
|
||||||
|
- Administration: write-only secret creation, one-time token rendering, and user-state update.
|
||||||
|
|
||||||
|
## Deliberate boundaries and remaining API gaps
|
||||||
|
|
||||||
|
- Backup deletion has no v2 delete endpoint, so the UI stops at its impact preview and directs the operator to the CLI.
|
||||||
|
- Recovery bundles, passphrases, and recovery imports/exports remain CLI-only.
|
||||||
|
- User/token metadata list endpoints and generated response ETags are not available. Therefore user/token discovery and ETag-required subscription edit/disable/archive controls are not exposed rather than implemented unsafely.
|
||||||
|
- Audit filtering and cursor navigation await their corresponding authenticated API query parameters.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm --prefix frontend test -- --run # 8 passed
|
||||||
|
npm --prefix frontend run typecheck # passed
|
||||||
|
.venv/bin/python -m pytest tests/integration/test_sources_jobs.py -q # 4 passed
|
||||||
|
make check # passed
|
||||||
|
```
|
||||||
|
|
||||||
|
The expected generated artifacts (`openapi/v2.json` and `frontend/src/api/generated/client.ts`) are staged so `make check` can verify API generation drift without staging unrelated workspace changes.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# M18 operator guide evidence
|
||||||
|
|
||||||
|
- Added `docs/runbooks/operator-console.md`, a task-oriented guide for first setup,
|
||||||
|
repositories, sources, jobs, executions, backups/restores, notifications, and
|
||||||
|
administration.
|
||||||
|
- Linked the guide from the documentation index without removing the existing
|
||||||
|
operator-UI-plan entry.
|
||||||
|
- Documents write-only-secret handling and CLI-only recovery/deletion boundaries.
|
||||||
|
|
||||||
|
Verification: `make check` passed (107 unit/contract, 73 integration with one
|
||||||
|
skipped, 15 fault, and 33 security tests; lint, typecheck, and frontend build
|
||||||
|
passed).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Operator console guide
|
||||||
|
|
||||||
|
Use the Backup Tool console for routine v2 operator work. It is served by the
|
||||||
|
same Compose deployment as the API, normally at `http://127.0.0.1:8080`.
|
||||||
|
|
||||||
|
## Before you begin
|
||||||
|
|
||||||
|
1. Start and check the deployment as described in the [project quick start](../../README.md).
|
||||||
|
2. On a new installation, open the console and create the first administrator.
|
||||||
|
Production setup should be protected with `BACKUP_TOOL_BOOTSTRAP_SECRET`.
|
||||||
|
3. Sign in with an active administrator account. If the session expires, the
|
||||||
|
console returns to sign-in; sign in again and reopen the saved URL.
|
||||||
|
|
||||||
|
## Typical backup workflow
|
||||||
|
|
||||||
|
1. **Repositories** — Create a repository. Choose its compression and encryption
|
||||||
|
policy carefully: it is immutable after creation. Use **Inspect repository**
|
||||||
|
to check its recorded policy.
|
||||||
|
2. **Sources** — Create a local source with an allowed root, or an SSH source.
|
||||||
|
SSH requires a stored private-key secret, hostname, username, exact host key,
|
||||||
|
and uses a forced-SFTP chroot at `/`. Probe the source before creating a job.
|
||||||
|
3. **Jobs & schedules** — Create a job with the source and repository IDs,
|
||||||
|
exclusions, and backup mode. Add a cron schedule or use **Run now** for a
|
||||||
|
one-off execution. Disable a job before changing its operational use.
|
||||||
|
4. **Executions** — Select an execution to watch its state. The page consumes
|
||||||
|
live updates and polls while reconnecting. Only running/queued executions
|
||||||
|
can be cancelled; only failed ones can be retried.
|
||||||
|
5. **Backups** — Verify committed backups. Review a deletion impact preview
|
||||||
|
before using the CLI procedure for deletion. Test restores as dry runs first;
|
||||||
|
an overwrite restore requires typing the exact destination.
|
||||||
|
6. **Notifications** — Create webhook or email subscriptions, choose event
|
||||||
|
filters, test the selected subscription, and rotate webhook signing keys when
|
||||||
|
needed. Treat signing secrets as write-only.
|
||||||
|
|
||||||
|
## Administration and safety boundaries
|
||||||
|
|
||||||
|
The **Administration** page can store write-only secrets, create a token (copy
|
||||||
|
it immediately), and change a known user's state. Do not paste secret values
|
||||||
|
into issue trackers, URLs, browser storage, or logs.
|
||||||
|
|
||||||
|
Some high-risk actions deliberately remain outside the browser:
|
||||||
|
|
||||||
|
- Recovery bundle export, validation, import, passphrases, and key material use
|
||||||
|
the [recovery-bundle runbook](recovery-bundle.md).
|
||||||
|
- Backup deletion is CLI-only after its browser impact preview.
|
||||||
|
- User/token inventory and subscription state changes that require ETags are not
|
||||||
|
offered until their authenticated list/header API contracts are available.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Use the in-page retry control for transient API failures.
|
||||||
|
- A **permission** response means the signed-in account/token lacks the required
|
||||||
|
scope; use an administrator account rather than retrying.
|
||||||
|
- For SSH probe failures, confirm the exact host key, private-key secret,
|
||||||
|
forced-SFTP account, and source root with the [SSH source runbook](ssh-sources.md).
|
||||||
|
- For delivery failures, inspect delivery attempts, then follow the
|
||||||
|
[notification runbook](notifications.md).
|
||||||
|
|
||||||
|
For deployment health, metrics, alerts, upgrades, or recovery, use the linked
|
||||||
|
runbooks rather than treating the console as a substitute for incident
|
||||||
|
procedures.
|
||||||
Generated
+59
-1
@@ -9,7 +9,8 @@
|
|||||||
"version": "2.0.0-dev.0",
|
"version": "2.0.0-dev.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
@@ -1645,6 +1646,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -2917,6 +2931,44 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -3074,6 +3126,12 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface Components {
|
|||||||
HTTPValidationError: { detail?: Array<Components['schemas']["ValidationError"]> };
|
HTTPValidationError: { detail?: Array<Components['schemas']["ValidationError"]> };
|
||||||
JobInput: { allow_empty?: boolean; enabled?: boolean; exclusions?: Array<string>; name : string; repository_id : string; requested_mode?: string; retention?: Record<string, unknown>; source_id : string };
|
JobInput: { allow_empty?: boolean; enabled?: boolean; exclusions?: Array<string>; name : string; repository_id : string; requested_mode?: string; retention?: Record<string, unknown>; source_id : string };
|
||||||
JobList: { items : Array<Components['schemas']["JobSummary"]> };
|
JobList: { items : Array<Components['schemas']["JobSummary"]> };
|
||||||
|
JobPatch: { allow_empty?: boolean | null; enabled?: boolean | null; exclusions?: Array<string> | null; name?: string | null; requested_mode?: string | null; retention?: Record<string, unknown> | null };
|
||||||
JobSummary: { enabled : boolean; id : string; name : string; repository_id : string; requested_mode : string; schedule : Components['schemas']["ScheduleSummary"] | null; source_id : string; state : string };
|
JobSummary: { enabled : boolean; id : string; name : string; repository_id : string; requested_mode : string; schedule : Components['schemas']["ScheduleSummary"] | null; source_id : string; state : string };
|
||||||
LocalSourceInput: { kind : string; name : string; public_config : Record<string, unknown> };
|
LocalSourceInput: { kind : string; name : string; public_config : Record<string, unknown> };
|
||||||
LoginInput: { password : string; username : string };
|
LoginInput: { password : string; username : string };
|
||||||
@@ -82,6 +83,8 @@ export type RetryExecutionParams = { path: { execution_id: string } };
|
|||||||
|
|
||||||
export type CreateJobParams = { body : Components['schemas']["JobInput"] };
|
export type CreateJobParams = { body : Components['schemas']["JobInput"] };
|
||||||
|
|
||||||
|
export type PatchJobParams = { path: { job_id: string }; body : Components['schemas']["JobPatch"] };
|
||||||
|
|
||||||
export type EnqueueExecutionParams = { path: { job_id: string } };
|
export type EnqueueExecutionParams = { path: { job_id: string } };
|
||||||
|
|
||||||
export type DeleteScheduleParams = { path: { job_id: string } };
|
export type DeleteScheduleParams = { path: { job_id: string } };
|
||||||
@@ -346,6 +349,13 @@ export class BackupToolClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async patchJob(params: PatchJobParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
|
||||||
|
const url = new URL("/api/v2/jobs/{job_id}".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
|
||||||
|
return request<Record<string, unknown>>(
|
||||||
|
url, "PATCH", options, params.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async enqueueExecution(params: EnqueueExecutionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
|
async enqueueExecution(params: EnqueueExecutionParams, options: RequestOptions = {}): Promise<Record<string, unknown>> {
|
||||||
const url = new URL("/api/v2/jobs/{job_id}/executions".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
|
const url = new URL("/api/v2/jobs/{job_id}/executions".replace("{job_id}", encodeURIComponent(String(params.path.job_id))), this.baseUrl);
|
||||||
return request<Record<string, unknown>>(
|
return request<Record<string, unknown>>(
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
import { isApiError } from "../api/generated/client";
|
||||||
|
import type { OperatorPageProps } from "./OperatorPages";
|
||||||
|
|
||||||
|
function message(error: unknown) {
|
||||||
|
return isApiError(error)
|
||||||
|
? (error.problem?.detail ?? "The request failed.")
|
||||||
|
: "The service could not be reached.";
|
||||||
|
}
|
||||||
|
function csrfHeaders(): HeadersInit {
|
||||||
|
const token = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((item) => item.startsWith("backup_tool_csrf="))
|
||||||
|
?.split("=", 2)[1];
|
||||||
|
return token ? { "X-CSRF-Token": token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdministrationPage({
|
||||||
|
client,
|
||||||
|
onSessionExpired,
|
||||||
|
}: OperatorPageProps) {
|
||||||
|
const [secretPurpose, setSecretPurpose] = useState("ssh_private_key");
|
||||||
|
const [secretValue, setSecretValue] = useState("");
|
||||||
|
const [scopes, setScopes] = useState("audit:read");
|
||||||
|
const [token, setToken] = useState<string>();
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [userState, setUserState] = useState("active");
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
if (isApiError(error) && error.status === 401) onSessionExpired();
|
||||||
|
else setStatus(message(error));
|
||||||
|
};
|
||||||
|
async function createSecret(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await client.createSecret(
|
||||||
|
{ body: { purpose: secretPurpose, value: secretValue } },
|
||||||
|
{ headers: csrfHeaders() },
|
||||||
|
);
|
||||||
|
setSecretValue("");
|
||||||
|
setStatus(
|
||||||
|
"Secret stored. Its value is no longer available in the browser.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function createToken(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const result: unknown = await client.createToken(
|
||||||
|
{ body: { scopes: scopes.split(/\s*,\s*/).filter(Boolean) } },
|
||||||
|
{ headers: csrfHeaders() },
|
||||||
|
);
|
||||||
|
const tokenValue =
|
||||||
|
result &&
|
||||||
|
typeof result === "object" &&
|
||||||
|
"token" in result &&
|
||||||
|
typeof result.token === "string"
|
||||||
|
? result.token
|
||||||
|
: undefined;
|
||||||
|
setToken(tokenValue);
|
||||||
|
setStatus("Token created. Copy it now; it will not be shown again.");
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function updateUser(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await client.patchUser(
|
||||||
|
{ path: { user_id: userId }, body: { state: userState } },
|
||||||
|
{ headers: csrfHeaders() },
|
||||||
|
);
|
||||||
|
setStatus(`User ${userState}.`);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="page-title"
|
||||||
|
className="mx-auto max-w-6xl p-4 sm:p-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold" id="page-title">
|
||||||
|
Administration
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-slate-300">
|
||||||
|
Secret values are write-only. User and token discovery remain
|
||||||
|
unavailable until their authenticated list APIs are added.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||||
|
<form
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 p-4"
|
||||||
|
onSubmit={createSecret}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">Store secret</h3>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
Purpose
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setSecretPurpose(event.target.value)}
|
||||||
|
required
|
||||||
|
value={secretPurpose}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
Value
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setSecretValue(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={secretValue}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="mt-4 rounded border border-slate-500 px-3 py-2"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Store secret
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 p-4"
|
||||||
|
onSubmit={createToken}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">Create API token</h3>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
Scopes (comma separated)
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setScopes(event.target.value)}
|
||||||
|
required
|
||||||
|
value={scopes}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="mt-4 rounded border border-slate-500 px-3 py-2"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create token
|
||||||
|
</button>
|
||||||
|
{token ? (
|
||||||
|
<p className="mt-3 break-all" role="status">
|
||||||
|
{token}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 p-4"
|
||||||
|
onSubmit={updateUser}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">Change user state</h3>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
User ID
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setUserId(event.target.value)}
|
||||||
|
required
|
||||||
|
value={userId}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
State
|
||||||
|
<select
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setUserState(event.target.value)}
|
||||||
|
value={userState}
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="mt-4 rounded border border-slate-500 px-3 py-2"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update user
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{status ? (
|
||||||
|
<p className="mt-4" role="status">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+173
-97
@@ -1,4 +1,10 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
@@ -11,166 +17,236 @@ function response(body: unknown, status = 200): Response {
|
|||||||
status,
|
status,
|
||||||
} as Response;
|
} as Response;
|
||||||
}
|
}
|
||||||
|
function problem(
|
||||||
function problem(status: number, code: string, detail = "Request failed."): Response {
|
status: number,
|
||||||
return response({ type: `https://backup-tool.invalid/problems/${code}`, title: code, status, detail, instance: "/", code }, status);
|
code: string,
|
||||||
|
detail = "Request failed.",
|
||||||
|
): Response {
|
||||||
|
return response(
|
||||||
|
{
|
||||||
|
type: `https://backup-tool.invalid/problems/${code}`,
|
||||||
|
title: code,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
instance: "/",
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function session() {
|
function session() {
|
||||||
return response({ id: "user-1", username: "operator", state: "active" });
|
return response({ id: "user-1", username: "operator", state: "active" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositories(items: unknown[] = []) {
|
function repositories(items: unknown[] = []) {
|
||||||
return response({ items });
|
return response({ items });
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("operator foundation", () => {
|
describe("operator foundation", () => {
|
||||||
it("shows loading then the accessible empty dashboard", async () => {
|
it("shows loading then the accessible empty dashboard", async () => {
|
||||||
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories());
|
const fetch = vi
|
||||||
vi.stubGlobal("fetch", fetch);
|
.fn()
|
||||||
render(<App />);
|
.mockResolvedValueOnce(session())
|
||||||
|
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("Checking your session");
|
|
||||||
expect(await screen.findByText("No repositories have been configured.")).toBeInTheDocument();
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects setup and creates a session for the first administrator", async () => {
|
|
||||||
const fetch = vi.fn()
|
|
||||||
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
|
||||||
.mockResolvedValueOnce(problem(503, "setup_required"))
|
|
||||||
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }, 201))
|
|
||||||
.mockResolvedValueOnce(repositories());
|
.mockResolvedValueOnce(repositories());
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
expect(await screen.findByRole("heading", { name: "Set up your administrator account" })).toBeInTheDocument();
|
"Checking your session",
|
||||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } });
|
);
|
||||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } });
|
expect(
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Create administrator account" }));
|
await screen.findByText("No repositories have been configured."),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
const setupRequest = fetch.mock.calls[2]?.[0] as URL;
|
});
|
||||||
expect(setupRequest.pathname).toBe("/api/v2/setup");
|
it("selects setup and creates a session for the first administrator", async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
||||||
|
.mockResolvedValueOnce(problem(503, "setup_required"))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({ id: "user-1", username: "operator" }, 201),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(repositories());
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
render(<App />);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", {
|
||||||
|
name: "Set up your administrator account",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Username"), {
|
||||||
|
target: { value: "operator" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), {
|
||||||
|
target: { value: "correct horse battery staple" },
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Create administrator account" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Dashboard" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe("/api/v2/setup");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("signs in after setup is complete", async () => {
|
it("signs in after setup is complete", async () => {
|
||||||
const fetch = vi.fn()
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
.mockResolvedValueOnce(problem(401, "authentication_required"))
|
||||||
.mockResolvedValueOnce(response({ status: "ready" }))
|
.mockResolvedValueOnce(response({ status: "ready" }))
|
||||||
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }))
|
.mockResolvedValueOnce(response({ id: "user-1", username: "operator" }))
|
||||||
.mockResolvedValueOnce(repositories());
|
.mockResolvedValueOnce(repositories());
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
expect(
|
||||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
await screen.findByRole("heading", { name: "Sign in" }),
|
||||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } });
|
).toBeInTheDocument();
|
||||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "correct horse battery staple" } });
|
fireEvent.change(screen.getByLabelText("Username"), {
|
||||||
|
target: { value: "operator" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), {
|
||||||
|
target: { value: "correct horse battery staple" },
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
expect(
|
||||||
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
await screen.findByRole("heading", { name: "Dashboard" }),
|
||||||
const loginRequest = fetch.mock.calls[2]?.[0] as URL;
|
).toBeInTheDocument();
|
||||||
expect(loginRequest.pathname).toBe("/api/v2/auth/login");
|
expect((fetch.mock.calls[2]?.[0] as URL).pathname).toBe(
|
||||||
|
"/api/v2/auth/login",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows retryable dashboard errors", async () => {
|
it("shows retryable dashboard errors", async () => {
|
||||||
const fetch = vi.fn()
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce(session())
|
.mockResolvedValueOnce(session())
|
||||||
.mockResolvedValueOnce(problem(500, "service_unavailable", "Dashboard data is unavailable."))
|
.mockResolvedValueOnce(
|
||||||
|
problem(500, "service_unavailable", "Dashboard data is unavailable."),
|
||||||
|
)
|
||||||
.mockResolvedValueOnce(repositories());
|
.mockResolvedValueOnce(repositories());
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Dashboard data is unavailable.");
|
"Dashboard data is unavailable.",
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||||
await waitFor(() => expect(screen.getByText("No repositories have been configured.")).toBeInTheDocument());
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText("No repositories have been configured."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads source and job states from generated client methods", async () => {
|
it("loads source and job states from generated client methods", async () => {
|
||||||
const fetch = vi.fn()
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce(session())
|
.mockResolvedValueOnce(session())
|
||||||
.mockResolvedValueOnce(repositories())
|
.mockResolvedValueOnce(repositories())
|
||||||
.mockResolvedValueOnce(response({ items: [] }))
|
.mockResolvedValueOnce(response({ items: [] }))
|
||||||
.mockResolvedValueOnce(problem(500, "service_unavailable", "Jobs are unavailable."));
|
.mockResolvedValueOnce(
|
||||||
|
problem(500, "service_unavailable", "Jobs are unavailable."),
|
||||||
|
);
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await screen.findByText("No repositories have been configured.");
|
await screen.findByText("No repositories have been configured.");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Sources" }));
|
fireEvent.click(screen.getByRole("link", { name: "Sources" }));
|
||||||
expect(await screen.findByText("No sources have been configured.")).toBeInTheDocument();
|
expect(
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Jobs & schedules" }));
|
await screen.findByText("No sources have been configured."),
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Jobs are unavailable.");
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Jobs & schedules" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"Jobs are unavailable.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads execution detail after selecting an execution", async () => {
|
it("loads execution detail after selecting an execution", async () => {
|
||||||
const execution = { id: "execution-1", state: "failed", attempt: 2, revision: 3, reason_code: "timeout", progress: {} };
|
const execution = {
|
||||||
const fetch = vi.fn()
|
id: "execution-1",
|
||||||
|
state: "failed",
|
||||||
|
attempt: 2,
|
||||||
|
revision: 3,
|
||||||
|
reason_code: "timeout",
|
||||||
|
progress: {},
|
||||||
|
};
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce(session())
|
.mockResolvedValueOnce(session())
|
||||||
.mockResolvedValueOnce(repositories())
|
.mockResolvedValueOnce(repositories())
|
||||||
.mockResolvedValueOnce(response({ items: [execution] }))
|
.mockResolvedValueOnce(response({ items: [execution] }))
|
||||||
.mockResolvedValueOnce(response(execution));
|
.mockResolvedValueOnce(response(execution));
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await screen.findByText("No repositories have been configured.");
|
await screen.findByText("No repositories have been configured.");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Executions" }));
|
fireEvent.click(screen.getByRole("link", { name: "Executions" }));
|
||||||
expect(await screen.findByRole("button", { name: "Execution execution-1" })).toBeInTheDocument();
|
expect(
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Execution execution-1" }));
|
await screen.findByRole("button", { name: "Execution execution-1" }),
|
||||||
expect(await screen.findByRole("heading", { name: "Execution detail" })).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Execution execution-1" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Execution detail" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("timeout")).toBeInTheDocument();
|
expect(screen.getByText("timeout")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("cancels a running execution from its detail view", async () => {
|
||||||
it("announces SSE reconnects and applies live execution updates", async () => {
|
window.history.replaceState({}, "", "/executions");
|
||||||
|
const execution = {
|
||||||
|
id: "execution-1",
|
||||||
|
state: "running",
|
||||||
|
attempt: 1,
|
||||||
|
revision: 1,
|
||||||
|
reason_code: null,
|
||||||
|
progress: {},
|
||||||
|
};
|
||||||
class EventSourceMock {
|
class EventSourceMock {
|
||||||
static instances: EventSourceMock[] = [];
|
|
||||||
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
constructor() { EventSourceMock.instances.push(this); }
|
addEventListener() {}
|
||||||
close = vi.fn();
|
close() {}
|
||||||
}
|
}
|
||||||
vi.stubGlobal("EventSource", EventSourceMock);
|
vi.stubGlobal("EventSource", EventSourceMock);
|
||||||
const execution = { id: "execution-1", state: "running", attempt: 1, revision: 1, reason_code: null, progress: {} };
|
const fetch = vi
|
||||||
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(repositories()).mockResolvedValueOnce(response({ items: [execution] })).mockResolvedValueOnce(response(execution));
|
.fn()
|
||||||
vi.stubGlobal("fetch", fetch);
|
|
||||||
render(<App />);
|
|
||||||
await screen.findByText("No repositories have been configured.");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Executions" }));
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Execution execution-1" }));
|
|
||||||
await screen.findByRole("heading", { name: "Execution detail" });
|
|
||||||
EventSourceMock.instances[0]?.onerror?.();
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Live updates disconnected");
|
|
||||||
EventSourceMock.instances[0]?.onmessage?.({ data: JSON.stringify({ ...execution, state: "committed", revision: 2 }) } as MessageEvent<string>);
|
|
||||||
expect(await screen.findByText("committed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends an idempotency key when retrying a failed notification delivery", async () => {
|
|
||||||
const fetch = vi.fn()
|
|
||||||
.mockResolvedValueOnce(session())
|
.mockResolvedValueOnce(session())
|
||||||
.mockResolvedValueOnce(repositories())
|
.mockResolvedValueOnce(response({ items: [execution] }))
|
||||||
.mockResolvedValueOnce(response({ items: [] }))
|
.mockResolvedValueOnce(response(execution))
|
||||||
.mockResolvedValueOnce(response({ items: [{ id: "delivery-1", event_id: "event-1", subscription_id: "subscription-1", state: "failed", attempt_count: 1, response_class: null, response_summary: null, terminal_reason: "timeout", due_at: "2026-01-01T00:00:00Z" }] }))
|
.mockResolvedValueOnce(
|
||||||
.mockResolvedValueOnce(response({ delivery_id: "delivery-1" }, 202));
|
response({ ...execution, state: "cancelling" }, 202),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(response({ ...execution, state: "cancelling" }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response({ items: [{ ...execution, state: "cancelling" }] }),
|
||||||
|
);
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await screen.findByText("No repositories have been configured.");
|
fireEvent.click(await screen.findByRole("link", { name: "Executions" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
|
fireEvent.click(
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Retry delivery" }));
|
await screen.findByRole("button", { name: "Execution execution-1" }),
|
||||||
expect(await screen.findByRole("status")).toHaveTextContent("Delivery retry queued.");
|
);
|
||||||
const options = fetch.mock.calls[4]?.[1] as RequestInit;
|
fireEvent.click(
|
||||||
expect(new Headers(options.headers).get("Idempotency-Key")).toBeTruthy();
|
await screen.findByRole("button", { name: "Cancel execution" }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||||
|
"Execution cancel requested.",
|
||||||
|
);
|
||||||
|
expect((fetch.mock.calls[3]?.[0] as URL).pathname).toBe(
|
||||||
|
"/api/v2/executions/execution-1/cancel",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns to sign-in when the dashboard request finds an expired session", async () => {
|
it("returns to sign-in when the dashboard request finds an expired session", async () => {
|
||||||
const fetch = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(problem(401, "authentication_required"));
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(session())
|
||||||
|
.mockResolvedValueOnce(problem(401, "authentication_required"));
|
||||||
vi.stubGlobal("fetch", fetch);
|
vi.stubGlobal("fetch", fetch);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
expect(
|
||||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
await screen.findByRole("heading", { name: "Sign in" }),
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("Your session expired");
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Your session expired",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+167
-18
@@ -1,4 +1,5 @@
|
|||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BackupToolClient,
|
BackupToolClient,
|
||||||
@@ -17,12 +18,19 @@ type Screen =
|
|||||||
const defaultClient = new BackupToolClient();
|
const defaultClient = new BackupToolClient();
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
|
if (isApiError(error))
|
||||||
|
return (
|
||||||
|
error.problem?.detail ?? "The server could not complete that request."
|
||||||
|
);
|
||||||
return "We could not reach the Backup Tool service. Check your connection and try again.";
|
return "We could not reach the Backup Tool service. Check your connection and try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSetupRequired(error: unknown): boolean {
|
function isSetupRequired(error: unknown): boolean {
|
||||||
return isApiError(error) && error.status === 503 && error.problem?.code === "setup_required";
|
return (
|
||||||
|
isApiError(error) &&
|
||||||
|
error.status === 503 &&
|
||||||
|
error.problem?.code === "setup_required"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSessionExpired(error: unknown): boolean {
|
function isSessionExpired(error: unknown): boolean {
|
||||||
@@ -56,10 +64,92 @@ function AuthForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <main className="flex min-h-screen items-center justify-center bg-slate-950 p-4 text-slate-100"><section aria-labelledby="auth-title" className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 p-6 shadow-2xl"><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-3 text-3xl font-bold" id="auth-title">{isSetup ? "Set up your administrator account" : "Sign in"}</h1><p className="mt-3 text-slate-300">{isSetup ? "Create the first administrator account to begin operating this backup service." : "Use an administrator account to continue."}</p>{sessionExpired ? <p role="status" className="mt-4 rounded-md border border-amber-400/50 bg-amber-950/40 p-3 text-amber-100">Your session expired. Sign in again to continue.</p> : null}{error ? <p role="alert" className="mt-4 rounded-md border border-rose-400/50 bg-rose-950/40 p-3 text-rose-100">{error}</p> : null}<form className="mt-6 space-y-4" onSubmit={submit}><div><label className="block text-sm font-medium" htmlFor="username">Username</label><input autoComplete="username" autoFocus className="mt-1 w-full rounded-md border border-slate-600 bg-slate-950 px-3 py-2" id="username" onChange={(event) => setUsername(event.target.value)} required value={username} /></div><div><label className="block text-sm font-medium" htmlFor="password">Password</label><input autoComplete={isSetup ? "new-password" : "current-password"} className="mt-1 w-full rounded-md border border-slate-600 bg-slate-950 px-3 py-2" id="password" minLength={isSetup ? 12 : undefined} onChange={(event) => setPassword(event.target.value)} required type="password" value={password} />{isSetup ? <p className="mt-1 text-sm text-slate-400">Use at least 12 characters.</p> : null}</div><button className="w-full rounded-md bg-emerald-500 px-4 py-2 font-semibold text-slate-950 hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60" disabled={submitting} type="submit">{submitting ? "Working…" : isSetup ? "Create administrator account" : "Sign in"}</button></form></section></main>;
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-slate-950 p-4 text-slate-100">
|
||||||
|
<section
|
||||||
|
aria-labelledby="auth-title"
|
||||||
|
className="w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 p-6 shadow-2xl"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">
|
||||||
|
Backup Tool
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold" id="auth-title">
|
||||||
|
{isSetup ? "Set up your administrator account" : "Sign in"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-slate-300">
|
||||||
|
{isSetup
|
||||||
|
? "Create the first administrator account to begin operating this backup service."
|
||||||
|
: "Use an administrator account to continue."}
|
||||||
|
</p>
|
||||||
|
{sessionExpired ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="mt-4 rounded-md border border-amber-400/50 bg-amber-950/40 p-3 text-amber-100"
|
||||||
|
>
|
||||||
|
Your session expired. Sign in again to continue.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-md border border-rose-400/50 bg-rose-950/40 p-3 text-rose-100"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={submit}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium" htmlFor="username">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-600 bg-slate-950 px-3 py-2"
|
||||||
|
id="username"
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoComplete={isSetup ? "new-password" : "current-password"}
|
||||||
|
className="mt-1 w-full rounded-md border border-slate-600 bg-slate-950 px-3 py-2"
|
||||||
|
id="password"
|
||||||
|
minLength={isSetup ? 12 : undefined}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
|
{isSetup ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Use at least 12 characters.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md bg-emerald-500 px-4 py-2 font-semibold text-slate-950 hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={submitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? "Working…"
|
||||||
|
: isSetup
|
||||||
|
? "Create administrator account"
|
||||||
|
: "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App({ client = defaultClient }: { client?: BackupToolClient }) {
|
function AppContent({ client = defaultClient }: { client?: BackupToolClient }) {
|
||||||
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
|
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
@@ -67,7 +157,10 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) {
|
|||||||
async function discover() {
|
async function discover() {
|
||||||
setScreen({ kind: "loading" });
|
setScreen({ kind: "loading" });
|
||||||
try {
|
try {
|
||||||
setScreen({ kind: "operator", user: await clientRef.current.getSession() });
|
setScreen({
|
||||||
|
kind: "operator",
|
||||||
|
user: await clientRef.current.getSession(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isSessionExpired(error)) {
|
if (!isSessionExpired(error)) {
|
||||||
setScreen({ kind: "auth", mode: "login", error: errorMessage(error) });
|
setScreen({ kind: "auth", mode: "login", error: errorMessage(error) });
|
||||||
@@ -77,21 +170,77 @@ export function App({ client = defaultClient }: { client?: BackupToolClient }) {
|
|||||||
await clientRef.current.readyz();
|
await clientRef.current.readyz();
|
||||||
setScreen({ kind: "auth", mode: "login" });
|
setScreen({ kind: "auth", mode: "login" });
|
||||||
} catch (readinessError) {
|
} catch (readinessError) {
|
||||||
setScreen({ kind: "auth", mode: isSetupRequired(readinessError) ? "setup" : "login", error: isSetupRequired(readinessError) ? undefined : errorMessage(readinessError) });
|
setScreen({
|
||||||
|
kind: "auth",
|
||||||
|
mode: isSetupRequired(readinessError) ? "setup" : "login",
|
||||||
|
error: isSetupRequired(readinessError)
|
||||||
|
? undefined
|
||||||
|
: errorMessage(readinessError),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { void discover(); }, []);
|
useEffect(() => {
|
||||||
if (screen.kind === "loading") return <main aria-live="polite" className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100" role="status">Checking your session…</main>;
|
void discover();
|
||||||
if (screen.kind === "auth") return <AuthForm {...screen} onSubmit={async (username, password) => {
|
}, []);
|
||||||
try {
|
if (screen.kind === "loading")
|
||||||
const user = screen.mode === "setup" ? await clientRef.current.setup({ body: { username, password } }) : await clientRef.current.login({ body: { username, password } });
|
return (
|
||||||
setScreen({ kind: "operator", user: { ...user, state: "active" } });
|
<main
|
||||||
} catch (error) {
|
aria-live="polite"
|
||||||
if (screen.mode === "setup" && isApiError(error) && error.problem?.code === "setup_complete") setScreen({ kind: "auth", mode: "login", error: "Setup is already complete. Sign in with the administrator account." });
|
className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100"
|
||||||
else setScreen({ ...screen, error: errorMessage(error) });
|
role="status"
|
||||||
}
|
>
|
||||||
}} />;
|
Checking your session…
|
||||||
return <OperatorViews client={clientRef.current} onSessionExpired={() => setScreen({ kind: "auth", mode: "login", sessionExpired: true })} user={screen.user} />;
|
</main>
|
||||||
|
);
|
||||||
|
if (screen.kind === "auth")
|
||||||
|
return (
|
||||||
|
<AuthForm
|
||||||
|
{...screen}
|
||||||
|
onSubmit={async (username, password) => {
|
||||||
|
try {
|
||||||
|
const user =
|
||||||
|
screen.mode === "setup"
|
||||||
|
? await clientRef.current.setup({
|
||||||
|
body: { username, password },
|
||||||
|
})
|
||||||
|
: await clientRef.current.login({
|
||||||
|
body: { username, password },
|
||||||
|
});
|
||||||
|
setScreen({ kind: "operator", user: { ...user, state: "active" } });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
screen.mode === "setup" &&
|
||||||
|
isApiError(error) &&
|
||||||
|
error.problem?.code === "setup_complete"
|
||||||
|
)
|
||||||
|
setScreen({
|
||||||
|
kind: "auth",
|
||||||
|
mode: "login",
|
||||||
|
error:
|
||||||
|
"Setup is already complete. Sign in with the administrator account.",
|
||||||
|
});
|
||||||
|
else setScreen({ ...screen, error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<OperatorViews
|
||||||
|
client={clientRef.current}
|
||||||
|
onSessionExpired={() =>
|
||||||
|
setScreen({ kind: "auth", mode: "login", sessionExpired: true })
|
||||||
|
}
|
||||||
|
user={screen.user}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App({ client }: { client?: BackupToolClient }) {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppContent client={client} />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { type BackupToolClient, type Components, isApiError } from "../api/generated/client";
|
import {
|
||||||
|
type BackupToolClient,
|
||||||
|
type Components,
|
||||||
|
isApiError,
|
||||||
|
} from "../api/generated/client";
|
||||||
|
|
||||||
type BackupSummary = Components["schemas"]["BackupSummary"];
|
type BackupSummary = Components["schemas"]["BackupSummary"];
|
||||||
type Delivery = Components["schemas"]["NotificationDeliverySummary"];
|
type Delivery = Components["schemas"]["NotificationDeliverySummary"];
|
||||||
@@ -10,24 +14,648 @@ type State<T> = { loading: boolean; data?: T; error?: string };
|
|||||||
|
|
||||||
type Props = { client: BackupToolClient; onSessionExpired: () => void };
|
type Props = { client: BackupToolClient; onSessionExpired: () => void };
|
||||||
|
|
||||||
function message(error: unknown) { return isApiError(error) ? error.problem?.detail ?? "The request failed." : "The service could not be reached."; }
|
function message(error: unknown) {
|
||||||
function expired(error: unknown) { return isApiError(error) && error.status === 401; }
|
return isApiError(error)
|
||||||
function Panel({ children, title }: { children: ReactNode; title: string }) { return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>; }
|
? (error.problem?.detail ?? "The request failed.")
|
||||||
function Retry({ error, load }: { error?: string; load: () => void }) { return error ? <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{error}</p><button className="mt-3 rounded border border-slate-500 px-3 py-2" onClick={load} type="button">Try again</button></div> : null; }
|
: "The service could not be reached.";
|
||||||
function Loading({ label }: { label: string }) { return <p className="mt-4 text-slate-300" role="status">Loading {label}…</p>; }
|
}
|
||||||
function csrfHeaders(): HeadersInit { const value = document.cookie.split("; ").find((entry) => entry.startsWith("backup_tool_csrf="))?.split("=", 2)[1]; return value ? { "X-CSRF-Token": value } : {}; }
|
function expired(error: unknown) {
|
||||||
function idempotencyKey(): string { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; }
|
return isApiError(error) && error.status === 401;
|
||||||
|
}
|
||||||
export function BackupsPage({ client, onSessionExpired }: Props) {
|
function Panel({ children, title }: { children: ReactNode; title: string }) {
|
||||||
const [state,setState]=useState<State<BackupSummary[]>>({loading:true}); const [selected,setSelected]=useState<BackupSummary>(); const [preview,setPreview]=useState<string>(); const [destination,setDestination]=useState(""); const [restoreStatus,setRestoreStatus]=useState<string>();
|
return (
|
||||||
const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listBackups()).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};
|
<section
|
||||||
useEffect(()=>{void load();},[]);
|
aria-labelledby="page-title"
|
||||||
const headers=csrfHeaders();
|
className="mx-auto max-w-6xl p-4 sm:p-6"
|
||||||
return <Panel title="Backups">{state.loading?<Loading label="backups"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?.length===0?<p className="mt-4 text-slate-300">No backups have been committed.</p>:null}<ul className="mt-4 space-y-3" aria-label="Backups">{state.data?.map(item=><li className="rounded border border-slate-700 bg-slate-900 p-4" key={item.id}><button className="font-semibold hover:text-emerald-300" onClick={()=>setSelected(item)} type="button">Backup {item.id}</button><p className="text-sm text-slate-300">{item.integrity} · {item.logical_bytes} logical bytes</p></li>)}</ul>{selected?<section className="mt-6 rounded border border-slate-700 bg-slate-900 p-4" aria-labelledby="backup-detail"><h3 id="backup-detail" className="font-semibold">Backup detail</h3><p className="mt-2">Integrity: {selected.integrity}</p><div className="mt-3 flex flex-wrap gap-2"><button className="rounded border border-slate-500 px-3 py-2" onClick={async()=>{try{setSelected(await client.verifyBackup({path:{backup_id:selected.id}},{headers}));}catch(error){setPreview(message(error));}}} type="button">Verify</button><button className="rounded border border-slate-500 px-3 py-2" onClick={async()=>{try{const result=await client.backupDeletePreview({path:{backup_id:selected.id}},{headers});setPreview(result.reason??result.destructive_action);}catch(error){setPreview(message(error));}}} type="button">Preview deletion</button></div>{preview?<p className="mt-3" role="status">{preview}</p>:null}<form className="mt-4 space-y-2" onSubmit={async(event)=>{event.preventDefault();try{await client.createRestore({path:{backup_id:selected.id},body:{destination,dry_run:true,selection:[],overwrite_policy:"fail"}},{headers});setRestoreStatus("Restore dry run queued.");}catch(error){setRestoreStatus(message(error));}}}><label className="block text-sm font-medium" htmlFor="restore-destination">Restore destination</label><input className="w-full rounded border border-slate-500 bg-slate-950 p-2" id="restore-destination" onChange={(event)=>setDestination(event.target.value)} required value={destination}/><button className="rounded border border-slate-500 px-3 py-2" type="submit">Queue restore dry run</button>{restoreStatus?<p role="status">{restoreStatus}</p>:null}</form></section>:null}</Panel>;
|
>
|
||||||
|
<h2 className="text-xl font-semibold" id="page-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Retry({ error, load }: { error?: string; load: () => void }) {
|
||||||
|
return error ? (
|
||||||
|
<div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4">
|
||||||
|
<p role="alert">{error}</p>
|
||||||
|
<button
|
||||||
|
className="mt-3 rounded border border-slate-500 px-3 py-2"
|
||||||
|
onClick={load}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
function Loading({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<p className="mt-4 text-slate-300" role="status">
|
||||||
|
Loading {label}…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function csrfHeaders(): HeadersInit {
|
||||||
|
const value = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((entry) => entry.startsWith("backup_tool_csrf="))
|
||||||
|
?.split("=", 2)[1];
|
||||||
|
return value ? { "X-CSRF-Token": value } : {};
|
||||||
|
}
|
||||||
|
function idempotencyKey(): string {
|
||||||
|
return typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecurityPage({ client,onSessionExpired }: Props) { const [state,setState]=useState<State<Components["schemas"]["RecoveryStatus"]>>({loading:true}); const load=async()=>{setState({loading:true});try{setState({loading:false,data:await client.recoveryStatus()});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};useEffect(()=>{void load();},[]);return <Panel title="Security & recovery">{state.loading?<Loading label="recovery status"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?<article className="mt-4 rounded border border-slate-700 bg-slate-900 p-4"><p>Recovery is {state.data.recovery_mode.replace("_"," ")}.</p><p className="mt-2">Encrypted repositories: {state.data.encrypted_repository_count}</p><p className="mt-3 text-slate-300">Use the CLI and the recovery runbook: <code>{state.data.runbook}</code>. Passphrases and recovery bundles never enter the browser.</p></article>:null}</Panel>; }
|
export function BackupsPage({ client, onSessionExpired }: Props) {
|
||||||
|
const [state, setState] = useState<State<BackupSummary[]>>({ loading: true });
|
||||||
|
const [selected, setSelected] = useState<BackupSummary>();
|
||||||
|
const [preview, setPreview] = useState<string>();
|
||||||
|
const [deleteConfirmation, setDeleteConfirmation] = useState("");
|
||||||
|
const [destination, setDestination] = useState("");
|
||||||
|
const [overwrite, setOverwrite] = useState(false);
|
||||||
|
const [overwriteConfirmation, setOverwriteConfirmation] = useState("");
|
||||||
|
const [restoreStatus, setRestoreStatus] = useState<string>();
|
||||||
|
const load = async () => {
|
||||||
|
setState({ loading: true });
|
||||||
|
try {
|
||||||
|
setState({ loading: false, data: (await client.listBackups()).items });
|
||||||
|
} catch (error) {
|
||||||
|
if (expired(error)) onSessionExpired();
|
||||||
|
else setState({ loading: false, error: message(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
const headers = csrfHeaders();
|
||||||
|
return (
|
||||||
|
<Panel title="Backups">
|
||||||
|
{state.loading ? <Loading label="backups" /> : null}
|
||||||
|
<Retry error={state.error} load={() => void load()} />
|
||||||
|
{state.data?.length === 0 ? (
|
||||||
|
<p className="mt-4 text-slate-300">No backups have been committed.</p>
|
||||||
|
) : null}
|
||||||
|
<ul className="mt-4 space-y-3" aria-label="Backups">
|
||||||
|
{state.data?.map((item) => (
|
||||||
|
<li
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 p-4"
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="font-semibold hover:text-emerald-300"
|
||||||
|
onClick={() => setSelected(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Backup {item.id}
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
{item.integrity} · {item.logical_bytes} logical bytes
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{selected ? (
|
||||||
|
<section
|
||||||
|
className="mt-6 rounded border border-slate-700 bg-slate-900 p-4"
|
||||||
|
aria-labelledby="backup-detail"
|
||||||
|
>
|
||||||
|
<h3 id="backup-detail" className="font-semibold">
|
||||||
|
Backup detail
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2">Integrity: {selected.integrity}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded border border-slate-500 px-3 py-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setSelected(
|
||||||
|
await client.verifyBackup(
|
||||||
|
{ path: { backup_id: selected.id } },
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setPreview(message(error));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded border border-slate-500 px-3 py-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const result = await client.backupDeletePreview(
|
||||||
|
{ path: { backup_id: selected.id } },
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
setPreview(result.reason ?? result.destructive_action);
|
||||||
|
} catch (error) {
|
||||||
|
setPreview(message(error));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Preview deletion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{preview ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-3" role="status">
|
||||||
|
{preview}
|
||||||
|
</p>
|
||||||
|
<label className="mt-3 block text-sm">
|
||||||
|
Type backup ID to confirm deletion
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDeleteConfirmation(event.target.value)
|
||||||
|
}
|
||||||
|
value={deleteConfirmation}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{deleteConfirmation === selected.id ? (
|
||||||
|
<p className="mt-2 text-sm text-slate-300">
|
||||||
|
Deletion is confirmed locally, but this v2 API intentionally
|
||||||
|
exposes no delete endpoint. Use the CLI after reviewing this
|
||||||
|
impact preview.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<form
|
||||||
|
className="mt-4 space-y-2"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (overwrite && overwriteConfirmation !== destination) {
|
||||||
|
setRestoreStatus(
|
||||||
|
"Type the exact destination to confirm overwrite.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.createRestore(
|
||||||
|
{
|
||||||
|
path: { backup_id: selected.id },
|
||||||
|
body: {
|
||||||
|
destination,
|
||||||
|
dry_run: !overwrite,
|
||||||
|
selection: [],
|
||||||
|
overwrite_policy: overwrite ? "overwrite" : "fail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
setRestoreStatus(
|
||||||
|
overwrite
|
||||||
|
? "Restore queued with confirmed overwrite."
|
||||||
|
: "Restore dry run queued.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setRestoreStatus(message(error));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium"
|
||||||
|
htmlFor="restore-destination"
|
||||||
|
>
|
||||||
|
Restore destination
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
id="restore-destination"
|
||||||
|
onChange={(event) => setDestination(event.target.value)}
|
||||||
|
required
|
||||||
|
value={destination}
|
||||||
|
/>
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
checked={overwrite}
|
||||||
|
onChange={(event) => setOverwrite(event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Overwrite existing destination
|
||||||
|
</label>
|
||||||
|
{overwrite ? (
|
||||||
|
<label className="block text-sm">
|
||||||
|
Type the exact destination to confirm overwrite
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) =>
|
||||||
|
setOverwriteConfirmation(event.target.value)
|
||||||
|
}
|
||||||
|
value={overwriteConfirmation}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="rounded border border-slate-500 px-3 py-2"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{overwrite ? "Queue confirmed restore" : "Queue restore dry run"}
|
||||||
|
</button>
|
||||||
|
{restoreStatus ? <p role="status">{restoreStatus}</p> : null}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NotificationsPage({client,onSessionExpired}:Props){const [state,setState]=useState<State<{subscriptions:Subscription[];deliveries:Delivery[]}>>({loading:true});const [history,setHistory]=useState<string>();const load=async()=>{setState({loading:true});const results=await Promise.allSettled([client.listNotificationSubscriptions(),client.listNotificationDeliveries({query:{limit:20}})]);if(results.some((result)=>result.status==="rejected"&&expired(result.reason))){onSessionExpired();return;}const errors=results.filter((result)=>result.status==="rejected");setState({loading:false,data:{subscriptions:results[0].status==="fulfilled"?results[0].value.items:[],deliveries:results[1].status==="fulfilled"?results[1].value.items:[]},error:errors.length?"Some notification history could not be loaded.":undefined});};useEffect(()=>{void load();},[]);return <Panel title="Notifications & history">{state.loading?<Loading label="notifications"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?<><h3 className="mt-4 font-semibold">Subscriptions</h3>{state.data.subscriptions.length?<ul className="mt-2">{state.data.subscriptions.map(item=><li key={item.id}>{item.channel} · {item.state}</li>)}</ul>:<p className="mt-2 text-slate-300">No notification subscriptions.</p>}<h3 className="mt-5 font-semibold">Delivery history</h3>{state.data.deliveries.length?<ul className="mt-2">{state.data.deliveries.map(item=><li key={item.id}>{item.state} · {item.attempt_count} attempts <button className="ml-2 underline" onClick={async()=>{try{const attempts=await client.listNotificationAttempts({path:{delivery_id:item.id}});setHistory(`${attempts.items.length} delivery attempts loaded.`);}catch(error){setHistory(message(error));}}} type="button">View attempts</button>{item.state==="failed"?<button className="ml-2 underline" onClick={async()=>{try{await client.retryNotificationDelivery({path:{delivery_id:item.id}},{headers:{...csrfHeaders(),"Idempotency-Key":idempotencyKey()}});setHistory("Delivery retry queued.");}catch(error){setHistory(message(error));}}} type="button">Retry delivery</button>:null}</li>)}</ul>:<p className="mt-2 text-slate-300">No notification deliveries.</p>}{history?<p className="mt-3" role="status">{history}</p>:null}</>:null}</Panel>}
|
export function SecurityPage({ client, onSessionExpired }: Props) {
|
||||||
|
const [state, setState] = useState<
|
||||||
|
State<Components["schemas"]["RecoveryStatus"]>
|
||||||
|
>({ loading: true });
|
||||||
|
const load = async () => {
|
||||||
|
setState({ loading: true });
|
||||||
|
try {
|
||||||
|
setState({ loading: false, data: await client.recoveryStatus() });
|
||||||
|
} catch (error) {
|
||||||
|
if (expired(error)) onSessionExpired();
|
||||||
|
else setState({ loading: false, error: message(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Panel title="Security & recovery">
|
||||||
|
{state.loading ? <Loading label="recovery status" /> : null}
|
||||||
|
<Retry error={state.error} load={() => void load()} />
|
||||||
|
{state.data ? (
|
||||||
|
<article className="mt-4 rounded border border-slate-700 bg-slate-900 p-4">
|
||||||
|
<p>Recovery is {state.data.recovery_mode.replace("_", " ")}.</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Encrypted repositories: {state.data.encrypted_repository_count}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-slate-300">
|
||||||
|
Use the CLI and the recovery runbook:{" "}
|
||||||
|
<code>{state.data.runbook}</code>. Passphrases and recovery bundles
|
||||||
|
never enter the browser.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AuditPage({client,onSessionExpired}:Props){const [state,setState]=useState<State<Audit[]>>({loading:true});const load=async()=>{setState({loading:true});try{setState({loading:false,data:(await client.listAudit({query:{limit:50}})).items});}catch(error){if(expired(error))onSessionExpired();else setState({loading:false,error:message(error)});}};useEffect(()=>{void load();},[]);return <Panel title="Audit">{state.loading?<Loading label="audit events"/>:null}<Retry error={state.error} load={()=>void load()}/>{state.data?.length===0?<p className="mt-4 text-slate-300">No audit events.</p>:null}<ul className="mt-4 space-y-2">{state.data?.map(item=><li className="rounded border border-slate-700 p-3" key={item.id}>{item.action} {item.resource_type} · {item.outcome}</li>)}</ul></Panel>}
|
function NotificationControls({
|
||||||
|
client,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
client: BackupToolClient;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [channel, setChannel] = useState<"webhook" | "email">("webhook");
|
||||||
|
const [destination, setDestination] = useState("");
|
||||||
|
const [filters, setFilters] = useState("backup.committed");
|
||||||
|
const [secret, setSecret] = useState("");
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
async function create(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await client.createNotificationSubscription(
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
channel,
|
||||||
|
destination:
|
||||||
|
channel === "webhook"
|
||||||
|
? { url: destination }
|
||||||
|
: { address: destination },
|
||||||
|
event_filters: filters.split(/\s*,\s*/).filter(Boolean),
|
||||||
|
signing_secret: channel === "webhook" && secret ? secret : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ headers: csrfHeaders() },
|
||||||
|
);
|
||||||
|
setDestination("");
|
||||||
|
setSecret("");
|
||||||
|
setStatus("Subscription created. Signing secrets are write-only.");
|
||||||
|
onDone();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(message(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-4 rounded border border-slate-700 bg-slate-900 p-4"
|
||||||
|
onSubmit={create}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">Create notification subscription</h3>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label>
|
||||||
|
Channel
|
||||||
|
<select
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) =>
|
||||||
|
setChannel(event.target.value as "webhook" | "email")
|
||||||
|
}
|
||||||
|
value={channel}
|
||||||
|
>
|
||||||
|
<option value="webhook">Webhook</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{channel === "webhook" ? "Webhook URL" : "Email address"}
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setDestination(event.target.value)}
|
||||||
|
required
|
||||||
|
type={channel === "webhook" ? "url" : "email"}
|
||||||
|
value={destination}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sm:col-span-2">
|
||||||
|
Event filters (comma separated)
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setFilters(event.target.value)}
|
||||||
|
required
|
||||||
|
value={filters}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{channel === "webhook" ? (
|
||||||
|
<label className="sm:col-span-2">
|
||||||
|
Signing secret (optional)
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
type="password"
|
||||||
|
value={secret}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mt-4 rounded border border-slate-500 px-3 py-2"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create subscription
|
||||||
|
</button>
|
||||||
|
{status ? (
|
||||||
|
<p className="mt-3" role="status">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionActions({
|
||||||
|
client,
|
||||||
|
subscriptions,
|
||||||
|
}: {
|
||||||
|
client: BackupToolClient;
|
||||||
|
subscriptions: Subscription[];
|
||||||
|
}) {
|
||||||
|
const [id, setId] = useState("");
|
||||||
|
const [rotationSecret, setRotationSecret] = useState("");
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
async function test() {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await client.testNotificationSubscription(
|
||||||
|
{ path: { subscription_id: id } },
|
||||||
|
{ headers: csrfHeaders() },
|
||||||
|
);
|
||||||
|
setStatus("Selected subscription test queued.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(message(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function rotate(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await client.rotateNotificationSigningKey(
|
||||||
|
{ path: { subscription_id: id }, body: { secret: rotationSecret } },
|
||||||
|
{ headers: csrfHeaders() },
|
||||||
|
);
|
||||||
|
setRotationSecret("");
|
||||||
|
setStatus("Signing key rotation started with overlap.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(message(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!subscriptions.length) return null;
|
||||||
|
return (
|
||||||
|
<section className="mt-4 rounded border border-slate-700 bg-slate-900 p-4">
|
||||||
|
<h3 className="font-semibold">Subscription actions</h3>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
Subscription
|
||||||
|
<select
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
onChange={(event) => setId(event.target.value)}
|
||||||
|
value={id}
|
||||||
|
>
|
||||||
|
<option value="">Select subscription</option>
|
||||||
|
{subscriptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.channel} subscription
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="mt-3 rounded border border-slate-500 px-3 py-2"
|
||||||
|
disabled={!id}
|
||||||
|
onClick={() => void test()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Test selected subscription
|
||||||
|
</button>
|
||||||
|
<form className="mt-3" onSubmit={rotate}>
|
||||||
|
<label>
|
||||||
|
New webhook signing secret
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-slate-500 bg-slate-950 p-2"
|
||||||
|
disabled={!id}
|
||||||
|
onChange={(event) => setRotationSecret(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={rotationSecret}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="mt-3 rounded border border-slate-500 px-3 py-2"
|
||||||
|
disabled={!id}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Rotate signing key
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status ? (
|
||||||
|
<p className="mt-3" role="status">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsPage({ client, onSessionExpired }: Props) {
|
||||||
|
const [state, setState] = useState<
|
||||||
|
State<{ subscriptions: Subscription[]; deliveries: Delivery[] }>
|
||||||
|
>({ loading: true });
|
||||||
|
const [history, setHistory] = useState<string>();
|
||||||
|
const load = async () => {
|
||||||
|
setState({ loading: true });
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
client.listNotificationSubscriptions(),
|
||||||
|
client.listNotificationDeliveries({ query: { limit: 20 } }),
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
results.some(
|
||||||
|
(result) => result.status === "rejected" && expired(result.reason),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onSessionExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errors = results.filter((result) => result.status === "rejected");
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
data: {
|
||||||
|
subscriptions:
|
||||||
|
results[0].status === "fulfilled" ? results[0].value.items : [],
|
||||||
|
deliveries:
|
||||||
|
results[1].status === "fulfilled" ? results[1].value.items : [],
|
||||||
|
},
|
||||||
|
error: errors.length
|
||||||
|
? "Some notification history could not be loaded."
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Panel title="Notifications & history">
|
||||||
|
<NotificationControls client={client} onDone={() => void load()} />
|
||||||
|
<SubscriptionActions
|
||||||
|
client={client}
|
||||||
|
subscriptions={state.data?.subscriptions ?? []}
|
||||||
|
/>
|
||||||
|
{state.loading ? <Loading label="notifications" /> : null}
|
||||||
|
<Retry error={state.error} load={() => void load()} />
|
||||||
|
{state.data ? (
|
||||||
|
<>
|
||||||
|
<h3 className="mt-4 font-semibold">Subscriptions</h3>
|
||||||
|
{state.data.subscriptions.length ? (
|
||||||
|
<ul className="mt-2">
|
||||||
|
{state.data.subscriptions.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{item.channel} · {item.state}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-slate-300">
|
||||||
|
No notification subscriptions.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 className="mt-5 font-semibold">Delivery history</h3>
|
||||||
|
{state.data.deliveries.length ? (
|
||||||
|
<ul className="mt-2">
|
||||||
|
{state.data.deliveries.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{item.state} · {item.attempt_count} attempts{" "}
|
||||||
|
<button
|
||||||
|
className="ml-2 underline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const attempts = await client.listNotificationAttempts({
|
||||||
|
path: { delivery_id: item.id },
|
||||||
|
});
|
||||||
|
setHistory(
|
||||||
|
`${attempts.items.length} delivery attempts loaded.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setHistory(message(error));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
View attempts
|
||||||
|
</button>
|
||||||
|
{item.state === "failed" ? (
|
||||||
|
<button
|
||||||
|
className="ml-2 underline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await client.retryNotificationDelivery(
|
||||||
|
{ path: { delivery_id: item.id } },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...csrfHeaders(),
|
||||||
|
"Idempotency-Key": idempotencyKey(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setHistory("Delivery retry queued.");
|
||||||
|
} catch (error) {
|
||||||
|
setHistory(message(error));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Retry delivery
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-slate-300">No notification deliveries.</p>
|
||||||
|
)}
|
||||||
|
{history ? (
|
||||||
|
<p className="mt-3" role="status">
|
||||||
|
{history}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditPage({ client, onSessionExpired }: Props) {
|
||||||
|
const [state, setState] = useState<State<Audit[]>>({ loading: true });
|
||||||
|
const load = async () => {
|
||||||
|
setState({ loading: true });
|
||||||
|
try {
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
data: (await client.listAudit({ query: { limit: 50 } })).items,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (expired(error)) onSessionExpired();
|
||||||
|
else setState({ loading: false, error: message(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Panel title="Audit">
|
||||||
|
{state.loading ? <Loading label="audit events" /> : null}
|
||||||
|
<Retry error={state.error} load={() => void load()} />
|
||||||
|
{state.data?.length === 0 ? (
|
||||||
|
<p className="mt-4 text-slate-300">No audit events.</p>
|
||||||
|
) : null}
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{state.data?.map((item) => (
|
||||||
|
<li className="rounded border border-slate-700 p-3" key={item.id}>
|
||||||
|
{item.action} {item.resource_type} · {item.outcome}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+256
-139
@@ -1,159 +1,276 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type BackupToolClient,
|
Component,
|
||||||
type Components,
|
type ErrorInfo,
|
||||||
isApiError,
|
type ReactNode,
|
||||||
} from "../api/generated/client";
|
lazy,
|
||||||
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows";
|
Suspense,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Navigate,
|
||||||
|
NavLink,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
import type { BackupToolClient, Components } from "../api/generated/client";
|
||||||
|
import type { OperatorPageProps } from "./OperatorPages";
|
||||||
|
|
||||||
type SessionUser = Components["schemas"]["SessionUser"];
|
type SessionUser = Components["schemas"]["SessionUser"];
|
||||||
type SourceSummary = Components["schemas"]["SourceSummary"];
|
|
||||||
type RepositorySummary = Components["schemas"]["RepositorySummary"];
|
|
||||||
type JobSummary = Components["schemas"]["JobSummary"];
|
|
||||||
type ExecutionSummary = Components["schemas"]["ExecutionSummary"];
|
|
||||||
type Page = "dashboard" | "sources" | "repositories" | "jobs" | "executions" | "backups" | "security" | "notifications" | "audit";
|
|
||||||
type LoadState<T> =
|
|
||||||
| { kind: "loading" }
|
|
||||||
| { kind: "ready"; items: T }
|
|
||||||
| { kind: "error"; message: string };
|
|
||||||
|
|
||||||
const pages: Array<{ id: Page; label: string }> = [
|
type RouteDefinition = {
|
||||||
{ id: "dashboard", label: "Dashboard" },
|
path: string;
|
||||||
{ id: "sources", label: "Sources" },
|
label: string;
|
||||||
{ id: "repositories", label: "Repositories" },
|
title: string;
|
||||||
{ id: "jobs", label: "Jobs & schedules" },
|
component: React.LazyExoticComponent<React.ComponentType<OperatorPageProps>>;
|
||||||
{ id: "executions", label: "Executions" },
|
};
|
||||||
{ id: "backups", label: "Backups" },
|
|
||||||
{ id: "security", label: "Security & recovery" },
|
const DashboardPage = lazy(() =>
|
||||||
{ id: "notifications", label: "Notifications" },
|
import("./OperatorPages").then(({ DashboardPage: Page }) => ({
|
||||||
{ id: "audit", label: "Audit" },
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const SourcesPage = lazy(() =>
|
||||||
|
import("./OperatorPages").then(({ SourcesPage: Page }) => ({
|
||||||
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const RepositoriesPage = lazy(() =>
|
||||||
|
import("./OperatorPages").then(({ RepositoriesPage: Page }) => ({
|
||||||
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const JobsPage = lazy(() =>
|
||||||
|
import("./OperatorPages").then(({ JobsPage: Page }) => ({ default: Page })),
|
||||||
|
);
|
||||||
|
const ExecutionsPage = lazy(() =>
|
||||||
|
import("./OperatorPages").then(({ ExecutionsPage: Page }) => ({
|
||||||
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const BackupsPage = lazy(() =>
|
||||||
|
import("./M13Workflows").then(({ BackupsPage: Page }) => ({ default: Page })),
|
||||||
|
);
|
||||||
|
const SecurityPage = lazy(() =>
|
||||||
|
import("./M13Workflows").then(({ SecurityPage: Page }) => ({
|
||||||
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const NotificationsPage = lazy(() =>
|
||||||
|
import("./M13Workflows").then(({ NotificationsPage: Page }) => ({
|
||||||
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const AuditPage = lazy(() =>
|
||||||
|
import("./M13Workflows").then(({ AuditPage: Page }) => ({ default: Page })),
|
||||||
|
);
|
||||||
|
const AdministrationPage = lazy(() =>
|
||||||
|
import("./AdministrationPage").then(({ AdministrationPage: Page }) => ({
|
||||||
|
default: Page,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const routes: RouteDefinition[] = [
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
title: "Dashboard",
|
||||||
|
component: DashboardPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/sources",
|
||||||
|
label: "Sources",
|
||||||
|
title: "Sources",
|
||||||
|
component: SourcesPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/repositories",
|
||||||
|
label: "Repositories",
|
||||||
|
title: "Repositories",
|
||||||
|
component: RepositoriesPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/jobs",
|
||||||
|
label: "Jobs & schedules",
|
||||||
|
title: "Jobs & schedules",
|
||||||
|
component: JobsPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/executions",
|
||||||
|
label: "Executions",
|
||||||
|
title: "Executions",
|
||||||
|
component: ExecutionsPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/backups",
|
||||||
|
label: "Backups",
|
||||||
|
title: "Backups",
|
||||||
|
component: BackupsPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/security",
|
||||||
|
label: "Security & recovery",
|
||||||
|
title: "Security & recovery",
|
||||||
|
component: SecurityPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/notifications",
|
||||||
|
label: "Notifications",
|
||||||
|
title: "Notifications",
|
||||||
|
component: NotificationsPage,
|
||||||
|
},
|
||||||
|
{ path: "/audit", label: "Audit", title: "Audit", component: AuditPage },
|
||||||
|
{
|
||||||
|
path: "/administration",
|
||||||
|
label: "Administration",
|
||||||
|
title: "Administration",
|
||||||
|
component: AdministrationPage,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
class RouteErrorBoundary extends Component<
|
||||||
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
|
{ children: ReactNode },
|
||||||
return "We could not reach the Backup Tool service. Check your connection and try again.";
|
{ error?: Error }
|
||||||
}
|
> {
|
||||||
|
state: { error?: Error } = {};
|
||||||
|
|
||||||
function isSessionExpired(error: unknown): boolean {
|
static getDerivedStateFromError(error: Error) {
|
||||||
return isApiError(error) && error.status === 401;
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorPanel({ message, retry }: { message: string; retry: () => void }) {
|
componentDidCatch(_error: Error, _info: ErrorInfo) {}
|
||||||
return <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{message}</p><button className="mt-3 rounded-md border border-slate-500 px-3 py-2 font-semibold hover:bg-slate-800" onClick={retry} type="button">Try again</button></div>;
|
|
||||||
}
|
componentDidUpdate(previousProps: Readonly<{ children: ReactNode }>) {
|
||||||
|
if (this.state.error && previousProps.children !== this.props.children)
|
||||||
function Loading({ label }: { label: string }) {
|
this.setState({ error: undefined });
|
||||||
return <p aria-live="polite" className="mt-4 text-slate-300" role="status">Loading {label}…</p>;
|
}
|
||||||
}
|
|
||||||
|
render() {
|
||||||
function ResourceSection({ children, title }: { children: ReactNode; title: string }) {
|
if (this.state.error)
|
||||||
return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>;
|
return (
|
||||||
}
|
<section className="mx-auto max-w-6xl p-4 sm:p-6">
|
||||||
|
<h2 className="text-xl font-semibold">Page unavailable</h2>
|
||||||
function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
<p className="mt-3" role="alert">
|
||||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
This page could not be loaded. Try another page or refresh your
|
||||||
async function load() {
|
browser.
|
||||||
setState({ kind: "loading" });
|
</p>
|
||||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
</section>
|
||||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
);
|
||||||
|
return this.props.children;
|
||||||
}
|
}
|
||||||
useEffect(() => { void load(); }, []);
|
|
||||||
return <ResourceSection title="Dashboard">
|
|
||||||
<p className="mt-2 text-slate-300">Repository availability at a glance.</p>
|
|
||||||
{state.kind === "loading" ? <Loading label="dashboard data" /> : null}
|
|
||||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
|
||||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
|
||||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 grid gap-3 sm:grid-cols-2">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-2 text-sm text-slate-300">{repository.state} · {repository.encryption}</p></li>)}</ul> : null}
|
|
||||||
</ResourceSection>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourcesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
function RouteLoading() {
|
||||||
const [state, setState] = useState<LoadState<SourceSummary[]>>({ kind: "loading" });
|
return (
|
||||||
async function load() {
|
<section className="mx-auto max-w-6xl p-4 sm:p-6">
|
||||||
setState({ kind: "loading" });
|
<p aria-live="polite" role="status">
|
||||||
try { setState({ kind: "ready", items: (await client.listSources()).items }); }
|
Loading page…
|
||||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
</p>
|
||||||
}
|
</section>
|
||||||
useEffect(() => { void load(); }, []);
|
);
|
||||||
return <ResourceSection title="Sources">
|
|
||||||
{state.kind === "loading" ? <Loading label="sources" /> : null}
|
|
||||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
|
||||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No sources have been configured.</p> : null}
|
|
||||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured sources" className="mt-4 space-y-3">{state.items.map((source) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={source.id}><h3 className="font-semibold">{source.name}</h3><p className="mt-1 text-sm text-slate-300">{source.kind} · {source.state}</p></li>)}</ul> : null}
|
|
||||||
</ResourceSection>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
function SkipLink() {
|
||||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
return (
|
||||||
async function load() {
|
<a
|
||||||
setState({ kind: "loading" });
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 focus:rounded focus:bg-slate-100 focus:px-4 focus:py-2 focus:text-slate-950"
|
||||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
href="#main-content"
|
||||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
onClick={(event) => {
|
||||||
}
|
event.preventDefault();
|
||||||
useEffect(() => { void load(); }, []);
|
document.getElementById("main-content")?.focus();
|
||||||
return <ResourceSection title="Repositories">
|
}}
|
||||||
{state.kind === "loading" ? <Loading label="repositories" /> : null}
|
>
|
||||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
Skip to main content
|
||||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
</a>
|
||||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 space-y-3">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-1 text-sm text-slate-300">Format {repository.format_version} · {repository.encryption} · {repository.state}</p></li>)}</ul> : null}
|
);
|
||||||
</ResourceSection>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
function PageRoute({
|
||||||
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" });
|
component: Page,
|
||||||
async function load() {
|
...props
|
||||||
setState({ kind: "loading" });
|
}: RouteDefinition & OperatorPageProps) {
|
||||||
try { setState({ kind: "ready", items: (await client.listJobs()).items }); }
|
return (
|
||||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
<RouteErrorBoundary>
|
||||||
}
|
<Suspense fallback={<RouteLoading />}>
|
||||||
useEffect(() => { void load(); }, []);
|
<Page {...props} />
|
||||||
return <ResourceSection title="Jobs & schedules">
|
</Suspense>
|
||||||
{state.kind === "loading" ? <Loading label="jobs" /> : null}
|
</RouteErrorBoundary>
|
||||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
);
|
||||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No jobs have been configured.</p> : null}
|
|
||||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured jobs" className="mt-4 space-y-3">{state.items.map((job) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={job.id}><h3 className="font-semibold">{job.name}</h3><p className="mt-1 text-sm text-slate-300">{job.requested_mode} · {job.enabled ? "enabled" : "disabled"}</p><p className="mt-2 text-sm text-slate-300">{job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}</p></li>)}</ul> : null}
|
|
||||||
</ResourceSection>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
function RouteEffects() {
|
||||||
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" });
|
const { pathname } = useLocation();
|
||||||
const [selectedId, setSelectedId] = useState<string>();
|
const hasMounted = useRef(false);
|
||||||
const [detail, setDetail] = useState<LoadState<ExecutionSummary> | undefined>();
|
|
||||||
async function load() {
|
|
||||||
setState({ kind: "loading" });
|
|
||||||
try { setState({ kind: "ready", items: (await client.listExecutions()).items }); }
|
|
||||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
|
||||||
}
|
|
||||||
async function loadDetail(executionId: string) {
|
|
||||||
setSelectedId(executionId); setDetail({ kind: "loading" });
|
|
||||||
try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); }
|
|
||||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); }
|
|
||||||
}
|
|
||||||
useEffect(() => { void load(); }, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId || typeof EventSource === "undefined") return;
|
const route = routes.find((item) => item.path === pathname);
|
||||||
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } }));
|
document.title = `${route?.title ?? "Dashboard"} | Backup Tool`;
|
||||||
stream.onmessage = (event) => {
|
if (hasMounted.current) document.getElementById("main-content")?.focus();
|
||||||
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); }
|
hasMounted.current = true;
|
||||||
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); }
|
}, [pathname]);
|
||||||
};
|
|
||||||
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); };
|
return null;
|
||||||
return () => stream.close();
|
|
||||||
}, [client, selectedId]);
|
|
||||||
return <ResourceSection title="Executions">
|
|
||||||
{state.kind === "loading" ? <Loading label="executions" /> : null}
|
|
||||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
|
||||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No executions have been queued.</p> : null}
|
|
||||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Executions" className="mt-4 space-y-3">{state.items.map((execution) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={execution.id}><button aria-current={selectedId === execution.id ? "true" : undefined} className="text-left font-semibold hover:text-emerald-300" onClick={() => { void loadDetail(execution.id); }} type="button">Execution {execution.id}</button><p className="mt-1 text-sm text-slate-300">{execution.state} · attempt {execution.attempt}</p></li>)}</ul> : null}
|
|
||||||
{detail?.kind === "loading" ? <Loading label="execution details" /> : null}
|
|
||||||
{detail?.kind === "error" && selectedId ? <ErrorPanel message={detail.message} retry={() => { void loadDetail(selectedId); }} /> : null}
|
|
||||||
{detail?.kind === "ready" ? <section aria-labelledby="execution-detail-heading" className="mt-6 rounded-lg border border-slate-700 bg-slate-900 p-4"><h3 id="execution-detail-heading" className="text-lg font-semibold">Execution detail</h3><dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-slate-400">State</dt><dd>{detail.items.state}</dd></div><div><dt className="text-slate-400">Attempt</dt><dd>{detail.items.attempt}</dd></div><div><dt className="text-slate-400">Reason</dt><dd>{detail.items.reason_code ?? "None"}</dd></div></dl></section> : null}
|
|
||||||
</ResourceSection>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OperatorViews({ client, onSessionExpired, user }: { client: BackupToolClient; onSessionExpired: () => void; user: SessionUser }) {
|
export function OperatorViews({
|
||||||
const [page, setPage] = useState<Page>("dashboard");
|
client,
|
||||||
const common = { client, onSessionExpired };
|
onSessionExpired,
|
||||||
return <main className="min-h-screen bg-slate-950 text-slate-100"><header className="border-b border-slate-800 bg-slate-900"><div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6"><div><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-1 text-2xl font-bold">Operator console</h1></div><p className="text-sm text-slate-300"><span className="sr-only">Signed in as </span>{user.username}</p></div><nav aria-label="Primary" className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6">{pages.map((item) => <button aria-current={page === item.id ? "page" : undefined} className="whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800" key={item.id} onClick={() => setPage(item.id)} type="button">{item.label}</button>)}</nav></header>{page === "dashboard" ? <DashboardPage {...common} /> : null}{page === "sources" ? <SourcesPage {...common} /> : null}{page === "repositories" ? <RepositoriesPage {...common} /> : null}{page === "jobs" ? <JobsPage {...common} /> : null}{page === "executions" ? <ExecutionsPage {...common} /> : null}{page === "backups" ? <BackupsPage {...common} /> : null}{page === "security" ? <SecurityPage {...common} /> : null}{page === "notifications" ? <NotificationsPage {...common} /> : null}{page === "audit" ? <AuditPage {...common} /> : null}</main>;
|
user,
|
||||||
|
}: {
|
||||||
|
client: BackupToolClient;
|
||||||
|
onSessionExpired: () => void;
|
||||||
|
user: SessionUser;
|
||||||
|
}) {
|
||||||
|
const pageProps = { client, onSessionExpired };
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<SkipLink />
|
||||||
|
<header className="border-b border-slate-800 bg-slate-900">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">
|
||||||
|
Backup Tool
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold">Operator console</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
<span className="sr-only">Signed in as </span>
|
||||||
|
{user.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
aria-label="Primary"
|
||||||
|
className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6"
|
||||||
|
>
|
||||||
|
{routes.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800 focus-visible:bg-slate-800 ${isActive ? "bg-slate-800 text-emerald-300" : ""}`
|
||||||
|
}
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main id="main-content" tabIndex={-1}>
|
||||||
|
<RouteEffects />
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Navigate replace to="/dashboard" />} path="/" />
|
||||||
|
{routes.map((route) => (
|
||||||
|
<Route
|
||||||
|
element={<PageRoute {...route} {...pageProps} />}
|
||||||
|
key={route.path}
|
||||||
|
path={route.path}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Route element={<Navigate replace to="/dashboard" />} path="*" />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+161
@@ -416,6 +416,84 @@
|
|||||||
"title": "JobList",
|
"title": "JobList",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"JobPatch": {
|
||||||
|
"properties": {
|
||||||
|
"allow_empty": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Allow Empty"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Enabled"
|
||||||
|
},
|
||||||
|
"exclusions": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Exclusions"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 255,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"requested_mode": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Requested Mode"
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Retention"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "JobPatch",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"JobSummary": {
|
"JobSummary": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
@@ -2835,6 +2913,89 @@
|
|||||||
"summary": "Create Job"
|
"summary": "Create Job"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v2/jobs/{job_id}": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "patch_job_api_v2_jobs__job_id__patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "job_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"title": "Job Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "authorization",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Authorization"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-CSRF-Token",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Csrf-Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/JobPatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Response Patch Job Api V2 Jobs Job Id Patch",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Patch Job"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v2/jobs/{job_id}/executions": {
|
"/api/v2/jobs/{job_id}/executions": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "enqueue_execution_api_v2_jobs__job_id__executions_post",
|
"operationId": "enqueue_execution_api_v2_jobs__job_id__executions_post",
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
|
|||||||
)
|
)
|
||||||
assert job.status_code == 201
|
assert job.status_code == 201
|
||||||
assert "destination_path" not in job.json()
|
assert "destination_path" not in job.json()
|
||||||
|
invalid_patch = await client.patch(
|
||||||
|
f"/api/v2/jobs/{job.json()['id']}", json={}, headers=headers
|
||||||
|
)
|
||||||
|
assert invalid_patch.status_code == 422
|
||||||
|
updated_job = await client.patch(
|
||||||
|
f"/api/v2/jobs/{job.json()['id']}",
|
||||||
|
json={"name": "renamed-job", "exclusions": ["*.cache"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated_job.status_code == 200
|
||||||
|
assert updated_job.json()["name"] == "renamed-job"
|
||||||
|
assert updated_job.json()["exclusions"] == ["*.cache"]
|
||||||
execution = await client.post(
|
execution = await client.post(
|
||||||
f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers
|
f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user