Files
backup-tool/frontend/src/app/OperatorViews.tsx
T

277 lines
6.4 KiB
TypeScript

import {
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 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,
},
];
class RouteErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: 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;
}
}
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 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 PageRoute({
component: Page,
...props
}: RouteDefinition & OperatorPageProps) {
return (
<RouteErrorBoundary>
<Suspense fallback={<RouteLoading />}>
<Page {...props} />
</Suspense>
</RouteErrorBoundary>
);
}
function RouteEffects() {
const { pathname } = useLocation();
const hasMounted = useRef(false);
useEffect(() => {
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 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>
);
}