feat(pending): full-screen modal with grid cards, filters, bulk restore
Replaces sidebar pending/hidden panels with a wide modal showing devices as cards. Adds search, segmented source/status filters, type filter, select mode for bulk approve/hide/restore, and keyboard shortcuts. Hidden cards click-to-restore (no approval detour); approval no longer pops the edit modal. Backend: new restore + bulk-restore endpoints (hidden -> pending).
This commit is contained in:
@@ -174,6 +174,41 @@ async def bulk_hide_devices(
|
|||||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{device_id}/restore", response_model=dict)
|
||||||
|
async def restore_device(
|
||||||
|
device_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device = await db.get(PendingDevice, device_id)
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
if device.status != "hidden":
|
||||||
|
raise HTTPException(status_code=409, detail="Device is not hidden")
|
||||||
|
device.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": True, "device_id": device_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/bulk-restore", response_model=dict)
|
||||||
|
async def bulk_restore_devices(
|
||||||
|
payload: BulkActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
|
PendingDevice.status == "hidden",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
for device in devices:
|
||||||
|
device.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
async def approve_device(
|
async def approve_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -140,6 +140,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
|
|||||||
assert len(hidden_res.json()) == 1
|
assert len(hidden_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Restore hidden device ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
||||||
|
# Hide first
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["restored"] is True
|
||||||
|
|
||||||
|
# Now back in pending, gone from hidden
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert len(pending_res.json()) == 1
|
||||||
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||||
|
assert hidden_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||||
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
||||||
|
# Hide
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-restore",
|
||||||
|
headers=headers,
|
||||||
|
json={"device_ids": [pending_device.id]},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["restored"] == 1
|
||||||
|
assert res.json()["skipped"] == 0
|
||||||
|
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert len(pending_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
# --- Ignore device ---
|
# --- Ignore device ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+21
-20
@@ -23,6 +23,7 @@ import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
|||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -47,8 +48,16 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
||||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
|
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||||
|
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||||
|
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
|
||||||
|
setPendingHighlightId(undefined)
|
||||||
|
setPendingModalStatus(status)
|
||||||
|
setPendingModalOpen(true)
|
||||||
|
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
|
||||||
|
}, [])
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
@@ -437,9 +446,8 @@ export default function App() {
|
|||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
|
||||||
forceView={sidebarForceView}
|
forceView={sidebarForceView}
|
||||||
highlightPendingId={highlightPendingId}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -461,14 +469,7 @@ export default function App() {
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
@@ -608,17 +609,17 @@ export default function App() {
|
|||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
<PendingDevicesModal
|
||||||
|
open={pendingModalOpen}
|
||||||
|
onClose={() => setPendingModalOpen(false)}
|
||||||
|
highlightId={pendingHighlightId}
|
||||||
|
initialStatus={pendingModalStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
open={exportModalOpen}
|
open={exportModalOpen}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export const scanApi = {
|
|||||||
skipped: number
|
skipped: number
|
||||||
}>('/scan/pending/bulk-approve', { device_ids: ids }),
|
}>('/scan/pending/bulk-approve', { device_ids: ids }),
|
||||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||||
|
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||||
|
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
|
||||||
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||||
|
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
|
|
||||||
|
interface PendingDevicesModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
highlightId?: string
|
||||||
|
initialStatus?: 'pending' | 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT_COLORS: Record<number, string> = {
|
||||||
|
22: '#a855f7', // SSH purple
|
||||||
|
80: '#00d4ff', // HTTP cyan
|
||||||
|
443: '#39d353', // HTTPS green
|
||||||
|
53: '#e3b341', // DNS amber
|
||||||
|
3306: '#a855f7', // MySQL
|
||||||
|
5432: '#a855f7', // Postgres
|
||||||
|
6379: '#f85149', // Redis
|
||||||
|
9090: '#e3b341', // Prometheus
|
||||||
|
3000: '#00d4ff', // Grafana/dev
|
||||||
|
8080: '#00d4ff',
|
||||||
|
8443: '#39d353',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
hypervisor: '#ff6e00',
|
||||||
|
nas: '#39d353',
|
||||||
|
automation: '#a855f7',
|
||||||
|
containers: '#00d4ff',
|
||||||
|
network: '#39d353',
|
||||||
|
security: '#f85149',
|
||||||
|
monitoring: '#e3b341',
|
||||||
|
database: '#a855f7',
|
||||||
|
web: '#00d4ff',
|
||||||
|
media: '#ff6e00',
|
||||||
|
iot: '#e3b341',
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceColor(port: number | null | undefined, category?: string | null): string {
|
||||||
|
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
|
||||||
|
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
|
||||||
|
return '#8b949e'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
isp: Globe,
|
||||||
|
router: Router,
|
||||||
|
server: Server,
|
||||||
|
proxmox: Layers,
|
||||||
|
vm: Box,
|
||||||
|
lxc: Container,
|
||||||
|
nas: HardDrive,
|
||||||
|
iot: Cpu,
|
||||||
|
ap: Wifi,
|
||||||
|
switch: Network,
|
||||||
|
generic: Circle,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
||||||
|
type StatusFilter = 'pending' | 'hidden'
|
||||||
|
|
||||||
|
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
||||||
|
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
||||||
|
return 'ip'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_PORTS = new Set([22, 80, 443])
|
||||||
|
|
||||||
|
function specialServiceName(d: PendingDevice): string | undefined {
|
||||||
|
const candidates = (d.services ?? []).filter(
|
||||||
|
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||||
|
)
|
||||||
|
// Deprioritize generic web category so apps like home assistant / jellyfin win
|
||||||
|
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
|
||||||
|
return (nonWeb ?? candidates[0])?.service_name ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(d: PendingDevice): string {
|
||||||
|
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
||||||
|
if (!edges || edges.length === 0) return
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
edges: [
|
||||||
|
...state.edges,
|
||||||
|
...edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
data: { type: 'iot' as const },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||||
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
|
const [selectMode, setSelectMode] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
||||||
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||||
|
setDevices(res.data)
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to load ${statusFilter} devices`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => { if (open) load() }, [open, load])
|
||||||
|
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||||
|
|
||||||
|
// Reset transient state when reopening
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectMode(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setSearch('')
|
||||||
|
} else {
|
||||||
|
setStatusFilter(initialStatus)
|
||||||
|
}
|
||||||
|
}, [open, initialStatus])
|
||||||
|
|
||||||
|
const distinctTypes = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
|
||||||
|
return [...set].sort()
|
||||||
|
}, [devices])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
return devices.filter((d) => {
|
||||||
|
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
||||||
|
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||||
|
if (q) {
|
||||||
|
const hay = [
|
||||||
|
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||||
|
...d.services.map((s) => s.service_name),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
if (!hay.includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [devices, search, sourceFilter, typeFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightId || loading || !open) return
|
||||||
|
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, [highlightId, loading, open, filtered])
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (d: PendingDevice) => {
|
||||||
|
if (selectMode) { toggleSelect(d.id); return }
|
||||||
|
if (statusFilter === 'hidden') { handleRestore(d); return }
|
||||||
|
setSelected(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.restore(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
toast.success(`Restored ${deviceLabel(device)}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to restore device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkRestore = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkRestore(ids)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk restore devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterSelectMode = () => {
|
||||||
|
setSelectMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitSelectMode = () => {
|
||||||
|
setSelectMode(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllVisible = () => {
|
||||||
|
setSelectedIds(new Set(filtered.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
try {
|
||||||
|
await scanApi.clearPending()
|
||||||
|
setDevices([])
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success('Pending devices cleared')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to clear pending devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
const fallbackLabel = deviceLabel(device)
|
||||||
|
const nodeData = {
|
||||||
|
label: fallbackLabel,
|
||||||
|
type: (device.suggested_type ?? 'generic') as NodeType,
|
||||||
|
ip: device.ip ?? undefined,
|
||||||
|
hostname: device.hostname ?? undefined,
|
||||||
|
status: 'unknown',
|
||||||
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
|
}
|
||||||
|
const res = await scanApi.approve(device.id, nodeData)
|
||||||
|
const nodeId = res.data.node_id
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type: nodeData.type,
|
||||||
|
position: { x: 400, y: 300 },
|
||||||
|
data: { ...nodeData, status: 'unknown' as const },
|
||||||
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
|
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
|
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
onNodeApproved(nodeId)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to approve device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHide = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.hide(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
toast.success('Device hidden')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to hide device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIgnore = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.ignore(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkApprove = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkApprove(ids)
|
||||||
|
const deviceToNode: Record<string, string> = {}
|
||||||
|
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||||
|
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||||
|
approvedDevices.forEach((d, i) => {
|
||||||
|
const nodeId = deviceToNode[d.id]
|
||||||
|
if (!nodeId) return
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||||
|
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||||
|
data: {
|
||||||
|
label: deviceLabel(d),
|
||||||
|
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||||
|
ip: d.ip ?? undefined,
|
||||||
|
hostname: d.hostname ?? undefined,
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
|
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk approve devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkHide = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkHide(ids)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk hide devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (inField) return
|
||||||
|
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||||
|
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
||||||
|
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
||||||
|
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!max-w-none w-[95vw] h-[90vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
|
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{statusFilter === 'pending' && devices.length > 0 && (
|
||||||
|
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors" title="Clear all pending">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search name, IP, MAC, IEEE, service…"
|
||||||
|
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('all')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('ip')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
IP scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('zigbee')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Zigbee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||||
|
aria-label="Type filter"
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex rounded border border-border overflow-hidden text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('pending')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('hidden')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Hidden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||||
|
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||||
|
title="Toggle select mode (s)"
|
||||||
|
>
|
||||||
|
{selectMode ? 'Exit select' : 'Select mode'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-10">
|
||||||
|
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||||
|
{filtered.map((d) => (
|
||||||
|
<DeviceCard
|
||||||
|
key={d.id}
|
||||||
|
device={d}
|
||||||
|
selected={selectedIds.has(d.id)}
|
||||||
|
selectMode={selectMode}
|
||||||
|
highlighted={d.id === highlightId}
|
||||||
|
onClick={() => handleCardClick(d)}
|
||||||
|
cardRef={d.id === highlightId ? highlightRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection action bar */}
|
||||||
|
{selectMode && (
|
||||||
|
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={selectAllVisible}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Select all visible ({filtered.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Approve ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkHide}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Hide ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{statusFilter === 'hidden' && (
|
||||||
|
<button
|
||||||
|
onClick={handleBulkRestore}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Restore ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<PendingDeviceModal
|
||||||
|
device={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onHide={handleHide}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceCardProps {
|
||||||
|
device: PendingDevice
|
||||||
|
selected: boolean
|
||||||
|
selectMode: boolean
|
||||||
|
highlighted: boolean
|
||||||
|
onClick: () => void
|
||||||
|
cardRef?: React.Ref<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||||
|
const source = inferSource(device)
|
||||||
|
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
|
const label = deviceLabel(device)
|
||||||
|
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||||
|
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||||
|
const services = device.services ?? []
|
||||||
|
const visibleServices = services.slice(0, 4)
|
||||||
|
const moreServices = services.length - visibleServices.length
|
||||||
|
|
||||||
|
const borderClass = highlighted
|
||||||
|
? 'border-[#e3b341] bg-[#2d3748]'
|
||||||
|
: selected
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
|
||||||
|
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={cardRef}
|
||||||
|
onClick={onClick}
|
||||||
|
data-testid={`pending-card-${device.id}`}
|
||||||
|
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
|
||||||
|
>
|
||||||
|
{selectMode && selected && (
|
||||||
|
<CheckCircle2
|
||||||
|
size={18}
|
||||||
|
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!selectMode && device.status === 'hidden' && (
|
||||||
|
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${sourceColor}22`, color: sourceColor }}
|
||||||
|
>
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
{device.suggested_type && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||||
|
{device.suggested_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.lqi != null && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||||
|
LQI {device.lqi}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tech grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
|
||||||
|
{device.ip && <InfoLine label="IP" value={device.ip} />}
|
||||||
|
{device.mac && <InfoLine label="MAC" value={device.mac} />}
|
||||||
|
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
|
||||||
|
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
|
||||||
|
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
|
||||||
|
{device.model && <InfoLine label="Model" value={device.model} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
{visibleServices.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{visibleServices.map((s, i) => {
|
||||||
|
const color = serviceColor(s.port, s.category)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${s.port}-${s.protocol}-${i}`}
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${color}22`, color }}
|
||||||
|
title={`${s.service_name} (${s.protocol}/${s.port})`}
|
||||||
|
>
|
||||||
|
{s.service_name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{moreServices > 0 && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
|
||||||
|
+{moreServices}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||||
|
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
|
||||||
|
<span className="font-mono text-foreground truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
const mockBulkApprove = vi.fn()
|
||||||
|
const mockBulkHide = vi.fn()
|
||||||
|
const mockRestore = vi.fn()
|
||||||
|
const mockBulkRestore = vi.fn()
|
||||||
|
const mockApprove = vi.fn()
|
||||||
|
const mockHide = vi.fn()
|
||||||
|
const mockPending = vi.fn()
|
||||||
|
const mockHidden = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
pending: (...a: unknown[]) => mockPending(...a),
|
||||||
|
hidden: (...a: unknown[]) => mockHidden(...a),
|
||||||
|
clearPending: vi.fn().mockResolvedValue({}),
|
||||||
|
approve: (...a: unknown[]) => mockApprove(...a),
|
||||||
|
hide: (...a: unknown[]) => mockHide(...a),
|
||||||
|
ignore: vi.fn().mockResolvedValue({}),
|
||||||
|
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
|
||||||
|
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
|
||||||
|
restore: (...a: unknown[]) => mockRestore(...a),
|
||||||
|
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||||
|
PendingDeviceModal: ({ device }: { device: unknown }) =>
|
||||||
|
device ? <div data-testid="approval-modal" /> : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const DEVICE_IP = {
|
||||||
|
id: 'dev-a',
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
hostname: 'host-a',
|
||||||
|
mac: 'aa:bb:cc:dd:ee:01',
|
||||||
|
os: null,
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
|
||||||
|
suggested_type: 'server',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
discovered_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_ZIGBEE = {
|
||||||
|
id: 'dev-b',
|
||||||
|
ip: null,
|
||||||
|
hostname: null,
|
||||||
|
mac: null,
|
||||||
|
os: null,
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'iot',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'zigbee',
|
||||||
|
ieee_address: '0x00124b001234abcd',
|
||||||
|
friendly_name: 'living-room-bulb',
|
||||||
|
vendor: 'Philips',
|
||||||
|
model: 'Hue White',
|
||||||
|
discovered_at: '2026-01-02T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||||
|
// setState is used by injectAutoEdges
|
||||||
|
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||||
|
mockHidden.mockResolvedValue({ data: [] })
|
||||||
|
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
||||||
|
mockHide.mockResolvedValue({ data: {} })
|
||||||
|
mockBulkApprove.mockResolvedValue({
|
||||||
|
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
|
||||||
|
})
|
||||||
|
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||||
|
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
||||||
|
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PendingDevicesModal', () => {
|
||||||
|
it('loads and renders pending devices on open', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by search query', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by source (zigbee only)', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by suggested type', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
|
||||||
|
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to hidden status loads hidden devices', async () => {
|
||||||
|
mockHidden.mockResolvedValue({
|
||||||
|
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
|
||||||
|
})
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
|
||||||
|
expect(mockHidden).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens approval modal when card is clicked outside select mode', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles selection in select mode instead of opening approval', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('select all visible selects only filtered devices', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
|
||||||
|
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk approve calls API with selected ids', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk hide calls API with selected ids', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Hide \(1\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not load when closed', () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} open={false} />)
|
||||||
|
expect(mockPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects initialStatus=hidden', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
|
||||||
|
expect(mockPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a hidden card restores it instead of opening approval', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk restore in hidden mode calls API with selected ids', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut, Network } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -8,19 +8,14 @@ import { scanApi, settingsApi } from '@/api/client'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
|
type SidebarView = 'canvas' | 'history' | 'settings'
|
||||||
|
|
||||||
const ALL_VIEWS = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||||
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||||
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
|
|
||||||
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
|
|
||||||
]
|
]
|
||||||
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
|
|
||||||
|
|
||||||
interface ScanRun {
|
interface ScanRun {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,12 +33,11 @@ interface SidebarProps {
|
|||||||
onScan: () => void
|
onScan: () => void
|
||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
|
||||||
forceView?: SidebarView
|
forceView?: SidebarView
|
||||||
highlightPendingId?: string
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
@@ -88,23 +82,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
|
|
||||||
{/* Views */}
|
{/* Views */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2">
|
<nav className="flex flex-col gap-0.5 p-2">
|
||||||
{VIEWS.map(({ id, icon: Icon, label }) => (
|
<SidebarItem
|
||||||
|
icon={LayoutDashboard}
|
||||||
|
label="Canvas"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'canvas'}
|
||||||
|
onClick={() => setActiveView('canvas')}
|
||||||
|
/>
|
||||||
|
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={id}
|
key={t.kind}
|
||||||
icon={Icon}
|
icon={t.icon}
|
||||||
label={label}
|
label={t.label}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === id}
|
onClick={() => onOpenPending(undefined, t.kind)}
|
||||||
onClick={() => setActiveView(id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={Clock}
|
||||||
|
label="Scan History"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'history'}
|
||||||
|
onClick={() => setActiveView('history')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* View content (only when expanded) */}
|
{/* View content (only when expanded) */}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
|
||||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
@@ -178,361 +185,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_PORTS = new Set([22, 80, 443])
|
|
||||||
|
|
||||||
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
|
||||||
if (!edges || edges.length === 0) return
|
|
||||||
useCanvasStore.setState((state) => ({
|
|
||||||
edges: [
|
|
||||||
...state.edges,
|
|
||||||
...edges.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
sourceHandle: 'bottom',
|
|
||||||
targetHandle: 'top-t',
|
|
||||||
type: 'iot',
|
|
||||||
data: { type: 'iot' as const },
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
hasUnsavedChanges: true,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
|
||||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
const allChecked = devices.length > 0 && checkedIds.size === devices.length
|
|
||||||
const someChecked = checkedIds.size > 0
|
|
||||||
|
|
||||||
const toggleCheck = (id: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setCheckedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id); else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.pending()
|
|
||||||
setDevices(res.data)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load pending devices')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
|
||||||
try {
|
|
||||||
await scanApi.clearPending()
|
|
||||||
setDevices([])
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success('Pending devices cleared')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to clear pending devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkApprove = async () => {
|
|
||||||
const ids = [...checkedIds]
|
|
||||||
try {
|
|
||||||
const res = await scanApi.bulkApprove(ids)
|
|
||||||
const deviceToNode: Record<string, string> = {}
|
|
||||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
|
||||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
|
||||||
approvedDevices.forEach((d, i) => {
|
|
||||||
const nodeId = deviceToNode[d.id]
|
|
||||||
if (!nodeId) return
|
|
||||||
const fallbackLabel = d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
|
|
||||||
addNode({
|
|
||||||
id: nodeId,
|
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
|
||||||
data: {
|
|
||||||
label: fallbackLabel,
|
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
ip: d.ip ?? undefined,
|
|
||||||
hostname: d.hostname ?? undefined,
|
|
||||||
status: 'unknown' as const,
|
|
||||||
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
onNodeApproved(nodeId)
|
|
||||||
})
|
|
||||||
injectAutoEdges(res.data.edges)
|
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
|
||||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to bulk approve devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkHide = async () => {
|
|
||||||
const ids = [...checkedIds]
|
|
||||||
try {
|
|
||||||
const res = await scanApi.bulkHide(ids)
|
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to bulk hide devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scanEventTs > 0) load()
|
|
||||||
}, [scanEventTs, load])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!highlightId || loading) return
|
|
||||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}, [highlightId, loading])
|
|
||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device'
|
|
||||||
const nodeData = {
|
|
||||||
label: fallbackLabel,
|
|
||||||
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
ip: device.ip ?? undefined,
|
|
||||||
hostname: device.hostname ?? undefined,
|
|
||||||
status: 'unknown',
|
|
||||||
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
|
||||||
}
|
|
||||||
const res = await scanApi.approve(device.id, nodeData)
|
|
||||||
const nodeId = res.data.node_id
|
|
||||||
addNode({
|
|
||||||
id: nodeId,
|
|
||||||
type: nodeData.type,
|
|
||||||
position: { x: 400, y: 300 },
|
|
||||||
data: { ...nodeData, status: 'unknown' as const },
|
|
||||||
})
|
|
||||||
injectAutoEdges(res.data.edges)
|
|
||||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
|
||||||
toast.success(`Approved ${nodeData.label}${extra}`)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
setSelected(null)
|
|
||||||
onNodeApproved(nodeId)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to approve device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHide = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
await scanApi.hide(device.id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
toast.success('Device hidden')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to hide device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIgnore = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
await scanApi.ignore(device.id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to ignore device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allChecked}
|
|
||||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
|
|
||||||
onChange={toggleAll}
|
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
|
||||||
title="Select all"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{someChecked && (
|
|
||||||
<div className="flex items-center gap-1 mb-2">
|
|
||||||
<button
|
|
||||||
onClick={handleBulkApprove}
|
|
||||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Approve ({checkedIds.size})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleBulkHide}
|
|
||||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Hide ({checkedIds.size})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && devices.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
|
||||||
)}
|
|
||||||
{devices.map((d) => {
|
|
||||||
const isZigbee = d.discovery_source === 'zigbee'
|
|
||||||
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
|
|
||||||
const titleService = namedService
|
|
||||||
?? d.services.find((s) => s.port === 80)
|
|
||||||
?? d.services.find((s) => s.port === 443)
|
|
||||||
?? d.services.find((s) => s.port === 22)
|
|
||||||
const title = isZigbee
|
|
||||||
? (d.friendly_name ?? d.hostname ?? d.ieee_address ?? 'zigbee device')
|
|
||||||
: (titleService?.service_name ?? d.hostname ?? d.ip ?? 'device')
|
|
||||||
const showIpBelow = !isZigbee && d.ip != null && title !== d.ip
|
|
||||||
const hasSsh = d.services.some((s) => s.port === 22)
|
|
||||||
const hasHttp = d.services.some((s) => s.port === 80)
|
|
||||||
const hasHttps = d.services.some((s) => s.port === 443)
|
|
||||||
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
|
||||||
const virtualBadge = detectVirtualBadge(d.mac)
|
|
||||||
const sourceColor =
|
|
||||||
d.discovery_source === 'mdns' ? '#a855f7'
|
|
||||||
: d.discovery_source === 'zigbee' ? '#00d4ff'
|
|
||||||
: '#8b949e'
|
|
||||||
const sourceLabel =
|
|
||||||
d.discovery_source === 'mdns' ? 'mDNS'
|
|
||||||
: d.discovery_source === 'arp' ? 'ARP'
|
|
||||||
: d.discovery_source === 'zigbee' ? 'ZIG'
|
|
||||||
: null
|
|
||||||
const isHighlighted = d.id === highlightId
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={d.id}
|
|
||||||
ref={isHighlighted ? highlightRef : null}
|
|
||||||
onClick={() => setSelected(d)}
|
|
||||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checkedIds.has(d.id)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
|
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-foreground truncate font-medium">{title}</span>
|
|
||||||
</div>
|
|
||||||
{showIpBelow && (
|
|
||||||
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
|
||||||
)}
|
|
||||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
|
|
||||||
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
|
||||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
|
||||||
{virtualBadge && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{hasSsh && <ServiceBadge label="SSH" color="#a855f7" />}
|
|
||||||
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
|
|
||||||
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
|
|
||||||
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
|
|
||||||
{isZigbee && d.lqi != null && <ServiceBadge label={`LQI ${d.lqi}`} color="#8b949e" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PendingDeviceModal
|
|
||||||
device={selected}
|
|
||||||
onClose={() => setSelected(null)}
|
|
||||||
onApprove={handleApprove}
|
|
||||||
onHide={handleHide}
|
|
||||||
onIgnore={handleIgnore}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HiddenDevicesPanel() {
|
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.hidden()
|
|
||||||
setDevices(res.data)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load hidden devices')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleIgnore = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await scanApi.ignore(id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to remove device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && devices.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
|
|
||||||
)}
|
|
||||||
{devices.map((d) => (
|
|
||||||
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
|
||||||
<div className="font-mono text-foreground">{d.ip}</div>
|
|
||||||
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
|
|
||||||
<div className="flex gap-1 mt-1.5">
|
|
||||||
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScanHistoryPanel() {
|
function ScanHistoryPanel() {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
@@ -731,55 +383,6 @@ function VersionBadge() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
|
||||||
'00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' },
|
|
||||||
'00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' },
|
|
||||||
'08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' },
|
|
||||||
'00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectVirtualBadge(mac: string | null) {
|
|
||||||
if (!mac) return null
|
|
||||||
return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceBadge({ label, color }: { label: string; color: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="px-1 py-0.5 rounded text-[9px] font-mono font-medium leading-none border"
|
|
||||||
style={{ color, borderColor: `${color}40`, backgroundColor: `${color}15` }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionButtonProps {
|
|
||||||
icon: React.ElementType
|
|
||||||
label: string
|
|
||||||
color?: 'green' | 'red'
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
|
|
||||||
const colorClass =
|
|
||||||
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
|
|
||||||
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
|
|
||||||
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
|
|
||||||
<Icon size={11} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -11,22 +11,11 @@ import type { NodeData } from '@/types'
|
|||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
vi.mock('@/stores/authStore')
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
const mockBulkApprove = vi.fn()
|
|
||||||
const mockBulkHide = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
trigger: vi.fn().mockResolvedValue({}),
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
stop: vi.fn().mockResolvedValue({}),
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
clearPending: vi.fn().mockResolvedValue({}),
|
|
||||||
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
|
|
||||||
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
|
|
||||||
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
|
|
||||||
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
|
|
||||||
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
|
|
||||||
},
|
},
|
||||||
settingsApi: {
|
settingsApi: {
|
||||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
@@ -48,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
|
|||||||
TooltipContent: () => null,
|
TooltipContent: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
|
||||||
PendingDeviceModal: () => null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||||
@@ -86,8 +71,9 @@ const defaultProps = {
|
|||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
|
onZigbeeImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onNodeApproved: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -129,26 +115,22 @@ describe('Sidebar', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total (excludes groupRect)
|
|
||||||
expect(screen.getByText('4')).toBeInTheDocument()
|
expect(screen.getByText('4')).toBeInTheDocument()
|
||||||
// Online
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument()
|
expect(screen.getByText('2')).toBeInTheDocument()
|
||||||
// Offline
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument()
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes groupRect nodes from stats', () => {
|
it('excludes groupRect nodes from stats', () => {
|
||||||
mockStore({
|
mockStore({
|
||||||
nodes: [
|
nodes: [
|
||||||
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
makeNode('n1', 'unknown'),
|
||||||
makeNode('zone', 'unknown', 'groupRect'),
|
makeNode('zone', 'unknown', 'groupRect'),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total row shows 1 (groupRect excluded), online/offline both 0
|
|
||||||
const totalRow = screen.getByText('Total').closest('div')!
|
const totalRow = screen.getByText('Total').closest('div')!
|
||||||
expect(totalRow).toHaveTextContent('1')
|
expect(totalRow).toHaveTextContent('1')
|
||||||
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
expect(screen.getAllByText('0')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Collapse ───────────────────────────────────────────────────────────────
|
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||||
@@ -225,7 +207,6 @@ describe('Sidebar', () => {
|
|||||||
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||||
mockStore({ hasUnsavedChanges: true })
|
mockStore({ hasUnsavedChanges: true })
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// The badge is a span sibling of the Save Canvas button icon
|
|
||||||
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||||
const badge = saveBtn.querySelector('span.rounded-full')
|
const badge = saveBtn.querySelector('span.rounded-full')
|
||||||
expect(badge).toBeInTheDocument()
|
expect(badge).toBeInTheDocument()
|
||||||
@@ -241,24 +222,24 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Scan action ────────────────────────────────────────────────────────────
|
// ── Scan action ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
it('calls onScan prop when Scan Network is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
fireEvent.click(screen.getByText('Pending Devices'))
|
||||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
|
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||||
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||||
@@ -267,16 +248,13 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Regression: forceView used to override local state on every render, freezing
|
// Regression: forceView must not freeze local state across rerenders.
|
||||||
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
|
|
||||||
it('allows switching views after forceView is set by parent', async () => {
|
it('allows switching views after forceView is set by parent', async () => {
|
||||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
// Parent keeps forceView as 'history'; user clicks another nav item.
|
|
||||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
fireEvent.click(screen.getByText('Canvas'))
|
||||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||||
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles Settings panel on Settings click', async () => {
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
@@ -285,7 +263,6 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||||
)
|
)
|
||||||
// Click the nav button again to close (use role to avoid matching the panel heading)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -303,101 +280,3 @@ describe('Sidebar', () => {
|
|||||||
expect(mockLogout).toHaveBeenCalledOnce()
|
expect(mockLogout).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEVICE_A = {
|
|
||||||
id: 'dev-a',
|
|
||||||
ip: '192.168.1.10',
|
|
||||||
hostname: 'host-a',
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
services: [],
|
|
||||||
suggested_type: 'generic',
|
|
||||||
status: 'pending',
|
|
||||||
discovery_source: 'arp',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEVICE_B = {
|
|
||||||
id: 'dev-b',
|
|
||||||
ip: '192.168.1.11',
|
|
||||||
hostname: 'host-b',
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
services: [],
|
|
||||||
suggested_type: 'generic',
|
|
||||||
status: 'pending',
|
|
||||||
discovery_source: 'arp',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PendingDevicesPanel — bulk select', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockStore()
|
|
||||||
mockAuth()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockBulkApprove.mockResolvedValue({
|
|
||||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
|
||||||
})
|
|
||||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function renderWithDevices() {
|
|
||||||
const { scanApi } = await import('@/api/client')
|
|
||||||
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
|
|
||||||
render(<Sidebar {...defaultProps} forceView="pending" />)
|
|
||||||
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders checkboxes for each device', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox')
|
|
||||||
// select-all + 2 device checkboxes
|
|
||||||
expect(checkboxes.length).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows bulk action bar when a device is checked', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(firstDeviceCheckbox)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
|
|
||||||
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides bulk action bar when no device is checked', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('select-all checks all devices', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('select-all unchecks all when all are selected', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll) // select all
|
|
||||||
fireEvent.click(selectAll) // deselect all
|
|
||||||
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls bulkApprove with checked ids and removes devices from list', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
fireEvent.click(screen.getByText(/Approve \(2\)/))
|
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
|
||||||
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls bulkHide with checked ids and removes devices from list', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
fireEvent.click(screen.getByText(/Hide \(2\)/))
|
|
||||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
|
||||||
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user