Files
clustri/src/App.tsx
T

434 lines
16 KiB
TypeScript
Raw Normal View History

2026-07-29 13:47:45 +00:00
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'
2026-07-29 13:47:45 +00:00
import { TaskList } from '@/components/tasks/TaskList'
import { TaskStatusBar } from '@/components/tasks/TaskStatusBar'
2026-07-29 13:47:45 +00:00
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'
2026-07-29 13:47:45 +00:00
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'
2026-07-29 13:47:45 +00:00
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'
2026-07-29 13:47:45 +00:00
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' }
2026-07-29 13:47:45 +00:00
| { 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 }
2026-07-29 13:47:45 +00:00
| { 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()
2026-07-29 13:47:45 +00:00
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false)
const [view, setView] = useState<View>({ 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())
2026-07-29 13:47:45 +00:00
// 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<string>('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])
2026-07-29 13:47:45 +00:00
// 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])
2026-07-29 13:47:45 +00:00
// 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 <SettingsPage />
}
if (!activeConnectionId) {
return (
<div className="flex h-full items-center justify-center p-6 dot-grid">
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-border bg-card p-8 shadow-card">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-5 w-5 text-primary" />
</div>
<DotMatrixText text="NO CONNECTION" size="sm" className="text-foreground" />
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Connect to a Proxmox server to get started
2026-07-29 13:47:45 +00:00
</p>
</div>
</div>
)
}
switch (view.type) {
case 'dashboard':
return <Dashboard connectionId={activeConnectionId} onNavigate={(viewName) => {
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
}
}} />
2026-07-29 13:47:45 +00:00
case 'vms':
return (
<VMList
connectionId={activeConnectionId}
onVMClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
/>
)
case 'vm-detail':
return (
<VMDetail
vm={view.vm}
connectionId={activeConnectionId}
onBack={() => handleNavigate({ type: 'vms' })}
/>
)
case 'nodes':
return (
<NodesPage
connectionId={activeConnectionId}
onVMClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
/>
)
case 'node-detail':
return (
<NodesPage
connectionId={activeConnectionId}
initialNodeName={view.nodeName}
onVMClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
/>
)
case 'containers':
return (
<ContainerList
connectionId={activeConnectionId}
onContainerClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
/>
)
2026-07-29 13:47:45 +00:00
case 'tasks':
return <TaskList connectionId={activeConnectionId} />
case 'backups':
return <BackupList connectionId={activeConnectionId} />
case 'storage':
return (
<StorageOverview
connectionId={activeConnectionId}
onStorageClick={(storage, node) =>
handleNavigate({ type: 'storage-detail', storage, node })
2026-07-29 13:47:45 +00:00
}
/>
)
case 'storage-detail':
return (
<StorageDetail
connectionId={activeConnectionId}
storage={view.storage}
node={view.node}
2026-07-29 13:47:45 +00:00
onBack={() => handleNavigate({ type: 'storage' })}
/>
)
case 'pbs-overview':
return <PbsOverview connectionId={activeConnectionId} />
case 'pbs-datastores':
return (
<PbsDatastores
connectionId={activeConnectionId}
onDatastoreClick={(store) => handleNavigate({ type: 'pbs-datastore-detail', store })}
/>
)
case 'pbs-datastore-detail':
return (
<PbsDatastoreDetail
connectionId={activeConnectionId}
store={view.store}
onBack={() => handleNavigate({ type: 'pbs-datastores' })}
/>
)
default:
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Unknown view: {(view as { type: string }).type}</p>
</div>
)
2026-07-29 13:47:45 +00:00
}
}
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])
2026-07-29 13:47:45 +00:00
return (
<div className="flex h-screen flex-col bg-background dot-grid">
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
<div className="flex items-center gap-2 border-b border-dotted border-warning/30 bg-warning/10 px-4 py-1.5 dot-grid">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
<DotMatrixText text="FALLBACK NODE" size="xs" className="text-warning" />
<p className="truncate font-mono text-[11px] tabular-nums text-warning/80">
{activeConnection.currentEndpointUrl}
</p>
</div>
)}
<div className="flex min-h-0 flex-1">
<Sidebar
onAddConnection={() => setConnectionDialogOpen(true)}
activeView={view.type}
onNavigate={(v) => handleNavigate(v as View)}
/>
<main className="flex min-h-0 flex-1 flex-col bg-background/60">
{activeConnectionId && (
<div className="shrink-0 border-b border-dotted border-border px-3 py-1.5 dot-grid">
<TaskStatusBar
connectionId={activeConnectionId}
onClick={() => handleNavigate({ type: 'tasks' })}
/>
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{renderMainContent()}
</div>
</main>
</div>
2026-07-29 13:47:45 +00:00
<ConnectionDialog
open={connectionDialogOpen}
onOpenChange={setConnectionDialogOpen}
/>
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
onNavigate={handleNavigate}
onAddConnection={() => setConnectionDialogOpen(true)}
/>
</div>
)
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ErrorBoundary>
<AppContent />
</ErrorBoundary>
</ToastProvider>
</QueryClientProvider>
)
}
export default App