import { useState, useCallback, useEffect, useRef } from 'react' import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } 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' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' const ALL_VIEWS = [ { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, { id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending 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 { id: string status: string ranges: string[] devices_found: number started_at: string finished_at: string | null error: string | null } interface SidebarProps { onAddNode: () => void onAddGroupRect: () => void onScan: () => void onSave: () => void onNodeApproved: (nodeId: string) => void } export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const [activeView, setActiveView] = useState('canvas') const { nodes, hasUnsavedChanges } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length const handleScan = useCallback(async () => { try { await scanApi.trigger() toast.success('Network scan started — check Scan History for results') setActiveView('history') onScan() } catch { toast.error('Failed to trigger scan') } }, [onScan]) return ( ) } function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState(null) const { addNode, scanEventTs } = useCanvasStore() 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) } }, []) useEffect(() => { load() }, [load]) useEffect(() => { if (scanEventTs > 0) load() }, [scanEventTs, load]) const handleApprove = async (device: PendingDevice) => { try { const nodeData = { label: device.hostname ?? device.ip, type: (device.suggested_type ?? 'generic') as import('@/types').NodeType, ip: device.ip, 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 }, }) toast.success(`Approved ${nodeData.label}`) setDevices((prev) => prev.filter((d) => d.id !== device.id)) 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 ( <>
Pending
{loading && } {!loading && devices.length === 0 && (

No pending devices

)} {devices.map((d) => { const COMMON_PORTS = new Set([22, 80, 443]) const namedService = d.services.find((s) => s.category != 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 = titleService?.service_name ?? d.hostname ?? d.ip const showIpBelow = 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) return ( ) })}
setSelected(null)} onApprove={handleApprove} onHide={handleHide} onIgnore={handleIgnore} /> ) } function HiddenDevicesPanel() { const [devices, setDevices] = useState([]) 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) } }, []) useState(() => { 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 (
Hidden
{loading && } {!loading && devices.length === 0 && (

No hidden devices

)} {devices.map((d) => (
{d.ip}
{d.hostname &&
{d.hostname}
}
handleIgnore(d.id)} />
))}
) } function ScanHistoryPanel() { const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(false) const prevRunsRef = useRef([]) const load = useCallback(async () => { setLoading(true) try { const res = await scanApi.runs() const next: ScanRun[] = res.data // Toast when a run transitions from running → error for (const run of next) { const prev = prevRunsRef.current.find((r) => r.id === run.id) if (prev?.status === 'running' && run.status === 'error') { toast.error(`Scan failed: ${run.error ?? 'unknown error'}`) } } prevRunsRef.current = next setRuns(next) } catch { toast.error('Failed to load scan history') } finally { setLoading(false) } }, []) // Initial load useEffect(() => { load() }, [load]) // Auto-refresh every 3s while any run is still running useEffect(() => { const hasRunning = runs.some((r) => r.status === 'running') if (!hasRunning) return const id = setInterval(load, 3000) return () => clearInterval(id) }, [runs, load]) const statusColor = (s: string) => s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e' return (
History
{loading && runs.length === 0 && } {!loading && runs.length === 0 && (

No scans yet

)} {runs.map((r) => (
{r.status} {r.status === 'running' && } {r.devices_found} found
{new Date(r.started_at).toLocaleString()}
{r.ranges.length > 0 && (
{r.ranges.join(', ')}
)} {r.error && (
{r.error}
)}
))}
) } const MAC_OUI: Record = { '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 ( {label} ) } 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 ( {label} ) } interface SidebarItemProps { icon: React.ElementType label: string collapsed: boolean active?: boolean badge?: boolean accent?: boolean onClick?: () => void } function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) { const btn = ( ) if (collapsed) { return ( {btn} {label} ) } return btn }