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
This commit is contained in:
@@ -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<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,
|
||||
}
|
||||
|
||||
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 categoryColor(category: string | null | undefined) {
|
||||
if (!category) return '#8b949e'
|
||||
return CATEGORY_COLORS[category.toLowerCase()] ?? '#8b949e'
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 text-xs">
|
||||
<span className="text-muted-foreground w-20 shrink-0">{label}</span>
|
||||
<span className="font-mono text-foreground break-all">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||
{device.hostname ?? device.ip}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
{/* Device info */}
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||
<InfoRow label="IP" value={device.ip} />
|
||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||
{device.suggested_type && (
|
||||
<InfoRow label="Type" value={device.suggested_type} />
|
||||
)}
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Services found ({device.services.length})
|
||||
</p>
|
||||
{device.services.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No services detected</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{device.services.map((svc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded bg-[#21262d] border border-[#30363d] text-xs"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: categoryColor(svc.category) }}
|
||||
/>
|
||||
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port}</span>
|
||||
<span className="text-muted-foreground w-7 shrink-0">{svc.protocol}</span>
|
||||
<span className="text-foreground truncate">{svc.service_name}</span>
|
||||
{svc.category && (
|
||||
<span className="ml-auto text-[10px] shrink-0" style={{ color: categoryColor(svc.category) }}>
|
||||
{svc.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground hover:bg-[#30363d]"
|
||||
onClick={handleHide}
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="flex-1 text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
||||
onClick={handleIgnore}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(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 (
|
||||
<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">Pending</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 pending 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>}
|
||||
{d.os && <div className="text-muted-foreground truncate text-[10px]">{d.os}</div>}
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
<ActionButton icon={Check} label="Approve" color="green" onClick={() => handleApprove(d)} />
|
||||
<ActionButton icon={Hide} label="Hide" onClick={() => handleHide(d.id)} />
|
||||
<ActionButton icon={Trash2} label="Ignore" color="red" onClick={() => handleIgnore(d.id)} />
|
||||
</div>
|
||||
<>
|
||||
<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">Pending</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => setSelected(d)}
|
||||
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent hover:border-[#30363d]"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||
<span className="font-mono text-foreground truncate">{d.ip}</span>
|
||||
{d.services.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
|
||||
{d.services.length} svc
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{d.hostname && <div className="text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.hostname}</div>}
|
||||
{d.suggested_type && (
|
||||
<div className="text-[#8b949e] truncate pl-3 text-[10px]">{d.suggested_type}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PendingDeviceModal
|
||||
device={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onApprove={handleApprove}
|
||||
onHide={handleHide}
|
||||
onIgnore={handleIgnore}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user