Files
Homelab-manager/web/src/layout/AppShell.tsx
T
bobbanandClaude Sonnet 5 1ff1c6550c Share the excluded-ranges filter with IP Addresses; add Admin Links page
IP Addresses (IPAM) now reads the same excluded-ranges setting the
Consistency page manages, so "not interesting" addresses - a Docker
bridge network repeating on every host, say - can be hidden there too.
Adds a "Hide excluded addresses" toggle (on by default, with a live
count), an inline ranges editor matching Consistency's, an "excluded"
badge on rows shown anyway, and a per-row "Exclude..." shortcut that
suggests a /24 (or /64 for IPv6) around that address. Editing ranges
from either page updates both, since it's one shared setting.

Also adds Operations > Admin Links: a single page summarizing every
admin bookmark added across all servers (Dockge, Webmin, Cockpit, etc,
previously only visible per-server on each server's own detail page),
sortable and searchable, with the same add/edit/delete capability -
adding one here just asks which server it belongs to.

Verified both with real HTTP-level tests: a genuine Express app, a
scratch SQLite DB, and forged sessions, covering the exclusion
matching, the shared-setting round trip, the links aggregation and
join, and role enforcement - the real dev DB was confirmed untouched
throughout. Both packages build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 00:07:27 +02:00

327 lines
12 KiB
TypeScript

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: <IconLayoutDashboard size={20} /> },
{
id: "infrastructure",
label: "Infrastructure",
icon: <IconServer2 size={20} />,
items: [
{ to: "/servers", label: "Servers", icon: <IconServer2 size={20} /> },
{ to: "/proxmox", label: "Proxmox", icon: <IconServerCog size={20} /> },
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
{ to: "/pbs", label: "Proxmox Backup", icon: <IconShieldCheck size={20} /> },
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
],
},
{
id: "network",
label: "Network",
icon: <IconWorld size={20} />,
items: [
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
{ to: "/ports", label: "Ports", icon: <IconPlug size={20} /> },
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
],
},
{
id: "automation",
label: "Automation",
icon: <IconRefresh size={20} />,
items: [
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
],
},
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
{
id: "operations",
label: "Operations",
icon: <IconTool size={20} />,
items: [
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
{ to: "/admin-links", label: "Admin Links", icon: <IconExternalLink size={20} /> },
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
],
},
{
id: "administration",
label: "Administration",
icon: <IconSettings size={20} />,
items: [
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
{ to: "/diag-log", label: "Diagnostic Log", icon: <IconStethoscope size={20} />, minRole: "admin" },
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
],
},
];
const roleRank: Record<CurrentUser["role"], number> = { 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<string> {
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<Theme>(() => getTheme());
const [maintenance, setMaintenance] = useState<MaintenanceWindow[]>([]);
// 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<Set<string>>(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 (
<div className="page">
<aside className="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div className="container-fluid">
<button
type="button"
className="navbar-toggler"
onClick={() => setSidebarOpen((open) => !open)}
aria-label="Toggle navigation"
>
<IconMenu2 size={20} />
</button>
<h1 className="navbar-brand navbar-brand-autodark">
<NavLink to="/">Homelab Manager</NavLink>
</h1>
<div className={`navbar-collapse collapse ${sidebarOpen ? "show" : ""}`}>
<div className="px-3 pt-3 pt-lg-0">
<CommandPalette />
</div>
<ul className="navbar-nav pt-lg-3">
{nav.map((entry) => {
if (!isGroup(entry)) {
return (
<li className="nav-item" key={entry.to}>
<NavLink to={entry.to} end={entry.to === "/"} className="nav-link" onClick={() => setSidebarOpen(false)}>
<span className="nav-link-icon d-md-none d-lg-inline-block">{entry.icon}</span>
<span className="nav-link-title">{entry.label}</span>
</NavLink>
</li>
);
}
const open = openGroups.has(entry.id);
const holdsCurrent = entry.items.some((item) => isCurrent(pathname, item.to));
return (
<li className="nav-item dropdown" key={entry.id}>
<button
type="button"
className={`nav-link dropdown-toggle w-100 border-0 bg-transparent text-start ${open ? "show" : ""} ${holdsCurrent ? "active" : ""}`}
aria-expanded={open}
onClick={() => toggleGroup(entry.id)}
>
<span className="nav-link-icon d-md-none d-lg-inline-block">{entry.icon}</span>
<span className="nav-link-title">{entry.label}</span>
</button>
<div className={`dropdown-menu ${open ? "show" : ""}`}>
{entry.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `dropdown-item ${isActive ? "active" : ""}`}
onClick={() => setSidebarOpen(false)}
>
{item.label}
</NavLink>
))}
</div>
</li>
);
})}
</ul>
<div className="navbar-footer px-3">
<button
type="button"
className="btn btn-outline-secondary btn-sm w-100 mb-2 d-flex align-items-center justify-content-center gap-2"
onClick={toggleTheme}
>
{theme === "dark" ? <IconSun size={16} /> : <IconMoon size={16} />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
<NavLink
to="/privacy"
className="btn btn-outline-secondary btn-sm w-100 mb-2 d-flex align-items-center justify-content-center gap-2"
onClick={() => setSidebarOpen(false)}
>
<IconShieldLock size={16} />
Privacy
</NavLink>
<div className="text-secondary small mb-2">
Signed in as {user.name ?? user.email ?? user.sub} · {user.role}
</div>
<a className="btn btn-outline-secondary btn-sm w-100" href="/auth/logout">
Sign out
</a>
</div>
</div>
</div>
</aside>
<div className="page-wrapper">
<div className="page-body">
<div className="container-xl">
{maintenance.length > 0 && (
<div className="alert alert-warning d-flex align-items-center gap-2">
<IconTool size={18} />
<div>
Maintenance — alerts silenced for{" "}
{maintenance.map((w) => `${w.targetName} (${formatRemaining(w.endsAt)} left)`).join(", ")}.{" "}
<Link to="/maintenance">Manage</Link>
</div>
</div>
)}
{children}
</div>
</div>
</div>
</div>
);
}