feat(ui): add routed operator workflows

This commit is contained in:
2026-07-31 17:19:08 +02:00
parent 22567c5fb1
commit 9023963bf1
14 changed files with 2846 additions and 275 deletions
+256 -139
View File
@@ -1,159 +1,276 @@
import { type ReactNode, useEffect, useState } from "react";
import {
type BackupToolClient,
type Components,
isApiError,
} from "../api/generated/client";
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows";
Component,
type ErrorInfo,
type ReactNode,
lazy,
Suspense,
useEffect,
useRef,
} from "react";
import {
Navigate,
NavLink,
Route,
Routes,
useLocation,
} from "react-router-dom";
import type { BackupToolClient, Components } from "../api/generated/client";
import type { OperatorPageProps } from "./OperatorPages";
type SessionUser = Components["schemas"]["SessionUser"];
type SourceSummary = Components["schemas"]["SourceSummary"];
type RepositorySummary = Components["schemas"]["RepositorySummary"];
type JobSummary = Components["schemas"]["JobSummary"];
type ExecutionSummary = Components["schemas"]["ExecutionSummary"];
type Page = "dashboard" | "sources" | "repositories" | "jobs" | "executions" | "backups" | "security" | "notifications" | "audit";
type LoadState<T> =
| { kind: "loading" }
| { kind: "ready"; items: T }
| { kind: "error"; message: string };
const pages: Array<{ id: Page; label: string }> = [
{ id: "dashboard", label: "Dashboard" },
{ id: "sources", label: "Sources" },
{ id: "repositories", label: "Repositories" },
{ id: "jobs", label: "Jobs & schedules" },
{ id: "executions", label: "Executions" },
{ id: "backups", label: "Backups" },
{ id: "security", label: "Security & recovery" },
{ id: "notifications", label: "Notifications" },
{ id: "audit", label: "Audit" },
type RouteDefinition = {
path: string;
label: string;
title: string;
component: React.LazyExoticComponent<React.ComponentType<OperatorPageProps>>;
};
const DashboardPage = lazy(() =>
import("./OperatorPages").then(({ DashboardPage: Page }) => ({
default: Page,
})),
);
const SourcesPage = lazy(() =>
import("./OperatorPages").then(({ SourcesPage: Page }) => ({
default: Page,
})),
);
const RepositoriesPage = lazy(() =>
import("./OperatorPages").then(({ RepositoriesPage: Page }) => ({
default: Page,
})),
);
const JobsPage = lazy(() =>
import("./OperatorPages").then(({ JobsPage: Page }) => ({ default: Page })),
);
const ExecutionsPage = lazy(() =>
import("./OperatorPages").then(({ ExecutionsPage: Page }) => ({
default: Page,
})),
);
const BackupsPage = lazy(() =>
import("./M13Workflows").then(({ BackupsPage: Page }) => ({ default: Page })),
);
const SecurityPage = lazy(() =>
import("./M13Workflows").then(({ SecurityPage: Page }) => ({
default: Page,
})),
);
const NotificationsPage = lazy(() =>
import("./M13Workflows").then(({ NotificationsPage: Page }) => ({
default: Page,
})),
);
const AuditPage = lazy(() =>
import("./M13Workflows").then(({ AuditPage: Page }) => ({ default: Page })),
);
const AdministrationPage = lazy(() =>
import("./AdministrationPage").then(({ AdministrationPage: Page }) => ({
default: Page,
})),
);
const routes: RouteDefinition[] = [
{
path: "/dashboard",
label: "Dashboard",
title: "Dashboard",
component: DashboardPage,
},
{
path: "/sources",
label: "Sources",
title: "Sources",
component: SourcesPage,
},
{
path: "/repositories",
label: "Repositories",
title: "Repositories",
component: RepositoriesPage,
},
{
path: "/jobs",
label: "Jobs & schedules",
title: "Jobs & schedules",
component: JobsPage,
},
{
path: "/executions",
label: "Executions",
title: "Executions",
component: ExecutionsPage,
},
{
path: "/backups",
label: "Backups",
title: "Backups",
component: BackupsPage,
},
{
path: "/security",
label: "Security & recovery",
title: "Security & recovery",
component: SecurityPage,
},
{
path: "/notifications",
label: "Notifications",
title: "Notifications",
component: NotificationsPage,
},
{ path: "/audit", label: "Audit", title: "Audit", component: AuditPage },
{
path: "/administration",
label: "Administration",
title: "Administration",
component: AdministrationPage,
},
];
function errorMessage(error: unknown): string {
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
return "We could not reach the Backup Tool service. Check your connection and try again.";
}
class RouteErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
function isSessionExpired(error: unknown): boolean {
return isApiError(error) && error.status === 401;
}
function ErrorPanel({ message, retry }: { message: string; retry: () => void }) {
return <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{message}</p><button className="mt-3 rounded-md border border-slate-500 px-3 py-2 font-semibold hover:bg-slate-800" onClick={retry} type="button">Try again</button></div>;
}
function Loading({ label }: { label: string }) {
return <p aria-live="polite" className="mt-4 text-slate-300" role="status">Loading {label}</p>;
}
function ResourceSection({ children, title }: { children: ReactNode; title: string }) {
return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>;
}
function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(_error: Error, _info: ErrorInfo) {}
componentDidUpdate(previousProps: Readonly<{ children: ReactNode }>) {
if (this.state.error && previousProps.children !== this.props.children)
this.setState({ error: undefined });
}
render() {
if (this.state.error)
return (
<section className="mx-auto max-w-6xl p-4 sm:p-6">
<h2 className="text-xl font-semibold">Page unavailable</h2>
<p className="mt-3" role="alert">
This page could not be loaded. Try another page or refresh your
browser.
</p>
</section>
);
return this.props.children;
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Dashboard">
<p className="mt-2 text-slate-300">Repository availability at a glance.</p>
{state.kind === "loading" ? <Loading label="dashboard data" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 grid gap-3 sm:grid-cols-2">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-2 text-sm text-slate-300">{repository.state} · {repository.encryption}</p></li>)}</ul> : null}
</ResourceSection>;
}
function SourcesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<SourceSummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listSources()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Sources">
{state.kind === "loading" ? <Loading label="sources" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No sources have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured sources" className="mt-4 space-y-3">{state.items.map((source) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={source.id}><h3 className="font-semibold">{source.name}</h3><p className="mt-1 text-sm text-slate-300">{source.kind} · {source.state}</p></li>)}</ul> : null}
</ResourceSection>;
function RouteLoading() {
return (
<section className="mx-auto max-w-6xl p-4 sm:p-6">
<p aria-live="polite" role="status">
Loading page
</p>
</section>
);
}
function RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Repositories">
{state.kind === "loading" ? <Loading label="repositories" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 space-y-3">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-1 text-sm text-slate-300">Format {repository.format_version} · {repository.encryption} · {repository.state}</p></li>)}</ul> : null}
</ResourceSection>;
function SkipLink() {
return (
<a
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 focus:rounded focus:bg-slate-100 focus:px-4 focus:py-2 focus:text-slate-950"
href="#main-content"
onClick={(event) => {
event.preventDefault();
document.getElementById("main-content")?.focus();
}}
>
Skip to main content
</a>
);
}
function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" });
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listJobs()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
return <ResourceSection title="Jobs & schedules">
{state.kind === "loading" ? <Loading label="jobs" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No jobs have been configured.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured jobs" className="mt-4 space-y-3">{state.items.map((job) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={job.id}><h3 className="font-semibold">{job.name}</h3><p className="mt-1 text-sm text-slate-300">{job.requested_mode} · {job.enabled ? "enabled" : "disabled"}</p><p className="mt-2 text-sm text-slate-300">{job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}</p></li>)}</ul> : null}
</ResourceSection>;
function PageRoute({
component: Page,
...props
}: RouteDefinition & OperatorPageProps) {
return (
<RouteErrorBoundary>
<Suspense fallback={<RouteLoading />}>
<Page {...props} />
</Suspense>
</RouteErrorBoundary>
);
}
function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" });
const [selectedId, setSelectedId] = useState<string>();
const [detail, setDetail] = useState<LoadState<ExecutionSummary> | undefined>();
async function load() {
setState({ kind: "loading" });
try { setState({ kind: "ready", items: (await client.listExecutions()).items }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
}
async function loadDetail(executionId: string) {
setSelectedId(executionId); setDetail({ kind: "loading" });
try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); }
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); }
}
useEffect(() => { void load(); }, []);
function RouteEffects() {
const { pathname } = useLocation();
const hasMounted = useRef(false);
useEffect(() => {
if (!selectedId || typeof EventSource === "undefined") return;
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } }));
stream.onmessage = (event) => {
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); }
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); }
};
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); };
return () => stream.close();
}, [client, selectedId]);
return <ResourceSection title="Executions">
{state.kind === "loading" ? <Loading label="executions" /> : null}
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No executions have been queued.</p> : null}
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Executions" className="mt-4 space-y-3">{state.items.map((execution) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={execution.id}><button aria-current={selectedId === execution.id ? "true" : undefined} className="text-left font-semibold hover:text-emerald-300" onClick={() => { void loadDetail(execution.id); }} type="button">Execution {execution.id}</button><p className="mt-1 text-sm text-slate-300">{execution.state} · attempt {execution.attempt}</p></li>)}</ul> : null}
{detail?.kind === "loading" ? <Loading label="execution details" /> : null}
{detail?.kind === "error" && selectedId ? <ErrorPanel message={detail.message} retry={() => { void loadDetail(selectedId); }} /> : null}
{detail?.kind === "ready" ? <section aria-labelledby="execution-detail-heading" className="mt-6 rounded-lg border border-slate-700 bg-slate-900 p-4"><h3 id="execution-detail-heading" className="text-lg font-semibold">Execution detail</h3><dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-slate-400">State</dt><dd>{detail.items.state}</dd></div><div><dt className="text-slate-400">Attempt</dt><dd>{detail.items.attempt}</dd></div><div><dt className="text-slate-400">Reason</dt><dd>{detail.items.reason_code ?? "None"}</dd></div></dl></section> : null}
</ResourceSection>;
const route = routes.find((item) => item.path === pathname);
document.title = `${route?.title ?? "Dashboard"} | Backup Tool`;
if (hasMounted.current) document.getElementById("main-content")?.focus();
hasMounted.current = true;
}, [pathname]);
return null;
}
export function OperatorViews({ client, onSessionExpired, user }: { client: BackupToolClient; onSessionExpired: () => void; user: SessionUser }) {
const [page, setPage] = useState<Page>("dashboard");
const common = { client, onSessionExpired };
return <main className="min-h-screen bg-slate-950 text-slate-100"><header className="border-b border-slate-800 bg-slate-900"><div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6"><div><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-1 text-2xl font-bold">Operator console</h1></div><p className="text-sm text-slate-300"><span className="sr-only">Signed in as </span>{user.username}</p></div><nav aria-label="Primary" className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6">{pages.map((item) => <button aria-current={page === item.id ? "page" : undefined} className="whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800" key={item.id} onClick={() => setPage(item.id)} type="button">{item.label}</button>)}</nav></header>{page === "dashboard" ? <DashboardPage {...common} /> : null}{page === "sources" ? <SourcesPage {...common} /> : null}{page === "repositories" ? <RepositoriesPage {...common} /> : null}{page === "jobs" ? <JobsPage {...common} /> : null}{page === "executions" ? <ExecutionsPage {...common} /> : null}{page === "backups" ? <BackupsPage {...common} /> : null}{page === "security" ? <SecurityPage {...common} /> : null}{page === "notifications" ? <NotificationsPage {...common} /> : null}{page === "audit" ? <AuditPage {...common} /> : null}</main>;
export function OperatorViews({
client,
onSessionExpired,
user,
}: {
client: BackupToolClient;
onSessionExpired: () => void;
user: SessionUser;
}) {
const pageProps = { client, onSessionExpired };
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<SkipLink />
<header className="border-b border-slate-800 bg-slate-900">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6">
<div>
<p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">
Backup Tool
</p>
<h1 className="mt-1 text-2xl font-bold">Operator console</h1>
</div>
<p className="text-sm text-slate-300">
<span className="sr-only">Signed in as </span>
{user.username}
</p>
</div>
<nav
aria-label="Primary"
className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6"
>
{routes.map((item) => (
<NavLink
className={({ isActive }) =>
`whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800 focus-visible:bg-slate-800 ${isActive ? "bg-slate-800 text-emerald-300" : ""}`
}
key={item.path}
to={item.path}
>
{item.label}
</NavLink>
))}
</nav>
</header>
<main id="main-content" tabIndex={-1}>
<RouteEffects />
<Routes>
<Route element={<Navigate replace to="/dashboard" />} path="/" />
{routes.map((route) => (
<Route
element={<PageRoute {...route} {...pageProps} />}
key={route.path}
path={route.path}
/>
))}
<Route element={<Navigate replace to="/dashboard" />} path="*" />
</Routes>
</main>
</div>
);
}