From bc30250398eb5fc8269ddc9bd7f4dfa0be7e31a7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 7 Mar 2026 16:11:53 +0100 Subject: [PATCH] feat: pending device detail modal with services and actions - Click any pending device row to open a detail modal - Modal shows IP, hostname, MAC, OS, suggested type, discovered_at - Service list with port/protocol/service_name, colored dot by category - Approve / Hide / Delete buttons with matching color coding - List items now show IP, service count, hostname and suggested type at a glance --- .../components/modals/PendingDeviceModal.tsx | 169 ++++++++++++++++++ frontend/src/components/panels/Sidebar.tsx | 95 +++++----- 2 files changed, 220 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/modals/PendingDeviceModal.tsx diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx new file mode 100644 index 0000000..34324cb --- /dev/null +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -0,0 +1,169 @@ +import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +interface Service { + port: number + protocol: string + service_name: string + icon?: string | null + category?: string | null +} + +export interface PendingDevice { + id: string + ip: string + mac: string | null + hostname: string | null + os: string | null + services: Service[] + suggested_type: string | null + status: string + discovered_at: string +} + +interface PendingDeviceModalProps { + device: PendingDevice | null + onClose: () => void + onApprove: (device: PendingDevice) => void + onHide: (device: PendingDevice) => void + onIgnore: (device: PendingDevice) => void +} + +const TYPE_ICONS: Record = { + isp: Globe, + router: Router, + server: Server, + proxmox: Layers, + vm: Box, + lxc: Container, + nas: HardDrive, + iot: Cpu, + ap: Wifi, + switch: Network, + generic: Circle, +} + +const CATEGORY_COLORS: Record = { + hypervisor: '#ff6e00', + nas: '#39d353', + automation: '#a855f7', + containers: '#00d4ff', + network: '#39d353', + security: '#f85149', + monitoring: '#e3b341', + database: '#a855f7', + web: '#00d4ff', + media: '#ff6e00', + iot: '#e3b341', +} + +function categoryColor(category: string | null | undefined) { + if (!category) return '#8b949e' + return CATEGORY_COLORS[category.toLowerCase()] ?? '#8b949e' +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: PendingDeviceModalProps) { + if (!device) return null + + const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle + + const handleApprove = () => { onApprove(device); onClose() } + const handleHide = () => { onHide(device); onClose() } + const handleIgnore = () => { onIgnore(device); onClose() } + + return ( + !o && onClose()}> + + + + + {device.hostname ?? device.ip} + + + +
+ {/* Device info */} +
+ + {device.hostname && } + {device.mac && } + {device.os && } + {device.suggested_type && ( + + )} + +
+ + {/* Services */} +
+

+ Services found ({device.services.length}) +

+ {device.services.length === 0 ? ( +

No services detected

+ ) : ( +
+ {device.services.map((svc, i) => ( +
+ + {svc.port} + {svc.protocol} + {svc.service_name} + {svc.category && ( + + {svc.category} + + )} +
+ ))} +
+ )} +
+ + {/* Actions */} +
+ + + +
+
+
+
+ ) +} diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 77dd074..706d42f 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,9 +1,10 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Check, EyeOff as Hide, Trash2, RefreshCw, Loader2 } from 'lucide-react' +import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2 } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { scanApi } from '@/api/client' import { toast } from 'sonner' +import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' @@ -14,18 +15,6 @@ const VIEWS = [ { id: 'history' as SidebarView, icon: Clock, label: 'Scan History' }, ] -interface PendingDevice { - id: string - ip: string - mac: string | null - hostname: string | null - os: string | null - services: unknown[] - suggested_type: string | null - status: string - discovered_at: string -} - interface ScanRun { id: string status: string @@ -150,6 +139,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) { function PendingDevicesPanel() { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState(null) const { addNode, scanEventTs } = useCanvasStore() const load = useCallback(async () => { @@ -164,10 +154,8 @@ function PendingDevicesPanel() { } }, []) - // Load on mount useEffect(() => { load() }, [load]) - // Auto-refresh when the backend pushes a scan_device_found event useEffect(() => { if (scanEventTs > 0) load() }, [scanEventTs, load]) @@ -197,50 +185,69 @@ function PendingDevicesPanel() { } } - const handleHide = async (id: string) => { + const handleHide = async (device: PendingDevice) => { try { - await scanApi.hide(id) - setDevices((prev) => prev.filter((d) => d.id !== id)) + 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 (id: string) => { + const handleIgnore = async (device: PendingDevice) => { try { - await scanApi.ignore(id) - setDevices((prev) => prev.filter((d) => d.id !== id)) + await scanApi.ignore(device.id) + setDevices((prev) => prev.filter((d) => d.id !== device.id)) } catch { toast.error('Failed to ignore device') } } return ( -
-
- Pending - -
- {loading && } - {!loading && devices.length === 0 && ( -

No pending devices

- )} - {devices.map((d) => ( -
-
{d.ip}
- {d.hostname &&
{d.hostname}
} - {d.os &&
{d.os}
} -
- handleApprove(d)} /> - handleHide(d.id)} /> - handleIgnore(d.id)} /> -
+ <> +
+
+ Pending +
- ))} -
+ {loading && } + {!loading && devices.length === 0 && ( +

No pending devices

+ )} + {devices.map((d) => ( + + ))} +
+ + setSelected(null)} + onApprove={handleApprove} + onHide={handleHide} + onIgnore={handleIgnore} + /> + ) }