Files
manage/frontend/src/App.tsx
Developer 77c6b62ee2 feat(frontend): slice 3 — Backups cluster migration + nav/IA
Web UI rework.
- Migrate BackupAlertsTable, BackupJobsTable, BackupRunsTable,
  BackupsPage, BackupDashboardWidget off @mui (shadcn Table + Badge
  severity variants: success=chart-2, warning=chart-3, destructive)
- App.tsx IA: Backups now top-level nav (DatabaseBackup icon);
  Media surface primary at /media; /applications -> /media redirect
  in both route trees (mirrors /monitoring -> /observability)

Gate: build + lint + test green.
2026-06-17 12:53:36 +00:00

494 lines
13 KiB
TypeScript

import {
BrowserRouter,
Routes,
Route,
NavLink,
useLocation,
Outlet,
Navigate,
} from "react-router-dom";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Applications } from "./pages/Applications";
import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
import { usePersistentState } from "./hooks/usePersistentState";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
LayoutDashboard,
Activity,
DatabaseBackup,
Monitor,
Users,
Zap,
FolderOpen,
Settings as SettingsIcon,
Menu,
Sun,
Moon,
LogOut,
ChevronLeft,
ChevronRight,
} from "lucide-react";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
function useDarkMode() {
const [darkMode, setDarkMode] = usePersistentState<boolean>(
"dark-mode",
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
);
useEffect(() => {
const root = document.documentElement;
if (darkMode) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}, [darkMode]);
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
}
// Navigation items for sidebar
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/media", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
function Sidebar({
collapsed,
onToggle,
isMobile,
}: {
collapsed: boolean;
onToggle: () => void;
isMobile: boolean;
}) {
const location = useLocation();
if (isMobile) return null;
return (
<aside
className={`fixed left-0 top-0 z-40 h-screen border-r border-border bg-card transition-all duration-300 ${
collapsed ? "w-16" : "w-60"
}`}
>
<div className="flex h-full flex-col">
{/* Logo area */}
<div className="flex h-14 items-center justify-between border-b border-border px-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
M
</div>
{!collapsed && <span className="font-semibold">Manage</span>}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onToggle}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-2">
<TooltipProvider delayDuration={0}>
{navItems.map((item) => {
const isItemActive = location.pathname === item.path;
const Icon = item.icon;
return collapsed ? (
<Tooltip key={item.path}>
<TooltipTrigger asChild>
<NavLink
to={item.path}
className={() =>
`flex h-10 w-full items-center justify-center rounded-md transition-colors ${
isItemActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`
}
>
<Icon className="h-5 w-5" />
</NavLink>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
) : (
<NavLink
key={item.path}
to={item.path}
className={() =>
`flex h-10 w-full items-center gap-3 rounded-md px-3 transition-colors ${
isItemActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`
}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">{item.label}</span>
</NavLink>
);
})}
</TooltipProvider>
</nav>
</div>
</aside>
);
}
function MobileDrawer() {
const [open, setOpen] = useState(false);
const location = useLocation();
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-60 p-0">
<SheetHeader className="border-b border-border p-4">
<SheetTitle className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
M
</div>
<span>Manage</span>
</SheetTitle>
</SheetHeader>
<nav className="space-y-1 p-2">
{navItems.map((item) => {
const isItemActive = location.pathname === item.path;
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
onClick={() => setOpen(false)}
className={() =>
`flex h-10 w-full items-center gap-3 rounded-md px-3 transition-colors ${
isItemActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`
}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">{item.label}</span>
</NavLink>
);
})}
</nav>
</SheetContent>
</Sheet>
);
}
function TopBar({
darkMode,
authLabel,
onSignOut,
onToggleDarkMode,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
onToggleDarkMode: () => void;
}) {
const location = useLocation();
const { data: appVersion } = useQuery({
queryKey: ["app-version"],
queryFn: fetchAppVersion,
staleTime: 60 * 60 * 1000,
});
const backendLabel = appVersion?.backend_label || "…";
const pageTitle =
navItems.find((item) => item.path === location.pathname)?.label ||
"Dashboard";
return (
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/80 backdrop-blur-md px-4">
<div className="flex items-center gap-3">
<MobileDrawer />
<h1 className="text-lg font-semibold">{pageTitle}</h1>
</div>
<div className="flex items-center gap-2">
<span className="hidden sm:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium">
FE {FRONTEND_VERSION_LABEL}
</span>
<span className="hidden sm:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium">
BE {backendLabel}
</span>
{authLabel && (
<span className="hidden md:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium max-w-[180px] truncate">
{authLabel}
</span>
)}
<Button
variant="ghost"
size="icon"
onClick={onToggleDarkMode}
className="h-8 w-8"
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
{onSignOut && (
<Button
variant="ghost"
size="sm"
onClick={onSignOut}
className="gap-2"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</Button>
)}
</div>
</header>
);
}
function ShellLayout({
darkMode,
authLabel,
onSignOut,
onToggleDarkMode,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
onToggleDarkMode: () => void;
}) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(
() => window.matchMedia("(max-width: 768px)").matches,
);
useEffect(() => {
const mql = window.matchMedia("(max-width: 768px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
return (
<div className="min-h-screen bg-background">
<Sidebar
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
isMobile={isMobile}
/>
<div
className={`transition-all duration-300 ${
isMobile ? "" : sidebarCollapsed ? "ml-16" : "ml-60"
}`}
>
<TopBar
darkMode={darkMode}
authLabel={authLabel}
onSignOut={onSignOut}
onToggleDarkMode={onToggleDarkMode}
/>
<main className="p-4 md:p-6">
<Outlet />
</main>
</div>
</div>
);
}
function LoadingScreen({ label }: { label: string }) {
return (
<div className="min-h-screen grid place-items-center p-4">
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-col items-center gap-3 text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<h2 className="text-lg font-semibold">{label}</h2>
</div>
</div>
</div>
);
}
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
return (
<div className="min-h-screen grid place-items-center p-4">
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-col items-center gap-3 text-center">
<h2 className="text-xl font-semibold">Sign in required</h2>
<p className="text-sm text-muted-foreground">
Use your Authentik account to access Manage.
</p>
<Button onClick={onSignIn}>Sign in with OIDC</Button>
</div>
</div>
</div>
);
}
function AuthenticatedApp() {
const auth = useAuth();
const [darkMode, toggleDarkMode] = useDarkMode();
useEffect(() => {
setAccessToken(auth.user?.access_token ?? null);
}, [auth.user?.access_token]);
const authLabel = useMemo(() => {
const profile = auth.user?.profile as Record<string, unknown> | undefined;
return String(
profile?.name ??
profile?.preferred_username ??
profile?.email ??
auth.user?.profile?.sub ??
"Authenticated",
);
}, [auth.user]);
if (auth.isLoading || auth.activeNavigator) {
return <LoadingScreen label="Checking sign-in…" />;
}
if (auth.error) {
return (
<LoadingScreen
label={`Authentication error: ${auth.error.message || "Unable to sign in"}`}
/>
);
}
if (!auth.isAuthenticated) {
return <SignInScreen onSignIn={() => void auth.signinRedirect()} />;
}
return (
<ShellLayout
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
onToggleDarkMode={toggleDarkMode}
/>
);
}
function AppInner() {
const [darkMode, toggleDarkMode] = useDarkMode();
return (
<QueryClientProvider client={queryClient}>
{isOidcConfigured() ? (
<AuthProvider {...getOidcConfig()}>
<BrowserRouter>
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
) : (
<BrowserRouter>
<Routes>
<Route
element={
<ShellLayout
darkMode={darkMode}
onToggleDarkMode={toggleDarkMode}
/>
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
)}
</QueryClientProvider>
);
}
export default function App() {
return <AppInner />;
}