ac703eecd2
Set refetchIntervalInBackground: false as a QueryClient default so all interval-based polls (widgets ~30s, message-queue 5s, media build progress 1s) pause when document.visibilityState === 'hidden'. Battery-friendly on mobile -- the dashboard is the page most likely to be left open on a phone. The media build-progress poll previously forced refetchIntervalInBackground: true; that override is removed so it inherits the default. The build keeps running server-side; the poll resumes and catches up when the user returns to the tab. 122 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/verify-report.md residual risk #3 (D8 battery follow-up).
509 lines
14 KiB
TypeScript
509 lines
14 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 { ServicePage } from "./pages/ServicePage";
|
|
import { ServicesPage } from "./pages/ServicesPage";
|
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
|
import { fetchAppVersion } from "./api/client";
|
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
|
import { usePersistentState } from "./hooks/usePersistentState";
|
|
import { useIsMobile } from "./hooks/useIsMobile";
|
|
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,
|
|
Boxes,
|
|
} from "lucide-react";
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: 1,
|
|
refetchOnWindowFocus: false,
|
|
// Pause interval-based refetches (widgets ~30s, queue status 5s,
|
|
// media build progress 1s) when the tab is hidden. Saves battery on
|
|
// mobile (D8 follow-up). Build progress polls resume on return.
|
|
refetchIntervalInBackground: 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: "/services", label: "Services", icon: Boxes },
|
|
{ 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="mobile-touch-target 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="mobile-touch-target 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="mobile-touch-target 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 = useIsMobile();
|
|
|
|
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 path="/services" element={<ServicesPage />} />
|
|
<Route
|
|
path="/services/:serviceType/:serviceId"
|
|
element={<ServicePage />}
|
|
/>
|
|
</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 path="/services" element={<ServicesPage />} />
|
|
<Route
|
|
path="/services/:serviceType/:serviceId"
|
|
element={<ServicePage />}
|
|
/>
|
|
</Route>
|
|
</Routes>
|
|
</BrowserRouter>
|
|
)}
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return <AppInner />;
|
|
}
|