{/* Mobile Actions — variant D approved in demo:
• Top-right: Refresh + Theme + Avatar (all with border)
• Bottom row (under Node line): badges left-aligned with
the Node text column, Uptime right-aligned in the same
horizontal line. No extra row for Uptime so the
header doesn't grow vertically. */}
{/* Mobile bottom row — badges (left, aligned with the title
column via pl-[3.25rem] = w-16 logo + space-x-2 gap-ish)
and Uptime (right). The pl matches the mobile logo width
+ the parent flex gap so the badges sit visually under
"Node: amd", not flush against the screen edge. */}
{/* Sprint 13D nav redesign — 6 top-level slots in usage order:
Overview · VMs & LXCs · Node ⌄ · Backup · Terminal · Admin ⌄
Node groups Storage / Network / Hardware (3 sub-items).
Admin groups System Logs / Security / Settings / About
(will split when RBAC arrives in 1.5.0).
Backup is direct now (only Host Backup); becomes a dropdown
when VM/LXC centralised backup ships. */}
{(() => {
const triggerActiveClass =
"data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
// Each dropdown lists its children in the order they
// render. When one of them is the active tab, the dropdown
// trigger swaps its label + icon to that child — same
// pattern macOS Settings uses inside a category: the
// crumb shows where you are, the chevron tells you the
// siblings are one click away.
const NODE_ITEMS = [
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
]
const ADMIN_ITEMS = [
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
].filter(item => !isPbs || item.value !== "audit")
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
const isNodeActive = activeNodeItem !== undefined
const isAdminActive = activeAdminItem !== undefined
// The trigger label + icon shown on the bar. When a child
// is active we surface IT; otherwise the group default.
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node")
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
// Dropdown trigger styling: parity with TabsTrigger so the
// parent visibly carries the "I'm the selected section"
// signal when any of its children is the active tab —
// same blue background + white text + rounded as a direct
// tab. Without this the user lands on Storage and the
// entire top bar looks idle.
const dropdownBtnClass = (active: boolean) =>
`inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
active
? "bg-blue-500 text-white rounded-md"
: "text-muted-foreground hover:text-foreground rounded-sm"
}`
// Data-driven TabsList: iterate over the user's saved
// top-level order. Each slot is either a direct tab or a
// dropdown group (Node/Admin). The internal items of a
// dropdown are never reordered by the user — a grouped
// slot moves as a unit.
const renderDirect = (
value: string,
Icon: React.ComponentType<{ className?: string }>,
label: string,
) => (
{label}
)
const renderDropdown = (
key: string,
items: { value: string; label: string; Icon: React.ComponentType<{ className?: string }> }[],
active: boolean,
TriggerIcon: React.ComponentType<{ className?: string }>,
triggerLabel: string,
) => (
{triggerLabel}
{items.map(({ value, label, Icon }) => (
setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
{label}
))}
)
const renderTop = (id: TopTab) => {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return renderDirect("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
case "terminal": return renderDirect("terminal", Terminal, t("navigation.terminal"))
case "node": return renderDropdown("node", NODE_ITEMS, isNodeActive, NodeTriggerIcon, NodeTriggerLabel)
case "admin": return renderDropdown("admin", ADMIN_ITEMS, isAdminActive, AdminTriggerIcon, AdminTriggerLabel)
}
}
return (
{tabOrder.map(renderTop)}
)
})()}
{(() => {
// Sheet items mirror the desktop layout: 6 sections,
// with two of them (Node, Admin) collapsing into a
// header + nested items. Direct tabs (Overview, VMs,
// Backup, Terminal) sit at the top level.
const select = (v: string) => {
setActiveTab(v)
setMobileMenuOpen(false)
}
const itemClass = (active: boolean) =>
`w-full justify-start gap-3 ${
active
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`
// Mobile sheet honours the same user-defined
// top-level order as the desktop TabsList. Grouped
// slots (Node/Admin) expand their child items
// inline right after their position — the group
// still moves as a unit, but children stay grouped.
const btn = (
value: string,
Icon: React.ComponentType<{ className?: string }>,
label: string,
) => (
)
return (
{tabOrder.map((id): React.ReactNode => {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return btn("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
case "terminal": return btn("terminal", Terminal, t("navigation.terminal"))
case "node": return (
{btn("storage", HardDrive, t("navigation.storage"))}
{btn("network", NetworkIcon, t("navigation.network"))}
{btn("hardware", Cpu, t("navigation.hardware"))}
)
case "admin": return (
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
)
}
})}
)
})()}
{/* No `space-y-*` here: only one TabsContent is visible at a
time, but Overview stays force-mounted (hidden) as the
first child, so every OTHER active tab used to inherit an
extra top margin from the space-y utility — pushing the
page content further from the nav than on Overview.
Vertical spacing INSIDE each tab lives on its own
TabsContent's `space-y-*`. */}
{/* forceMount so SystemOverview mounts at dashboard load and
never gets torn down when the user visits another tab.
Without this, every return to Overview re-fires ~7 fetches
(system, vms, storage, proxmox-storage, network, node
metrics, network chart) and the user waited for the
cascade to complete each time. With forceMount, the
5 s / 59 s refresh intervals keep the data fresh in the
background — reopening the tab is instant. */}
{isPbs && (
)}
{isPbs && (
)}
{!isPbs && <>
{/* forceMount so the modal-data prefetcher (inside VirtualMachines)
starts warming caches from the moment the dashboard loads,
not the first time the user clicks the VMs tab. Kept
visually hidden with data-attribute selector when the tab
is inactive — mount cost is ~zero (no polling loop that
other components run). */}
>}
{!isPbs && <>
>}
{/* Profile tab — not surfaced in the top tabs nav. The only
entry point is the avatar dropdown in the header (View
profile). v1.2.2 Fase 2. */}
setActiveTab("security")}
/>