From 0448982942a910c79af74719b8f3ac6290dc4602 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 12:20:19 -0500 Subject: [PATCH] feat(ui): rewrite sidebar with task-based Operate/Act navigation Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/layout/Sidebar.tsx | 647 ++++++++++++--------- 1 file changed, 377 insertions(+), 270 deletions(-) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4d3c11a..f3c4026 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,59 +1,191 @@ import { useEffect, useRef } from 'react' import { APP_VERSION } from '@/lib/version' -import { Link, useRouterState } from '@tanstack/react-router' +import { Link, useRouterState, useNavigate } from '@tanstack/react-router' import { Monitor, Building2, Users, Settings, - ChevronLeft, - ChevronRight, - Download, + LayoutDashboard, + Wifi, + MapPin, + Bell, + Map, Terminal, FileCode, - LayoutDashboard, - ClipboardList, - Wifi, - BarChart3, - MapPin, - ShieldCheck, - KeyRound, - Info, - Bell, - Network, - Map, - Layers, + Download, Wrench, + ClipboardList, + BellRing, Calendar, FileBarChart, - Eye, - BellRing, + Sun, + Moon, + LogOut, + ChevronDown, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth' import { useUIStore } from '@/lib/store' +import { useEventStreamContext } from '@/contexts/EventStreamContext' +import { alertEventsApi, tenantsApi } from '@/lib/api' +import { useQuery } from '@tanstack/react-query' import { RugLogo } from '@/components/brand/RugLogo' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import type { ConnectionState } from '@/hooks/useEventStream' + +// ─── Types ────────────────────────────────────────────────────────────────── interface NavItem { label: string href: string icon: React.FC<{ className?: string }> exact?: boolean + badge?: number } -interface NavSection { - label: string - items: NavItem[] - visible: boolean +// ─── Constants ────────────────────────────────────────────────────────────── + +const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000' + +const CONNECTION_LABELS: Record = { + connected: 'Connected', + connecting: 'Connecting', + reconnecting: 'Reconnecting', + disconnected: 'Disconnected', } +// ─── Styles ───────────────────────────────────────────────────────────────── + +const navItemBase = + 'flex items-center gap-2 text-[13px] py-[5px] px-2 pl-[10px] border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear' + +const navItemInactive = + 'text-text-secondary hover:border-accent' + +const navItemActive = + 'text-text-primary font-medium border-accent bg-accent-soft rounded-r-sm' + +const lowFreqBase = + 'flex items-center gap-2 text-[13px] text-text-muted py-[3px] px-2 pl-[10px] border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent' + +const iconClass = 'h-4 w-4 text-text-muted flex-shrink-0' + +// ─── Component ────────────────────────────────────────────────────────────── + export function Sidebar() { - const { user } = useAuth() - const { sidebarCollapsed, toggleSidebar, mobileSidebarOpen, setMobileSidebarOpen } = useUIStore() + const { user, logout } = useAuth() + const { + sidebarCollapsed, + toggleSidebar, + mobileSidebarOpen, + setMobileSidebarOpen, + selectedTenantId, + setSelectedTenantId, + theme, + setTheme, + } = useUIStore() + const { connectionState } = useEventStreamContext() const routerState = useRouterState() const currentPath = routerState.location.pathname + const navigate = useNavigate() + const navRef = useRef(null) + + const superAdmin = isSuperAdmin(user) + const tenantAdmin = isTenantAdmin(user) + const tenantId = superAdmin ? selectedTenantId : user?.tenant_id + + // ─── Queries ──────────────────────────────────────────────────────────── + + const { data: tenants } = useQuery({ + queryKey: ['tenants'], + queryFn: tenantsApi.list, + enabled: superAdmin, + select: (data) => data.filter((t) => t.id !== SYSTEM_TENANT_ID), + }) + + const selectedTenant = tenants?.find((t) => t.id === selectedTenantId) + + // Auto-select when there's exactly one tenant and nothing selected + useEffect(() => { + if (superAdmin && tenants && tenants.length === 1 && !selectedTenantId) { + setSelectedTenantId(tenants[0].id) + } + }, [tenants, selectedTenantId, superAdmin, setSelectedTenantId]) + + const { data: alertCount } = useQuery({ + queryKey: ['alert-active-count', tenantId], + queryFn: () => alertEventsApi.activeCount(tenantId!), + enabled: !!tenantId, + refetchInterval: 30_000, + }) + + // ─── Tenant display name ─────────────────────────────────────────────── + + const tenantName = superAdmin + ? (selectedTenant?.name ?? 'All Orgs') + : (user?.name ?? user?.email ?? 'Tenant') + + // ─── Nav items ──────────────────────────────────────────────────────── + + const operateItems: NavItem[] = [ + { label: 'Overview', href: '/', icon: LayoutDashboard, exact: true }, + ...(!superAdmin && user?.tenant_id + ? [ + { label: 'Devices', href: `/tenants/${user.tenant_id}/devices`, icon: Monitor }, + { label: 'Sites', href: `/tenants/${user.tenant_id}/sites`, icon: MapPin }, + ] + : []), + { + label: 'Alerts', + href: '/alerts', + icon: Bell, + badge: alertCount && alertCount > 0 ? alertCount : undefined, + }, + ...(!superAdmin && user?.tenant_id + ? [{ label: 'Wireless', href: `/tenants/${user.tenant_id}/wireless-links`, icon: Wifi }] + : [{ label: 'Wireless', href: '/wireless', icon: Wifi }] + ), + { label: 'Map', href: '/map', icon: Map }, + ] + + const actItems: NavItem[] = [ + { label: 'Config', href: '/config-editor', icon: Terminal }, + { label: 'Templates', href: '/templates', icon: FileCode }, + { label: 'Firmware', href: '/firmware', icon: Download }, + { label: 'Commands', href: '/bulk-commands', icon: Wrench }, + ] + + const lowFreqItems: NavItem[] = [ + ...(superAdmin || tenantAdmin + ? [{ label: 'Organizations', href: '/tenants', icon: Building2 }] + : []), + ...(tenantAdmin && user?.tenant_id + ? [{ label: 'Users', href: `/tenants/${user.tenant_id}/users`, icon: Users }] + : []), + { label: 'Alert Rules', href: '/alert-rules', icon: BellRing }, + { label: 'Maintenance', href: '/maintenance', icon: Calendar }, + { label: 'Settings', href: '/settings', icon: Settings }, + { label: 'Audit Log', href: '/audit', icon: ClipboardList }, + { label: 'Reports', href: '/reports', icon: FileBarChart }, + ] + + // ─── Active state ───────────────────────────────────────────────────── + + const isActive = (item: NavItem) => { + if (item.exact) return currentPath === item.href + if (item.href === '/settings') + return currentPath === '/settings' || currentPath.startsWith('/settings/') + return currentPath.startsWith(item.href) && item.href.length > 1 + } + + // ─── Focus trap for mobile ─────────────────────────────────────────── - // Mobile sidebar focus trap useEffect(() => { if (!mobileSidebarOpen) return @@ -86,9 +218,8 @@ export function Sidebar() { return () => document.removeEventListener('keydown', handleKeyDown) }, [mobileSidebarOpen, setMobileSidebarOpen]) - const navRef = useRef(null) + // ─── Keyboard shortcut: [ to toggle ─────────────────────────────────── - // Keyboard toggle: [ key collapses/expands sidebar useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( @@ -105,262 +236,236 @@ export function Sidebar() { return () => document.removeEventListener('keydown', handleKeyDown) }, [toggleSidebar]) - const sections: NavSection[] = [ - { - label: 'Fleet', - visible: true, - items: [ - { - label: 'Overview', - href: '/', - icon: LayoutDashboard, - exact: true, - }, - // Only show Devices for non-super_admin with a tenant_id - ...(!isSuperAdmin(user) && user?.tenant_id - ? [ - { - label: 'Devices', - href: `/tenants/${user.tenant_id}/devices`, - icon: Monitor, - }, - ] - : []), - ...(!isSuperAdmin(user) && user?.tenant_id - ? [ - { - label: 'Sites', - href: `/tenants/${user.tenant_id}/sites`, - icon: MapPin, - }, - ] - : []), - ...(!isSuperAdmin(user) && user?.tenant_id - ? [{ - label: 'Wireless Links', - href: `/tenants/${user.tenant_id}/wireless-links`, - icon: Wifi, - }] - : [{ - label: 'Wireless Links', - href: '/wireless', - icon: Wifi, - }] - ), - { - label: 'Traffic', - href: '/traffic', - icon: BarChart3, - }, - { - label: 'Alerts', - href: '/alerts', - icon: Bell, - }, - { - label: 'Topology', - href: '/topology', - icon: Network, - }, - { - label: 'Map', - href: '/map', - icon: Map, - }, - ], - }, - { - label: 'Config', - visible: true, - items: [ - { - label: 'Editor', - href: '/config-editor', - icon: Terminal, - }, - { - label: 'Templates', - href: '/templates', - icon: FileCode, - }, - { - label: 'Firmware', - href: '/firmware', - icon: Download, - }, - { - label: 'Certificates', - href: '/certificates', - icon: ShieldCheck, - }, - { - label: 'VPN', - href: '/vpn', - icon: KeyRound, - }, - { - label: 'Batch Config', - href: '/batch-config', - icon: Layers, - }, - { - label: 'Bulk Commands', - href: '/bulk-commands', - icon: Wrench, - }, - ], - }, - { - label: 'Admin', - visible: isSuperAdmin(user) || isTenantAdmin(user), - items: [ - ...(isTenantAdmin(user) && user?.tenant_id - ? [ - { - label: 'Users', - href: `/tenants/${user.tenant_id}/users`, - icon: Users, - }, - ] - : []), - ...(isSuperAdmin(user) || isTenantAdmin(user) - ? [ - { - label: 'Organizations', - href: '/tenants', - icon: Building2, - }, - ] - : []), - { - label: 'Audit Log', - href: '/audit', - icon: ClipboardList, - }, - { - label: 'Settings', - href: '/settings', - icon: Settings, - }, - { - label: 'Alert Rules', - href: '/alert-rules', - icon: BellRing, - }, - { - label: 'Maintenance', - href: '/maintenance', - icon: Calendar, - }, - { - label: 'Reports', - href: '/reports', - icon: FileBarChart, - }, - { - label: 'Transparency', - href: '/transparency', - icon: Eye, - }, - { - label: 'About', - href: '/about', - icon: Info, - }, - ], - }, - ] + // ─── Logout handler ─────────────────────────────────────────────────── - const visibleSections = sections.filter((s) => s.visible) - - const isActive = (item: NavItem) => { - if (item.exact) return currentPath === item.href - // Settings should only match exact to avoid catching everything - if (item.href === '/settings') return currentPath === '/settings' || currentPath.startsWith('/settings/') - return currentPath.startsWith(item.href) && item.href.length > 1 + const handleLogout = async () => { + await logout() + void navigate({ to: '/login' }) } - const sidebarContent = (showCollapsed: boolean) => ( + // ─── Render helpers ─────────────────────────────────────────────────── + + const renderNavItem = (item: NavItem, collapsed: boolean) => { + const Icon = item.icon + const active = isActive(item) + return ( + setMobileSidebarOpen(false)} + data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`} + className={cn( + collapsed + ? 'flex items-center justify-center py-[5px] px-2 border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent' + : navItemBase, + active + ? navItemActive + : collapsed + ? 'text-text-secondary' + : navItemInactive, + )} + title={collapsed ? item.label : undefined} + aria-label={collapsed ? item.label : undefined} + aria-current={active ? 'page' : undefined} + > +