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
const navigate = useNavigate()
// 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 {
// eslint-disable-next-line react-hooks/rules-of-hooks
searchObj = useSearch({ from: '/_authenticated/tenants/$tenantId/devices/' }) as typeof searchObj
@@ -32,6 +32,7 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
const searchText = searchObj.search ?? ''
const statusFilter = searchObj.status ?? ''
const deviceTypeFilter = searchObj.device_type ?? ''
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
@@ -65,11 +66,15 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
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 = () => {
if (inputRef.current) inputRef.current.value = ''
updateFilter({ search: undefined, status: undefined })
updateFilter({ search: undefined, status: undefined, device_type: undefined })
}
return (
@@ -100,6 +105,18 @@ export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
</SelectContent>
</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 && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-text-muted">
<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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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 { Badge } from '@/components/ui/badge'
import { useShortcut } from '@/hooks/useShortcut'
@@ -30,6 +30,7 @@ interface FleetTableProps {
tenantId: string
search?: string
status?: string
deviceType?: string
sortBy?: string
sortDir?: 'asc' | 'desc'
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 {
column: 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-center gap-2 min-w-0">
<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>
</div>
<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">
<span className="font-mono">{device.ip_address}</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>
{device.tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
@@ -126,6 +135,7 @@ export function FleetTable({
tenantId,
search,
status,
deviceType,
sortBy = 'hostname',
sortDir = 'asc',
page = 1,
@@ -156,11 +166,12 @@ export function FleetTable({
})
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: () =>
devicesApi.list(tenantId, {
search,
status,
device_type: deviceType,
sort_by: sortBy,
sort_dir: sortDir,
page,
@@ -269,6 +280,9 @@ export function FleetTable({
<td className="px-2 py-1.5 text-center">
<StatusDot status={device.status} />
</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-mono text-xs text-text-secondary">
{device.ip_address}
@@ -288,7 +302,7 @@ export function FleetTable({
)}
</td>
<td className="px-2 py-1.5 text-text-secondary">
{device.routeros_version ?? '—'}
{device.device_type === 'snmp' ? '—' : (device.routeros_version ?? '—')}
</td>
<td className="px-2 py-1.5 text-text-secondary">
{device.firmware_version || '—'}
@@ -324,6 +338,7 @@ export function FleetTable({
/>
</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="ip_address" label="IP" {...sortProps} className="text-left" />
<SortHeader column="model" label="Model" {...sortProps} className="text-left" />
@@ -432,13 +447,13 @@ export function FleetTable({
<tbody>
{isLoading ? (
<tr>
<td colSpan={11} className="px-3 py-4">
<td colSpan={12} className="px-3 py-4">
<TableSkeleton />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={11}>
<td colSpan={12}>
<EmptyState
icon={Monitor}
title="No devices yet"

View File

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