434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
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<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())
|
||
|
||
// 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])
|
||
|
||
// 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 <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
|
||
</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
|
||
}
|
||
}} />
|
||
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 })}
|
||
/>
|
||
)
|
||
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 })
|
||
}
|
||
/>
|
||
)
|
||
case 'storage-detail':
|
||
return (
|
||
<StorageDetail
|
||
connectionId={activeConnectionId}
|
||
storage={view.storage}
|
||
node={view.node}
|
||
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>
|
||
)
|
||
}
|
||
}
|
||
|
||
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 (
|
||
<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>
|
||
<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
|