feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
type BackupToolClient,
|
||||
type Components,
|
||||
isApiError,
|
||||
} from "../api/generated/client";
|
||||
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
|
||||
return "We could not reach the Backup Tool service. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function isSessionExpired(error: unknown): boolean {
|
||||
return isApiError(error) && error.status === 401;
|
||||
}
|
||||
|
||||
function 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) }); }
|
||||
}
|
||||
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 RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Repositories">
|
||||
{state.kind === "loading" ? <Loading label="repositories" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 space-y-3">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-1 text-sm text-slate-300">Format {repository.format_version} · {repository.encryption} · {repository.state}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
|
||||
function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listJobs()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Jobs & schedules">
|
||||
{state.kind === "loading" ? <Loading label="jobs" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No jobs have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured jobs" className="mt-4 space-y-3">{state.items.map((job) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={job.id}><h3 className="font-semibold">{job.name}</h3><p className="mt-1 text-sm text-slate-300">{job.requested_mode} · {job.enabled ? "enabled" : "disabled"}</p><p className="mt-2 text-sm text-slate-300">{job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
|
||||
function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" });
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [detail, setDetail] = useState<LoadState<ExecutionSummary> | undefined>();
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listExecutions()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
async function loadDetail(executionId: string) {
|
||||
setSelectedId(executionId); setDetail({ kind: "loading" });
|
||||
try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
useEffect(() => {
|
||||
if (!selectedId || typeof EventSource === "undefined") return;
|
||||
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } }));
|
||||
stream.onmessage = (event) => {
|
||||
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); }
|
||||
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); }
|
||||
};
|
||||
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); };
|
||||
return () => stream.close();
|
||||
}, [client, selectedId]);
|
||||
return <ResourceSection title="Executions">
|
||||
{state.kind === "loading" ? <Loading label="executions" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No executions have been queued.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Executions" className="mt-4 space-y-3">{state.items.map((execution) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={execution.id}><button aria-current={selectedId === execution.id ? "true" : undefined} className="text-left font-semibold hover:text-emerald-300" onClick={() => { void loadDetail(execution.id); }} type="button">Execution {execution.id}</button><p className="mt-1 text-sm text-slate-300">{execution.state} · attempt {execution.attempt}</p></li>)}</ul> : null}
|
||||
{detail?.kind === "loading" ? <Loading label="execution details" /> : null}
|
||||
{detail?.kind === "error" && selectedId ? <ErrorPanel message={detail.message} retry={() => { void loadDetail(selectedId); }} /> : null}
|
||||
{detail?.kind === "ready" ? <section aria-labelledby="execution-detail-heading" className="mt-6 rounded-lg border border-slate-700 bg-slate-900 p-4"><h3 id="execution-detail-heading" className="text-lg font-semibold">Execution detail</h3><dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-slate-400">State</dt><dd>{detail.items.state}</dd></div><div><dt className="text-slate-400">Attempt</dt><dd>{detail.items.attempt}</dd></div><div><dt className="text-slate-400">Reason</dt><dd>{detail.items.reason_code ?? "None"}</dd></div></dl></section> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user