import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Sidebar } from '@/components/layout/Sidebar' import { Dashboard } from '@/components/layout/Dashboard' import { ConnectionDialog } from '@/components/connections/ConnectionDialog' import { VMList } from '@/components/vms/VMList' import { VMDetail } from '@/components/vms/VMDetail' import { ContainerList } from '@/components/vms/ContainerList' import { NodesPage } from '@/components/nodes/NodesPage' import { TaskList } from '@/components/tasks/TaskList' import { TaskStatusBar } from '@/components/tasks/TaskStatusBar' import { BackupList } from '@/components/backups/BackupList' import { CommandPalette } from '@/components/command/CommandPalette' import { StorageOverview } from '@/components/storage/StorageOverview' import { StorageDetail } from '@/components/storage/StorageDetail' import { PbsOverview } from '@/components/pbs/PbsOverview' import { PbsDatastores } from '@/components/pbs/PbsDatastores' import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail' import { SettingsPage } from '@/components/settings/SettingsPage' import { ErrorBoundary } from '@/components/ErrorBoundary' import { ToastProvider, useToast } from '@/components/ui/toast' import { DotMatrixText } from '@/components/ui/dot-matrix' import { Server, AlertTriangle } from 'lucide-react' import { useConnectionStore } from '@/stores/connectionStore' import { useUIStore } from '@/stores/uiStore' import { useWebSocket } from '@/hooks/useWebSocket' import { isTauri, loadConnections, connectToServer, getConnectionStatus, getWebSocketURL, updateTrayMenu } from '@/lib/tauri' import { useEffect, useMemo, useState } from 'react' import type { ProxmoxVM } from '@/types/proxmox' const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, }, }, }) type View = | { type: 'dashboard' } | { type: 'vms' } | { type: 'vm-detail'; vm: ProxmoxVM } | { type: 'nodes' } | { type: 'node-detail'; nodeName: string } | { type: 'containers' } | { type: 'tasks' } | { type: 'backups' } | { type: 'storage' } | { type: 'storage-detail'; storage: string; node: string } | { type: 'pbs-overview' } | { type: 'pbs-datastores' } | { type: 'pbs-datastore-detail'; store: string } | { type: 'settings' } function AppContent() { const activeConnectionId = useConnectionStore((s) => s.activeConnectionId) const connections = useConnectionStore((s) => s.connections) const hydrate = useConnectionStore((s) => s.hydrate) const setActiveConnection = useConnectionStore((s) => s.setActiveConnection) const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus) const updateConnection = useConnectionStore((s) => s.updateConnection) const removeConnection = useConnectionStore((s) => s.removeConnection) const setAuthStatus = useConnectionStore((s) => s.setAuthStatus) const { addToast } = useToast() const [connectionDialogOpen, setConnectionDialogOpen] = useState(false) const [view, setView] = useState({ type: 'dashboard' }) const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen) const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen) // In browser mock mode there is nothing to load, so the app is ready // immediately. In Tauri mode the persisted connections load on mount. const [connectionsLoaded, setConnectionsLoaded] = useState(!isTauri()) // Startup: load persisted connections and auto-reconnect the active one. // React StrictMode mounts this effect twice in development (setup, cleanup, // setup): the first boot is cancelled by the cleanup, and the second setup // runs the load again and completes it. Guarding against a double run with // a ref would cancel the first boot and suppress the second, so the // persisted connections would never hydrate. useEffect(() => { if (!isTauri()) return let cancelled = false const boot = async () => { try { const { connections: loaded, activeConnectionId } = await loadConnections() if (cancelled) return hydrate(loaded, activeConnectionId) if (activeConnectionId && loaded.some((c) => c.id === activeConnectionId)) { setActiveConnection(activeConnectionId) setConnectionStatus(activeConnectionId, 'connecting') try { const result = await connectToServer(activeConnectionId) if (cancelled) return if (result.mergedInto && result.mergedInto !== activeConnectionId) { // This connection belongs to a cluster we already have; the // backend folded it into the surviving connection. removeConnection(activeConnectionId) setActiveConnection(result.mergedInto) setConnectionStatus(result.mergedInto, result.status) setAuthStatus('authenticated') addToast('Already connected to this cluster — added as a failover endpoint.', 'success') } else { setConnectionStatus(result.connectionId, result.status) } } catch (err) { if (!cancelled) { setConnectionStatus(activeConnectionId, 'failed') console.error('[App] Auto-reconnect failed:', err) } } } } catch (err) { console.error('[App] Failed to load connections:', err) } finally { if (!cancelled) setConnectionsLoaded(true) } } boot() return () => { cancelled = true } }, [hydrate, setActiveConnection, setConnectionStatus, setAuthStatus, removeConnection, addToast]) // Keep the system tray menu in sync with the connection list (no-op in browser mode) useEffect(() => { updateTrayMenu( connections.map((c) => ({ id: c.id, name: c.name, status: c.status })), ) }, [connections]) // Tray connection clicks switch the active connection (mirrored to the // backend by the store so it persists across launches). useEffect(() => { if (!isTauri()) return let unlisten: (() => void) | undefined import('@tauri-apps/api/event') .then(async ({ listen }) => { unlisten = await listen('tray-connection-click', (event) => { setActiveConnection(event.payload) }) }) .catch(() => {}) return () => { unlisten?.() } }, [setActiveConnection]) // Poll the backend connection status while a connection is active so the // sidebar node list and failover state stay in sync with the cluster. useEffect(() => { if (!isTauri() || !activeConnectionId) return let cancelled = false const poll = async () => { try { const info = await getConnectionStatus(activeConnectionId) if (cancelled) return setConnectionStatus(activeConnectionId, info.status) updateConnection(activeConnectionId, { nodes: info.nodes, currentEndpointUrl: info.currentEndpointUrl, }) } catch { // Transient failures are ignored; the next tick keeps polling. } } poll() const interval = setInterval(poll, 10_000) return () => { cancelled = true clearInterval(interval) } }, [activeConnectionId, setConnectionStatus, updateConnection]) // Auto-open login dialog only when there are genuinely zero connections useEffect(() => { if (connectionsLoaded && connections.length === 0 && !connectionDialogOpen) { setConnectionDialogOpen(true) } }, [connectionsLoaded, connections.length, connectionDialogOpen]) // WebSocket integration – connects when a connection is active const { connect: connectRelay, disconnect: disconnectRelay } = useWebSocket(activeConnectionId) // Start the backend event relay once a connection is connected, so task and // node events invalidate queries in near real-time. Polling remains the // fallback when the relay is not connected. const activeStatus = useMemo( () => connections.find((c) => c.id === activeConnectionId)?.status, [connections, activeConnectionId], ) useEffect(() => { if (!isTauri() || !activeConnectionId) return if (activeStatus !== 'connected' && activeStatus !== 'failover') { disconnectRelay().catch(() => {}) return } let cancelled = false const startRelay = async () => { try { const origin = await getWebSocketURL(activeConnectionId, '') if (cancelled) return await connectRelay(`${origin}/api2/json/events`) } catch (err) { console.error('[App] Failed to start event relay:', err) } } startRelay() return () => { cancelled = true } }, [activeConnectionId, activeStatus, connectRelay, disconnectRelay]) // Global keyboard shortcut: Cmd/Ctrl+K to open command palette useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault() setCommandPaletteOpen(!commandPaletteOpen) } } window.addEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown) }, [commandPaletteOpen, setCommandPaletteOpen]) const handleNavigate = (newView: View) => { setView(newView) } const renderMainContent = () => { if (view.type === 'settings') { return } if (!activeConnectionId) { return (

