import { useEffect, useState, type ReactNode } from "react"; import { Link, NavLink, useLocation } from "react-router-dom"; import { IconLayoutDashboard, IconServer2, IconWorld, IconNetwork, IconKey, IconPlugConnected, IconBrandDocker, IconAffiliate, IconPlayerPlay, IconBrandGit, IconServerCog, IconDatabase, IconUsers, IconDevices, IconHistory, IconStethoscope, IconSettings, IconMenu2, IconSun, IconMoon, IconWand, IconTool, IconWorldWww, IconListCheck, IconShieldLock, IconRefresh, IconActivityHeartbeat, IconShieldCheck, IconTicket, IconPlug, IconExternalLink, } from "@tabler/icons-react"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { formatRemaining } from "../utils/duration"; import { getTheme, setTheme, type Theme } from "../utils/theme"; import CommandPalette from "../components/CommandPalette"; type Role = "operator" | "admin"; interface NavLeaf { to: string; label: string; /** Shown for top-level links, and when a group is left with a single visible page and so is drawn as that page's own link. */ icon: ReactNode; minRole?: Role; } interface NavGroup { id: string; label: string; icon: ReactNode; items: NavLeaf[]; } type NavEntry = NavLeaf | NavGroup; const isGroup = (entry: NavEntry): entry is NavGroup => "items" in entry; const NAV: NavEntry[] = [ { to: "/", label: "Dashboard", icon: }, { id: "infrastructure", label: "Infrastructure", icon: , items: [ { to: "/servers", label: "Servers", icon: }, { to: "/proxmox", label: "Proxmox", icon: }, { to: "/synology", label: "Synology", icon: }, { to: "/pbs", label: "Proxmox Backup", icon: }, { to: "/docker", label: "Docker", icon: }, { to: "/tailscale", label: "Tailscale", icon: }, ], }, { id: "network", label: "Network", icon: , items: [ { to: "/dns", label: "DNS", icon: }, { to: "/domains", label: "Domains", icon: }, { to: "/ipam", label: "IP Addresses", icon: }, { to: "/ports", label: "Ports", icon: }, { to: "/consistency", label: "Consistency", icon: }, ], }, { id: "automation", label: "Automation", icon: , items: [ { to: "/semaphore", label: "Semaphore", icon: }, { to: "/gitea", label: "Gitea", icon: }, ], }, { to: "/secrets", label: "Secrets", icon: }, { id: "operations", label: "Operations", icon: , items: [ { to: "/maintenance", label: "Maintenance", icon: }, { to: "/uptime-kuma", label: "Uptime Kuma", icon: }, { to: "/osticket", label: "osTicket", icon: }, { to: "/admin-links", label: "Admin Links", icon: }, { to: "/generator", label: "Generator", icon: }, ], }, { id: "administration", label: "Administration", icon: , items: [ { to: "/integrations", label: "Integrations", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, { to: "/sessions", label: "Sessions", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, { to: "/diag-log", label: "Diagnostic Log", icon: , minRole: "admin" }, { to: "/settings", label: "Settings", icon: , minRole: "admin" }, ], }, ]; const roleRank: Record = { viewer: 0, operator: 1, admin: 2 }; /** What this role can see: pages it isn't allowed are dropped, an emptied group disappears, and a group down to one page becomes that page's own link. */ function visibleNav(role: CurrentUser["role"]): NavEntry[] { const allowed = (leaf: NavLeaf) => !leaf.minRole || roleRank[role] >= roleRank[leaf.minRole]; const out: NavEntry[] = []; for (const entry of NAV) { if (!isGroup(entry)) { if (allowed(entry)) out.push(entry); continue; } const items = entry.items.filter(allowed); if (items.length === 1) out.push(items[0]); else if (items.length > 1) out.push({ ...entry, items }); } return out; } const OPEN_GROUPS_KEY = "hlm-nav-open-groups"; function loadOpenGroups(): Set { try { const value = JSON.parse(localStorage.getItem(OPEN_GROUPS_KEY) ?? "[]"); return new Set(Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []); } catch { return new Set(); // storage blocked or corrupted: start with everything closed } } /** Is this the current page (or a page beneath it, like /servers/3 under /servers)? */ function isCurrent(pathname: string, to: string): boolean { return to === "/" ? pathname === "/" : pathname === to || pathname.startsWith(`${to}/`); } export default function AppShell({ user, children }: { user: CurrentUser; children: ReactNode }) { const [sidebarOpen, setSidebarOpen] = useState(false); const [theme, setThemeState] = useState(() => getTheme()); const [maintenance, setMaintenance] = useState([]); // Everyone sees what's currently silenced, so nobody is left wondering why an alert never came. useEffect(() => { let cancelled = false; const load = () => api.maintenance .list() .then((res) => { if (!cancelled) setMaintenance(res.windows); }) .catch(() => {}); load(); const poll = setInterval(load, 60_000); window.addEventListener("maintenance-changed", load); return () => { cancelled = true; clearInterval(poll); window.removeEventListener("maintenance-changed", load); }; }, []); const nav = visibleNav(user.role); const { pathname } = useLocation(); const [openGroups, setOpenGroups] = useState>(loadOpenGroups); // The group holding the current page is always open, so you can see where you are (others stay as you left them). useEffect(() => { const active = nav.find((entry): entry is NavGroup => isGroup(entry) && entry.items.some((item) => isCurrent(pathname, item.to))); if (active) setOpenGroups((prev) => (prev.has(active.id) ? prev : new Set(prev).add(active.id))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname, user.role]); useEffect(() => { try { localStorage.setItem(OPEN_GROUPS_KEY, JSON.stringify([...openGroups])); } catch { // not persisted; the menu still works for this visit } }, [openGroups]); function toggleGroup(id: string) { setOpenGroups((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggleTheme() { const next: Theme = theme === "dark" ? "light" : "dark"; setTheme(next); setThemeState(next); } return ( {maintenance.length > 0 && ( Maintenance — alerts silenced for{" "} {maintenance.map((w) => `${w.targetName} (${formatRemaining(w.endsAt)} left)`).join(", ")}.{" "} Manage )} {children} ); }