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:
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user