Connect to a Proxmox server to get started

) } switch (view.type) { case 'dashboard': return { switch (viewName) { case 'dashboard': handleNavigate({ type: 'dashboard' }); break case 'vms': handleNavigate({ type: 'vms' }); break case 'nodes': handleNavigate({ type: 'nodes' }); break case 'tasks': handleNavigate({ type: 'tasks' }); break case 'backups': handleNavigate({ type: 'backups' }); break case 'storage': handleNavigate({ type: 'storage' }); break default: break } }} /> case 'vms': return ( handleNavigate({ type: 'vm-detail', vm })} /> ) case 'vm-detail': return ( handleNavigate({ type: 'vms' })} /> ) case 'nodes': return ( handleNavigate({ type: 'vm-detail', vm })} /> ) case 'node-detail': return ( handleNavigate({ type: 'vm-detail', vm })} /> ) case 'containers': return ( handleNavigate({ type: 'vm-detail', vm })} /> ) case 'tasks': return case 'backups': return case 'storage': return ( handleNavigate({ type: 'storage-detail', storage, node }) } /> ) case 'storage-detail': return ( handleNavigate({ type: 'storage' })} /> ) case 'pbs-overview': return case 'pbs-datastores': return ( handleNavigate({ type: 'pbs-datastore-detail', store })} /> ) case 'pbs-datastore-detail': return ( handleNavigate({ type: 'pbs-datastores' })} /> ) default: return (

Unknown view: {(view as { type: string }).type}

) } } const activeConnection = connections.find((c) => c.id === activeConnectionId) // When the active connection switches to a PBS server, leave any VE-only // view behind and land on the PBS overview. Shared views (tasks, settings) // and PBS views are left untouched. const activeServerType = activeConnection?.serverType ?? 'pve' useEffect(() => { if (activeServerType !== 'pbs') return const veOnlyViews = new Set([ 'dashboard', 'vms', 'vm-detail', 'nodes', 'node-detail', 'containers', 'backups', 'storage', 'storage-detail', ]) if (veOnlyViews.has(view.type)) { setView({ type: 'pbs-overview' }) } }, [activeConnectionId, activeServerType, view.type]) return (
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (

{activeConnection.currentEndpointUrl}

)}
setConnectionDialogOpen(true)} activeView={view.type} onNavigate={(v) => handleNavigate(v as View)} />
{activeConnectionId && (
handleNavigate({ type: 'tasks' })} />
)}
{renderMainContent()}
setConnectionDialogOpen(true)} />
) } function App() { return ( ) } export default App