import { useRef, useState, useCallback } from 'react' import { Link, useNavigate } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor, MapPin } from 'lucide-react' import { devicesApi, sitesApi, type DeviceResponse } from '@/lib/api' import { Badge } from '@/components/ui/badge' import { useShortcut } from '@/hooks/useShortcut' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Button } from '@/components/ui/button' import { formatUptime, formatDateTime } from '@/lib/utils' import { cn } from '@/lib/utils' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { DeviceLink } from '@/components/ui/device-link' import { TableSkeleton } from '@/components/ui/page-skeleton' import { EmptyState } from '@/components/ui/empty-state' interface FleetTableProps { tenantId: string search?: string status?: string sortBy?: string sortDir?: 'asc' | 'desc' page?: number pageSize?: number } type SortDir = 'asc' | 'desc' function StatusDot({ status }: { status: string }) { const styles: Record = { online: 'bg-online shadow-[0_0_6px_hsl(var(--online)/0.3)]', offline: 'bg-offline shadow-[0_0_6px_hsl(var(--offline)/0.3)]', unknown: 'bg-unknown', } return ( ) } interface SortHeaderProps { column: string label: string currentSort: string currentDir: SortDir onSort: (col: string) => void className?: string } function SortHeader({ column, label, currentSort, currentDir, onSort, className }: SortHeaderProps) { const isActive = currentSort === column const ariaSortValue: 'ascending' | 'descending' | 'none' = isActive ? (currentDir === 'asc' ? 'ascending' : 'descending') : 'none' return ( ) } function DeviceCard({ device, tenantId }: { device: DeviceResponse; tenantId: string }) { return (
{device.hostname}
{formatUptime(device.uptime_seconds)}
{device.ip_address} {device.model && {device.model}} {device.routeros_version && v{device.routeros_version}}
{device.tags.length > 0 && (
{device.tags.map((tag) => ( {tag.name} ))}
)}
) } const VIRTUAL_SCROLL_THRESHOLD = 100 const VIRTUAL_ROW_HEIGHT = 48 const VIRTUAL_OVERSCAN = 10 export function FleetTable({ tenantId, search, status, sortBy = 'hostname', sortDir = 'asc', page = 1, pageSize = 25, }: FleetTableProps) { const navigate = useNavigate() const queryClient = useQueryClient() const scrollContainerRef = useRef(null) const [selectedIds, setSelectedIds] = useState>(new Set()) const [bulkAssignOpen, setBulkAssignOpen] = useState(false) const [bulkSiteId, setBulkSiteId] = useState('') const { data: sitesData } = useQuery({ queryKey: ['sites', tenantId], queryFn: () => sitesApi.list(tenantId), }) const bulkAssignMutation = useMutation({ mutationFn: ({ siteId, deviceIds }: { siteId: string; deviceIds: string[] }) => sitesApi.bulkAssign(tenantId, siteId, deviceIds), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['devices'] }) void queryClient.invalidateQueries({ queryKey: ['sites'] }) setSelectedIds(new Set()) setBulkAssignOpen(false) setBulkSiteId('') }, }) const { data, isLoading, isFetching } = useQuery({ queryKey: ['devices', tenantId, { search, status, sortBy, sortDir, page, pageSize }], queryFn: () => devicesApi.list(tenantId, { search, status, sort_by: sortBy, sort_dir: sortDir, page, page_size: pageSize, }), placeholderData: (prev) => prev, }) const updateSearch = (updates: Record) => { void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId }, search: (prev) => ({ ...prev, ...updates }), }) } const handleSort = (col: string) => { const newDir: SortDir = col === sortBy ? (sortDir === 'asc' ? 'desc' : 'asc') : 'asc' updateSearch({ sort_by: col, sort_dir: newDir, page: 1 }) } const handleDeviceClick = (device: DeviceResponse) => { void navigate({ to: '/tenants/$tenantId/devices/$deviceId', params: { tenantId, deviceId: device.id }, }) } const totalPages = data ? Math.ceil(data.total / data.page_size) : 0 const startItem = data ? (data.page - 1) * data.page_size + 1 : 0 const endItem = data ? Math.min(data.page * data.page_size, data.total) : 0 const sortProps = { currentSort: sortBy, currentDir: sortDir as SortDir, onSort: handleSort } const items = data?.items ?? [] const useVirtual = items.length > VIRTUAL_SCROLL_THRESHOLD const [selectedIndex, setSelectedIndex] = useState(-1) // j/k/Enter keyboard navigation for device list const hasItems = items.length > 0 useShortcut( 'j', useCallback(() => { setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1)) }, [items.length]), hasItems, ) useShortcut( 'k', useCallback(() => { setSelectedIndex((prev) => Math.max(prev - 1, 0)) }, []), hasItems, ) useShortcut( 'Enter', useCallback(() => { if (selectedIndex >= 0 && selectedIndex < items.length) { handleDeviceClick(items[selectedIndex]) } }, [selectedIndex, items]), hasItems && selectedIndex >= 0, ) const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => VIRTUAL_ROW_HEIGHT, overscan: VIRTUAL_OVERSCAN, enabled: useVirtual, }) const toggleSelection = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) } const toggleSelectAll = () => { if (selectedIds.size === items.length && items.length > 0) { setSelectedIds(new Set()) } else { setSelectedIds(new Set(items.map((d) => d.id))) } } function renderDeviceRow(device: DeviceResponse) { return ( <> toggleSelection(device.id)} className="h-3.5 w-3.5 rounded border-border accent-accent" onClick={(e) => e.stopPropagation()} /> {device.hostname} {device.ip_address} {device.model ?? '—'} {device.site_id ? ( {device.site_name} ) : ( -- )} {device.routeros_version ?? '—'} {device.firmware_version || '—'} {formatUptime(device.uptime_seconds)} {formatDateTime(device.last_seen)}
{device.tags.map((tag) => ( {tag.name} ))}
) } const tableHead = ( 0} onChange={toggleSelectAll} className="h-3.5 w-3.5 rounded border-border accent-accent" /> Status Site Tags ) return (
{/* Mobile card view (below lg:) */}
{isLoading ? ( ) : items.length === 0 ? ( void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId }, search: { add: 'true' }, }), }} /> ) : ( items.map((device) => ( )) )}
{/* Desktop table view (lg: and above) */}
{useVirtual ? ( /* Virtual scrolling for large lists (>100 items) */
{tableHead}
{virtualizer.getVirtualItems().map((virtualRow) => { const device = items[virtualRow.index] return ( {renderDeviceRow(device)} ) })}
) : ( /* Standard table for small lists (<=100 items) */
{tableHead} {isLoading ? ( ) : items.length === 0 ? ( ) : ( items.map((device, idx) => ( {renderDeviceRow(device)} )) )}
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId }, search: { add: 'true' }, }), }} />
)}
{/* Bulk assign action bar */} {selectedIds.size > 0 && (
{selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} selected
)} {/* Bulk assign dialog */} Assign {selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} to site
{/* Pagination (shown for both views) */} {data && data.total > 0 && (
Showing {startItem}–{endItem} of {data.total} device{data.total !== 1 ? 's' : ''}
{page} / {totalPages}
)}
) }