import { useState, useCallback, useEffect, useRef } from 'react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } 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 { type AlignmentSettings, readAlignmentSettings, writeAlignmentSettings, subscribeAlignmentSettings, } from '@/utils/alignmentSettings' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' type SidebarView = 'canvas' | 'history' | 'settings' const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ { kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, { kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' }, ] interface ScanRun { id: string status: string kind?: string ranges: string[] devices_found: number started_at: string finished_at: string | null error: string | null } interface SidebarProps { onAddNode: () => void onAddGroupRect: () => void onAddText: () => void onScan: () => void onZigbeeImport: () => void onSave: () => void forceView?: SidebarView onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void } export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) const logout = useAuthStore((s) => s.logout) // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. if (forceView !== prevForceView) { setPrevForceView(forceView) if (forceView) { setActiveView(forceView) setCollapsed(false) } } const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') 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 ( ) } 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 // Surface transitions and refresh dependent UI 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'}`) } if (prev?.status === 'running' && run.status === 'done') { if (run.kind === 'zigbee') { toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`) } // Notify pending modal/canvas to refresh useCanvasStore.getState().notifyScanDeviceFound() } } 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.kind === 'zigbee' ? 'ZIG' : 'IP'} {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) const [alignment, setAlignment] = useState(readAlignmentSettings) useEffect(() => { settingsApi.get() .then((res) => setIntervalValue(res.data.interval_seconds)) .catch(() => {/* use default */}) }, []) useEffect(() => subscribeAlignmentSettings(setAlignment), []) const updateAlignment = (patch: Partial) => { const next = { ...alignment, ...patch } setAlignment(next) writeAlignmentSettings(next) } 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…)

Canvas
updateAlignment({ threshold: Number(e.target.value) })} className="flex-1 cursor-pointer accent-[#00d4ff]" aria-label="Alignment snap threshold" /> {alignment.threshold}px

Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.

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