Files
clustri/src/App.tsx
T
Matt 039ac6f9d3 feat: add PBS datastore management and harden macOS keychain storage
Add Proxmox Backup Server datastore management: overview, datastore detail, download/prune/verify/GC dialogs, usePbs hook, backend commands, and tests.

Fix macOS keychain re-writes failing with 'item already exists': replace keyring 3 with keyring-core plus per-platform stores (macOS Keychain, Windows Credential Manager, Linux keyutils), recover by deleting the stale item and retrying once, and surface actionable messages for locked keychains.
2026-08-13 01:02:33 +00:00

434 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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, useRef, 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())
const bootRan = useRef(false)
// Startup: load persisted connections and auto-reconnect the active one
useEffect(() => {
if (bootRan.current) return
bootRan.current = true
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">
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-card shadow-card">
<Server className="h-5 w-5 text-primary" />
</div>
<h2 className="text-lg font-semibold tracking-tight">
No connection selected
</h2>
<p className="mt-1 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">
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
<div className="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
<p className="text-xs font-medium text-warning">Operating on a fallback node</p>
<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">
{activeConnectionId && (
<div className="shrink-0 border-b px-3 py-1.5">
<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