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>
327 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|