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 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' import { scanApi, settingsApi } from '@/api/client' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings' 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 forceView?: SidebarView highlightPendingId?: string } export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) { const [_collapsed, setCollapsed] = useState(false) const [_activeView, setActiveView] = useState('canvas') const logout = useAuthStore((s) => s.logout) // When forceView is set, override local state without useEffect const collapsed = forceView ? false : _collapsed const activeView = forceView ?? _activeView const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = 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(() => { onScan() }, [onScan]) return ( ) } const COMMON_PORTS = new Set([22, 80, 443]) function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState(null) const [checkedIds, setCheckedIds] = useState>(new Set()) const { addNode, scanEventTs } = useCanvasStore() const highlightRef = useRef(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 = {} 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 import('@/types').NodeType, position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, data: { label: d.hostname ?? d.ip, type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, ip: d.ip, hostname: d.hostname ?? undefined, status: 'unknown' as const, services: (d.services ?? []) as import('@/types').ServiceInfo[], }, }) onNodeApproved(nodeId) }) setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) setCheckedIds(new Set()) toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`) } 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 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)) 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 ( <>
{devices.length > 0 && ( { if (el) el.indeterminate = someChecked && !allChecked }} onChange={toggleAll} className="w-3 h-3 accent-[#00d4ff] cursor-pointer" title="Select all" /> )} Pending
{devices.length > 0 && ( )}
{someChecked && (
)} {loading && } {!loading && devices.length === 0 && (

No pending devices

)} {devices.map((d) => { 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 = 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) const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e' const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null const isHighlighted = d.id === highlightId 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) } }, []) 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 (
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 [stopping, setStopping] = useState(null) const handleStop = async (runId: string) => { setStopping(runId) try { await scanApi.stop(runId) toast.success('Scan stop requested') } catch { toast.error('Failed to stop scan') } finally { setStopping(null) } } const statusColor = (s: string) => s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : s === 'cancelled' ? '#8b949e' : '#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 {r.status === 'running' && ( Stop scan )}
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
{r.ranges.length > 0 && (
{r.ranges.join(', ')}
)} {r.error && (
{r.error}
)}
))}
) } function SettingsPanel() { const [interval, setIntervalValue] = useState(60) const [saving, setSaving] = useState(false) useEffect(() => { settingsApi.get() .then((res) => setIntervalValue(res.data.interval_seconds)) .catch(() => {/* use default */}) }, []) const handleSave = async () => { setSaving(true) try { await settingsApi.save({ interval_seconds: interval }) toast.success('Settings saved') } catch { toast.error('Failed to save settings') } finally { setSaving(false) } } return (
Settings
{ const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }} className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]" /> seconds

How often node health is polled (ping, HTTP, SSH…)

) } function VersionBadge() { const current = __APP_VERSION__ const { latest, hasUpdate } = useLatestRelease(current) return ( ) } 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 }