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