feat(11-03): add Site column, multi-select bulk assign, and site selector
- Add checkbox column and Site column to FleetTable
- Site names link to /tenants/{tenantId}/sites/{siteId}
- Multi-select checkboxes with select-all in header
- Bulk assign action bar with "Assign to site" dialog
- Device detail page includes site selector dropdown with assign/unassign
- Viewers see site name text, operators get a Select dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { useRef, useState, useCallback } from 'react'
|
import { useRef, useState, useCallback } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } 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 } from 'lucide-react'
|
import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor, MapPin } from 'lucide-react'
|
||||||
import { devicesApi, 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'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,12 @@ import {
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatUptime, formatDateTime } from '@/lib/utils'
|
import { formatUptime, formatDateTime } from '@/lib/utils'
|
||||||
import { cn } 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 { DeviceLink } from '@/components/ui/device-link'
|
||||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
@@ -126,7 +132,28 @@ export function FleetTable({
|
|||||||
pageSize = 25,
|
pageSize = 25,
|
||||||
}: FleetTableProps) {
|
}: FleetTableProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [bulkAssignOpen, setBulkAssignOpen] = useState(false)
|
||||||
|
const [bulkSiteId, setBulkSiteId] = useState<string>('')
|
||||||
|
|
||||||
|
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({
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
queryKey: ['devices', tenantId, { search, status, sortBy, sortDir, page, pageSize }],
|
queryKey: ['devices', tenantId, { search, status, sortBy, sortDir, page, pageSize }],
|
||||||
@@ -207,9 +234,38 @@ export function FleetTable({
|
|||||||
enabled: useVirtual,
|
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) {
|
function renderDeviceRow(device: DeviceResponse) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<td className="px-2 py-1.5 text-center w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(device.id)}
|
||||||
|
onChange={() => toggleSelection(device.id)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-border accent-accent"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<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>
|
||||||
@@ -218,6 +274,19 @@ export function FleetTable({
|
|||||||
{device.ip_address}
|
{device.ip_address}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-text-muted">{device.model ?? '—'}</td>
|
<td className="px-2 py-1.5 text-text-muted">{device.model ?? '—'}</td>
|
||||||
|
<td className="px-2 py-1.5 text-text-secondary">
|
||||||
|
{device.site_id ? (
|
||||||
|
<Link
|
||||||
|
to="/tenants/$tenantId/sites/$siteId"
|
||||||
|
params={{ tenantId, siteId: device.site_id }}
|
||||||
|
className="text-text-secondary hover:text-text-primary transition-colors text-xs truncate max-w-[120px] inline-block"
|
||||||
|
>
|
||||||
|
{device.site_name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted text-xs">--</span>
|
||||||
|
)}
|
||||||
|
</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.routeros_version ?? '—'}
|
||||||
</td>
|
</td>
|
||||||
@@ -246,10 +315,19 @@ export function FleetTable({
|
|||||||
const tableHead = (
|
const tableHead = (
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
|
<th scope="col" className="px-2 py-2 w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.size === items.length && items.length > 0}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
className="h-3.5 w-3.5 rounded border-border accent-accent"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
<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" />
|
||||||
|
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">Site</th>
|
||||||
<SortHeader column="routeros_version" label="RouterOS" {...sortProps} className="text-left" />
|
<SortHeader column="routeros_version" label="RouterOS" {...sortProps} className="text-left" />
|
||||||
<SortHeader column="firmware_version" label="Firmware" {...sortProps} className="text-left" />
|
<SortHeader column="firmware_version" label="Firmware" {...sortProps} className="text-left" />
|
||||||
<SortHeader column="uptime_seconds" label="Uptime" {...sortProps} className="text-right" />
|
<SortHeader column="uptime_seconds" label="Uptime" {...sortProps} className="text-right" />
|
||||||
@@ -354,13 +432,13 @@ export function FleetTable({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-3 py-4">
|
<td colSpan={11} className="px-3 py-4">
|
||||||
<TableSkeleton />
|
<TableSkeleton />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9}>
|
<td colSpan={11}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Monitor}
|
icon={Monitor}
|
||||||
title="No devices yet"
|
title="No devices yet"
|
||||||
@@ -396,6 +474,49 @@ export function FleetTable({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk assign action bar */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-elevated p-3">
|
||||||
|
<span className="text-sm text-text-secondary">{selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} selected</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setBulkAssignOpen(true)}>
|
||||||
|
<MapPin className="h-3.5 w-3.5 mr-1" /> Assign to site
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSelectedIds(new Set())}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk assign dialog */}
|
||||||
|
<Dialog open={bulkAssignOpen} onOpenChange={setBulkAssignOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assign {selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} to site</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 mt-2">
|
||||||
|
<Select value={bulkSiteId} onValueChange={setBulkSiteId}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select a site..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sitesData?.sites.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setBulkAssignOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!bulkSiteId || bulkAssignMutation.isPending}
|
||||||
|
onClick={() => bulkAssignMutation.mutate({ siteId: bulkSiteId, deviceIds: Array.from(selectedIds) })}
|
||||||
|
>
|
||||||
|
{bulkAssignMutation.isPending ? 'Assigning...' : 'Assign'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Pagination (shown for both views) */}
|
{/* Pagination (shown for both views) */}
|
||||||
{data && data.total > 0 && (
|
{data && data.total > 0 && (
|
||||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
BellOff,
|
BellOff,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
MapPin,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Shield,
|
Shield,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, configApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api'
|
import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, configApi, sitesApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api'
|
||||||
import { alertsApi } from '@/lib/alertsApi'
|
import { alertsApi } from '@/lib/alertsApi'
|
||||||
import { useAuth, canWrite, canDelete } from '@/lib/auth'
|
import { useAuth, canWrite, canDelete } from '@/lib/auth'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
@@ -376,6 +377,29 @@ function DeviceDetailPage() {
|
|||||||
enabled: canWrite(user),
|
enabled: canWrite(user),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: sitesData } = useQuery({
|
||||||
|
queryKey: ['sites', tenantId],
|
||||||
|
queryFn: () => sitesApi.list(tenantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const siteAssignMutation = useMutation({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
if (value === 'unassigned') {
|
||||||
|
if (device?.site_id) {
|
||||||
|
await sitesApi.removeDevice(tenantId, device.site_id, deviceId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await sitesApi.assignDevice(tenantId, value, deviceId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['sites'] })
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: 'Failed to update site assignment', variant: 'destructive' }),
|
||||||
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: () => devicesApi.delete(tenantId, deviceId),
|
mutationFn: () => devicesApi.delete(tenantId, deviceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -542,6 +566,32 @@ function DeviceDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
||||||
|
<InfoRow
|
||||||
|
label="Site"
|
||||||
|
value={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-text-muted" />
|
||||||
|
{canWrite(user) ? (
|
||||||
|
<Select
|
||||||
|
value={device.site_id ?? 'unassigned'}
|
||||||
|
onValueChange={(value) => siteAssignMutation.mutate(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||||
|
<SelectValue placeholder="Unassigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||||
|
{sitesData?.sites.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">{device.site_name ?? 'Unassigned'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Credentials (masked) */}
|
{/* Credentials (masked) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user