From a10e609c02f8202cdde84129105f30eb518789ef Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Mon, 16 Mar 2026 17:38:24 -0500 Subject: [PATCH] feat(ui): add ContextStrip with org switcher, status, and user controls Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/layout/ContextStrip.tsx | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 frontend/src/components/layout/ContextStrip.tsx diff --git a/frontend/src/components/layout/ContextStrip.tsx b/frontend/src/components/layout/ContextStrip.tsx new file mode 100644 index 0000000..e92808d --- /dev/null +++ b/frontend/src/components/layout/ContextStrip.tsx @@ -0,0 +1,248 @@ +import { useEffect } from 'react' +import { useNavigate, Link } from '@tanstack/react-router' +import { ChevronDown, Sun, Moon, LogOut, Settings, Menu } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { useCommandPalette } from '@/components/command-palette/useCommandPalette' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useAuth, isSuperAdmin } from '@/lib/auth' +import { useUIStore } from '@/lib/store' +import { tenantsApi, metricsApi } from '@/lib/api' +import { useEventStreamContext } from '@/contexts/EventStreamContext' +import type { ConnectionState } from '@/hooks/useEventStream' + +const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000' + +const CONNECTION_COLORS: Record = { + connected: 'bg-success', + connecting: 'bg-warning animate-pulse', + reconnecting: 'bg-warning animate-pulse', + disconnected: 'bg-error', +} + +const CONNECTION_LABELS: Record = { + connected: 'Connected', + connecting: 'Connecting', + reconnecting: 'Reconnecting', + disconnected: 'Disconnected', +} + +// Generate a deterministic color from a string +function tenantColor(name: string): string { + const colors = [ + 'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', + 'bg-rose-500', 'bg-cyan-500', 'bg-pink-500', 'bg-teal-500', + ] + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + return colors[Math.abs(hash) % colors.length] +} + +export function ContextStrip() { + const { user, logout } = useAuth() + const { selectedTenantId, setSelectedTenantId, theme, setTheme, setMobileSidebarOpen } = useUIStore() + const { connectionState } = useEventStreamContext() + const navigate = useNavigate() + const superAdmin = isSuperAdmin(user) + + // Tenant list (super_admin only) + 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]) + + // Fleet summary for status indicators + const tenantId = superAdmin ? selectedTenantId : user?.tenant_id + const { data: fleet } = useQuery({ + queryKey: ['fleet-summary', superAdmin ? 'all' : tenantId], + queryFn: () => + superAdmin && !selectedTenantId + ? metricsApi.fleetSummaryAll() + : tenantId + ? metricsApi.fleetSummary(tenantId) + : Promise.resolve([]), + enabled: !!tenantId || superAdmin, + refetchInterval: 30_000, + }) + + const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0 + const degradedCount = fleet?.filter((d) => d.status === 'degraded').length ?? 0 + + const handleLogout = async () => { + await logout() + void navigate({ to: '/login' }) + } + + // User initials for avatar + const initials = user?.name + ? user.name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase() + : user?.email?.slice(0, 2).toUpperCase() ?? '?' + + // Tenant display name for non-super_admin + const tenantName = superAdmin + ? (selectedTenant?.name ?? 'All Orgs') + : user?.name ?? 'Tenant' + + return ( +
+ {/* Mobile hamburger */} + + + {/* Left: Org switcher */} +
+ {superAdmin && tenants && tenants.length > 0 ? ( + + +
+ {tenantName[0]?.toUpperCase()} +
+ {tenantName} + +
+ + Organization + + setSelectedTenantId(null)} className="text-xs"> + All Orgs + + {tenants.map((tenant) => ( + setSelectedTenantId(tenant.id)} + className="text-xs" + > +
+ {tenant.name[0]?.toUpperCase()} +
+ {tenant.name} +
+ ))} +
+
+ ) : ( + + {superAdmin ? 'No orgs' : (user?.name ?? 'Tenant')} + + )} +
+ + {/* Center: Status indicators */} +
+ {fleet ? ( + <> + {offlineCount > 0 && ( + + )} + {degradedCount > 0 && ( + + )} + {offlineCount === 0 && degradedCount === 0 && fleet.length > 0 && ( + All systems nominal + )} + {fleet.length === 0 && ( + No devices + )} + + ) : ( + Status loading... + )} +
+ + {/* Right: Actions */} +
+ {/* Command palette shortcut */} + + + {/* Connection status dot */} +
+ + {/* Theme toggle */} + + + {/* User avatar dropdown */} + + +
+ {initials} +
+
+ + +
{user?.email}
+
+ + + + + Settings + + + + void handleLogout()} className="text-error text-xs"> + + Sign out + +
+
+
+
+ ) +}