feat(19-01): add device type icon and filter to fleet table

- Add DeviceTypeIcon component (Router for RouterOS, Network for SNMP)
- Add Type column to desktop table between Status and Hostname
- Add type icon to mobile DeviceCard view
- Show em-dash for RouterOS version on SNMP devices
- Add device type filter dropdown to DeviceFilters (All / RouterOS / SNMP)
- Pass device_type through URL search params to API query
- Update colSpan from 11 to 12 for empty/loading states

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 19:59:29 -05:00
parent 74ddaad551
commit fbad0e9a56
3 changed files with 43 additions and 9 deletions

View File

@@ -22,7 +22,7 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
// Use relative navigation for filter params // Use relative navigation for filter params
const navigate = useNavigate() const navigate = useNavigate()
// Safely get search params // Safely get search params
let searchObj: { search?: string; status?: string; page?: number; page_size?: number } = {} let searchObj: { search?: string; status?: string; device_type?: string; page?: number; page_size?: number } = {}
try { try {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
searchObj = useSearch({ from: '/_authenticated/tenants/$tenantId/devices/' }) as typeof searchObj searchObj = useSearch({ from: '/_authenticated/tenants/$tenantId/devices/' }) as typeof searchObj
@@ -32,6 +32,7 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
const searchText = searchObj.search ?? '' const searchText = searchObj.search ?? ''
const statusFilter = searchObj.status ?? '' const statusFilter = searchObj.status ?? ''
const deviceTypeFilter = searchObj.device_type ?? ''
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -65,11 +66,15 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
updateFilter({ status: value === 'all' ? undefined : value }) updateFilter({ status: value === 'all' ? undefined : value })
} }
const hasFilters = !!(searchText || statusFilter) const handleDeviceType = (value: string) => {
updateFilter({ device_type: value === 'all' ? undefined : value })
}
const hasFilters = !!(searchText || statusFilter || deviceTypeFilter)
const clearFilters = () => { const clearFilters = () => {
if (inputRef.current) inputRef.current.value = '' if (inputRef.current) inputRef.current.value = ''
updateFilter({ search: undefined, status: undefined }) updateFilter({ search: undefined, status: undefined, device_type: undefined })
} }
return ( return (
@@ -100,6 +105,18 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
</SelectContent> </SelectContent>
</Select> </Select>
{/* Device type filter */}
<Select value={deviceTypeFilter || 'all'} onValueChange={handleDeviceType}>
<SelectTrigger className="w-32">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="routeros">RouterOS</SelectItem>
<SelectItem value="snmp">SNMP</SelectItem>
</SelectContent>
</Select>
{hasFilters && ( {hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-text-muted"> <Button variant="ghost" size="sm" onClick={clearFilters} className="text-text-muted">
<X className="h-3.5 w-3.5 mr-1" /> <X className="h-3.5 w-3.5 mr-1" />

View File

@@ -2,7 +2,7 @@ import { useRef, useState, useCallback } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor, MapPin } from 'lucide-react' import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor, MapPin, Router, Network } from 'lucide-react'
import { devicesApi, sitesApi, type DeviceResponse } from '@/lib/api' import { devicesApi, sitesApi, type DeviceResponse } from '@/lib/api'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useShortcut } from '@/hooks/useShortcut' import { useShortcut } from '@/hooks/useShortcut'
@@ -30,6 +30,7 @@ interface FleetTableProps {
tenantId: string tenantId: string
search?: string search?: string
status?: string status?: string
deviceType?: string
sortBy?: string sortBy?: string
sortDir?: 'asc' | 'desc' sortDir?: 'asc' | 'desc'
page?: number page?: number
@@ -52,6 +53,13 @@ function StatusDot({ status }: { status: string }) {
) )
} }
function DeviceTypeIcon({ deviceType }: { deviceType: string }) {
if (deviceType === 'snmp') {
return <Network className="h-3.5 w-3.5 text-text-muted" title="SNMP" />
}
return <Router className="h-3.5 w-3.5 text-text-muted" title="RouterOS" />
}
interface SortHeaderProps { interface SortHeaderProps {
column: string column: string
label: string label: string
@@ -98,6 +106,7 @@ function DeviceCard({ device, tenantId }: { device: DeviceResponse; tenantId: st
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<StatusDot status={device.status} /> <StatusDot status={device.status} />
<DeviceTypeIcon deviceType={device.device_type ?? 'routeros'} />
<DeviceLink tenantId={tenantId} deviceId={device.id} className="font-medium text-sm text-text-primary truncate">{device.hostname}</DeviceLink> <DeviceLink tenantId={tenantId} deviceId={device.id} className="font-medium text-sm text-text-primary truncate">{device.hostname}</DeviceLink>
</div> </div>
<span className="text-xs text-text-muted shrink-0">{formatUptime(device.uptime_seconds)}</span> <span className="text-xs text-text-muted shrink-0">{formatUptime(device.uptime_seconds)}</span>
@@ -105,7 +114,7 @@ function DeviceCard({ device, tenantId }: { device: DeviceResponse; tenantId: st
<div className="mt-1.5 flex items-center gap-3 text-xs text-text-secondary"> <div className="mt-1.5 flex items-center gap-3 text-xs text-text-secondary">
<span className="font-mono">{device.ip_address}</span> <span className="font-mono">{device.ip_address}</span>
{device.model && <span>{device.model}</span>} {device.model && <span>{device.model}</span>}
{device.routeros_version && <span>v{device.routeros_version}</span>} {device.device_type !== 'snmp' && device.routeros_version && <span>v{device.routeros_version}</span>}
</div> </div>
{device.tags.length > 0 && ( {device.tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
@@ -126,6 +135,7 @@ export function FleetTable({
tenantId, tenantId,
search, search,
status, status,
deviceType,
sortBy = 'hostname', sortBy = 'hostname',
sortDir = 'asc', sortDir = 'asc',
page = 1, page = 1,
@@ -156,11 +166,12 @@ export function FleetTable({
}) })
const { data, isLoading, isFetching } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ['devices', tenantId, { search, status, sortBy, sortDir, page, pageSize }], queryKey: ['devices', tenantId, { search, status, deviceType, sortBy, sortDir, page, pageSize }],
queryFn: () => queryFn: () =>
devicesApi.list(tenantId, { devicesApi.list(tenantId, {
search, search,
status, status,
device_type: deviceType,
sort_by: sortBy, sort_by: sortBy,
sort_dir: sortDir, sort_dir: sortDir,
page, page,
@@ -269,6 +280,9 @@ export function FleetTable({
<td className="px-2 py-1.5 text-center"> <td className="px-2 py-1.5 text-center">
<StatusDot status={device.status} /> <StatusDot status={device.status} />
</td> </td>
<td className="px-2 py-1.5 text-center w-8">
<DeviceTypeIcon deviceType={device.device_type ?? 'routeros'} />
</td>
<td className="px-2 py-1.5 font-medium text-text-primary"><DeviceLink tenantId={tenantId} deviceId={device.id}>{device.hostname}</DeviceLink></td> <td className="px-2 py-1.5 font-medium text-text-primary"><DeviceLink tenantId={tenantId} deviceId={device.id}>{device.hostname}</DeviceLink></td>
<td className="px-2 py-1.5 font-mono text-xs text-text-secondary"> <td className="px-2 py-1.5 font-mono text-xs text-text-secondary">
{device.ip_address} {device.ip_address}
@@ -288,7 +302,7 @@ export function FleetTable({
)} )}
</td> </td>
<td className="px-2 py-1.5 text-text-secondary"> <td className="px-2 py-1.5 text-text-secondary">
{device.routeros_version ?? '—'} {device.device_type === 'snmp' ? '—' : (device.routeros_version ?? '—')}
</td> </td>
<td className="px-2 py-1.5 text-text-secondary"> <td className="px-2 py-1.5 text-text-secondary">
{device.firmware_version || '—'} {device.firmware_version || '—'}
@@ -324,6 +338,7 @@ export function FleetTable({
/> />
</th> </th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted w-6"><span className="sr-only">Status</span></th> <th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted w-6"><span className="sr-only">Status</span></th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted w-8"><span className="sr-only">Type</span></th>
<SortHeader column="hostname" label="Hostname" {...sortProps} className="text-left" /> <SortHeader column="hostname" label="Hostname" {...sortProps} className="text-left" />
<SortHeader column="ip_address" label="IP" {...sortProps} className="text-left" /> <SortHeader column="ip_address" label="IP" {...sortProps} className="text-left" />
<SortHeader column="model" label="Model" {...sortProps} className="text-left" /> <SortHeader column="model" label="Model" {...sortProps} className="text-left" />
@@ -432,13 +447,13 @@ export function FleetTable({
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
<tr> <tr>
<td colSpan={11} className="px-3 py-4"> <td colSpan={12} className="px-3 py-4">
<TableSkeleton /> <TableSkeleton />
</td> </td>
</tr> </tr>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<tr> <tr>
<td colSpan={11}> <td colSpan={12}>
<EmptyState <EmptyState
icon={Monitor} icon={Monitor}
title="No devices yet" title="No devices yet"

View File

@@ -13,6 +13,7 @@ import { AddDeviceForm } from '@/components/fleet/AddDeviceForm'
const searchSchema = z.object({ const searchSchema = z.object({
search: z.string().optional(), search: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
device_type: z.string().optional(),
sort_by: z.string().optional(), sort_by: z.string().optional(),
sort_dir: z.enum(['asc', 'desc']).optional(), sort_dir: z.enum(['asc', 'desc']).optional(),
page: z.number().int().positive().optional(), page: z.number().int().positive().optional(),
@@ -94,6 +95,7 @@ function DevicesPage() {
tenantId={tenantId} tenantId={tenantId}
search={search.search} search={search.search}
status={search.status} status={search.status}
deviceType={search.device_type}
sortBy={search.sort_by} sortBy={search.sort_by}
sortDir={search.sort_dir} sortDir={search.sort_dir}
page={search.page} page={search.page}