Fix 21 bugs: build failures, broken pages, IPC mismatches, and missing error handling

Critical fixes:
- Add esbuild dependency and fix Vite build target (safari13 → es2022)
- Fix StorageDetail always failing (null node parameter)
- Fix VNC/Terminal console stuck on 'Connecting...' (add onConnected callback)
- Fix Ctrl+Alt+Del not working (add data-vnc attribute and event listener)
- Add serde(rename_all='camelCase') to all 27 Rust structs crossing IPC
- Fix login methods returning empty connection_id (generate from URL hash)
- Fix add_connection not storing connections in HashMap
- Fix keyring_entry panicking on failure (replace expect with map_err)

High-severity fixes:
- Fix refresh_ticket reading wrong keyring entry for username
- Add onError handlers to all 23 mutations (toast notifications)
- Fix stale closure on activeConnectionId (use getState() instead)
- Fix WebSocket listener teardown race condition
- Add WebSocket reconnection retry limit (max 10 attempts)

Medium-severity fixes:
- Add Settings navigation to CommandPalette (Cmd+K)
- Disable dead Nodes/Containers sidebar items
- Wire up HardwareTab save button with warning toast
- Wire up QuickActions navigation buttons
- Add Dashboard error state (was stuck on 'Loading...')
- Add default case to App.tsx view switch
- Extract formatBytes/formatUptime/formatNetworkRate to shared utility
- Fix formatBytes negative input bug

32 files changed, 1073 insertions(+), 356 deletions(-)
This commit is contained in:
Matt
2026-07-29 19:35:37 +00:00
parent 535ba5e189
commit 9ad7fc85bd
32 changed files with 1080 additions and 363 deletions
+20 -4
View File
@@ -34,7 +34,7 @@ type View =
| { type: 'tasks' }
| { type: 'backups' }
| { type: 'storage' }
| { type: 'storage-detail'; storage: string }
| { type: 'storage-detail'; storage: string; node: string }
| { type: 'settings' }
function AppContent() {
@@ -90,7 +90,16 @@ function AppContent() {
switch (view.type) {
case 'dashboard':
return <Dashboard connectionId={activeConnectionId} />
return <Dashboard connectionId={activeConnectionId} onNavigate={(viewName) => {
switch (viewName) {
case 'dashboard': handleNavigate({ type: 'dashboard' }); break
case 'vms': handleNavigate({ type: 'vms' }); 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
@@ -114,8 +123,8 @@ function AppContent() {
return (
<StorageOverview
connectionId={activeConnectionId}
onStorageClick={(storage) =>
handleNavigate({ type: 'storage-detail', storage })
onStorageClick={(storage, node) =>
handleNavigate({ type: 'storage-detail', storage, node })
}
/>
)
@@ -124,9 +133,16 @@ function AppContent() {
<StorageDetail
connectionId={activeConnectionId}
storage={view.storage}
node={view.node}
onBack={() => handleNavigate({ type: 'storage' })}
/>
)
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>
)
}
}
+1 -8
View File
@@ -25,19 +25,12 @@ import { CreateBackupJobDialog } from './dialogs/CreateBackupJobDialog'
import { EditBackupJobDialog } from './dialogs/EditBackupJobDialog'
import { RestoreBackupDialog } from './dialogs/RestoreBackupDialog'
import type { ProxmoxBackupJob, ProxmoxBackup } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
interface BackupListProps {
connectionId: string
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatTimestamp(seconds: number): string {
if (seconds === 0) return 'N/A'
const date = new Date(seconds * 1000)
+12 -1
View File
@@ -20,6 +20,7 @@ import {
ListTodo,
Shield,
HardDrive,
Settings,
Plus,
X,
Search,
@@ -38,7 +39,8 @@ type View =
| { type: 'tasks' }
| { type: 'backups' }
| { type: 'storage' }
| { type: 'storage-detail'; storage: string }
| { type: 'storage-detail'; storage: string; node: string }
| { type: 'settings' }
type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections'
@@ -254,6 +256,15 @@ export function CommandPalette({
keywords: ['storage', 'disks', 'volumes'],
onExecute: () => onNavigate({ type: 'storage' }),
},
{
id: 'nav-settings',
label: 'Go to Settings',
icon: Settings,
category: 'navigation',
shortcut: '⌘6',
keywords: ['settings', 'preferences', 'configuration'],
onExecute: () => onNavigate({ type: 'settings' }),
},
{
id: 'nav-add-connection',
label: 'Add Connection',
+6 -2
View File
@@ -8,9 +8,10 @@ interface TerminalConsoleProps {
node: string
vmid: number
onError?: (message: string) => void
onConnected?: () => void
}
export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalConsoleProps) {
export function TerminalConsole({ connectionId, node, vmid, onError, onConnected }: TerminalConsoleProps) {
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
@@ -157,6 +158,8 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC
})
resizeObserver.observe(containerRef.current!)
onConnected?.()
return () => {
resizeObserver.disconnect()
}
@@ -172,6 +175,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC
ws.onopen = () => {
if (!cancelled) {
terminal.writeln(`\x1b[32mConnected to LXC container ${vmid} on ${node}\x1b[0m`)
onConnected?.()
}
}
@@ -242,7 +246,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC
cleanupResize?.()
cleanup()
}
}, [connectionId, node, vmid, onError, cleanup])
}, [connectionId, node, vmid, onError, onConnected, cleanup])
return (
<div
+17 -2
View File
@@ -5,11 +5,12 @@ interface VNCConsoleProps {
node: string
vmid: number
onError?: (message: string) => void
onConnected?: () => void
}
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProps) {
export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: VNCConsoleProps) {
const containerRef = useRef<HTMLDivElement>(null)
const rfbRef = useRef<unknown>(null)
const stateRef = useRef<ConnectionState>('connecting')
@@ -72,6 +73,7 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp
rfb.addEventListener('connect', () => {
if (!cancelled) {
stateRef.current = 'connected'
onConnected?.()
}
})
@@ -90,6 +92,15 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp
onError?.('VNC credentials required')
}
})
// Listen for Ctrl+Alt+Del command from parent
const onCtrlAltDel = () => {
const current = rfbRef.current as { sendCtrlAltDel?: () => void } | null
if (current?.sendCtrlAltDel) {
current.sendCtrlAltDel()
}
}
containerRef.current.addEventListener('vnc-ctrl-alt-del', onCtrlAltDel)
} catch (err) {
if (!cancelled) {
stateRef.current = 'error'
@@ -102,13 +113,17 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp
return () => {
cancelled = true
if (containerRef.current) {
containerRef.current.removeAttribute('data-vnc')
}
cleanup()
}
}, [connectionId, node, vmid, onError, cleanup])
}, [connectionId, node, vmid, onError, onConnected, cleanup])
return (
<div
ref={containerRef}
data-vnc
className="h-full w-full bg-[#404040] overflow-hidden"
/>
)
+1 -15
View File
@@ -3,27 +3,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
interface NodeHealthGridProps {
nodes: ProxmoxNode[] | undefined
vms: ProxmoxVM[] | undefined
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
if (days > 0) return `${days}d ${hours}h`
return `${hours}h`
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function getStatusColor(status: 'online' | 'offline'): string {
return status === 'online' ? 'bg-green-500' : 'bg-red-500'
}
+7 -1
View File
@@ -5,9 +5,10 @@ import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield } from 'lucide-reac
interface QuickActionsProps {
onRefresh: () => void
isRefreshing: boolean
onNavigate?: (view: string) => void
}
export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
return (
<Card>
<CardHeader>
@@ -27,6 +28,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
<Button
variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3"
onClick={() => onNavigate?.('dashboard')}
>
<Server className="h-4 w-4" />
<span className="text-xs">Nodes</span>
@@ -34,6 +36,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
<Button
variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3"
onClick={() => onNavigate?.('vms')}
>
<Box className="h-4 w-4" />
<span className="text-xs">VMs</span>
@@ -41,6 +44,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
<Button
variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3"
onClick={() => onNavigate?.('storage')}
>
<HardDrive className="h-4 w-4" />
<span className="text-xs">Storage</span>
@@ -48,6 +52,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
<Button
variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3"
onClick={() => onNavigate?.('tasks')}
>
<ListTodo className="h-4 w-4" />
<span className="text-xs">Tasks</span>
@@ -55,6 +60,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
<Button
variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3"
onClick={() => onNavigate?.('backups')}
>
<Shield className="h-4 w-4" />
<span className="text-xs">Backups</span>
+1 -8
View File
@@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { formatBytes } from '@/lib/format'
interface ResourceGaugeProps {
label: string
@@ -15,14 +16,6 @@ const iconMap = {
disk: HardDrive,
} as const
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatCores(cores: number): string {
return `${cores.toFixed(1)} cores`
}
+27 -13
View File
@@ -7,16 +7,18 @@ import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
import { NodeHealthGrid } from '@/components/dashboard/NodeHealthGrid'
import { ActivityFeed } from '@/components/dashboard/ActivityFeed'
import { QuickActions } from '@/components/dashboard/QuickActions'
import { formatBytes } from '@/lib/format'
interface DashboardProps {
connectionId: string
onNavigate?: (view: string) => void
}
export function Dashboard({ connectionId }: DashboardProps) {
export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
const queryClient = useQueryClient()
const { data: nodes, isLoading: nodesLoading } = useNodes(connectionId)
const { data: vms, isLoading: vmsLoading } = useVMs(connectionId)
const { data: tasks, isLoading: tasksLoading } = useTasks(connectionId)
const { data: nodes, isLoading: nodesLoading, error: nodesError } = useNodes(connectionId)
const { data: vms, isLoading: vmsLoading, error: vmsError } = useVMs(connectionId)
const { data: tasks, isLoading: tasksLoading, error: tasksError } = useTasks(connectionId)
const totalCPU = nodes?.reduce((acc, n) => acc + n.maxcpu, 0) ?? 0
const usedCPU = nodes?.reduce((acc, n) => acc + n.cpu * n.maxcpu, 0) ?? 0
@@ -25,14 +27,6 @@ export function Dashboard({ connectionId }: DashboardProps) {
const totalDisk = nodes?.reduce((acc, n) => acc + n.maxdisk, 0) ?? 0
const usedDisk = nodes?.reduce((acc, n) => acc + n.disk, 0) ?? 0
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
const handleRefresh = useCallback(() => {
@@ -49,6 +43,26 @@ export function Dashboard({ connectionId }: DashboardProps) {
)
}
const hasError = nodesError || vmsError || tasksError
if (hasError) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-4">
<AlertCircle className="h-12 w-12 text-destructive mx-auto" />
<div>
<h3 className="text-lg font-semibold">Failed to Load Dashboard</h3>
<p className="text-sm text-muted-foreground mt-1">
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
</p>
</div>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
)
}
return (
<div className="h-full overflow-auto p-6">
<div className="space-y-6">
@@ -152,7 +166,7 @@ export function Dashboard({ connectionId }: DashboardProps) {
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
</div>
<div>
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} />
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} onNavigate={onNavigate} />
</div>
</div>
</div>
+10 -5
View File
@@ -60,14 +60,14 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
active={activeView === 'dashboard'}
onClick={() => onNavigate?.({ type: 'dashboard' })}
/>
<SidebarItem icon={Server} label="Nodes" />
<SidebarItem icon={Server} label="Nodes" disabled />
<SidebarItem
icon={Box}
label="VMs"
active={activeView === 'vms' || activeView === 'vm-detail'}
onClick={() => onNavigate?.({ type: 'vms' })}
/>
<SidebarItem icon={Box} label="Containers" />
<SidebarItem icon={Box} label="Containers" disabled />
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
@@ -101,21 +101,26 @@ function SidebarItem({
icon: Icon,
label,
active,
disabled,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
active?: boolean
disabled?: boolean
onClick?: () => void
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
disabled
? 'opacity-50 cursor-not-allowed pointer-events-none'
: active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
)}
>
<Icon className="h-4 w-4" />
+1 -20
View File
@@ -3,32 +3,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxNode } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
interface NodeDetailProps {
node: ProxmoxNode
connectionId: string
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatUptime(seconds: number): string {
if (seconds === 0) return 'N/A'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
return parts.join(' ') || '< 1m'
}
function ResourceBar({ label, used, total, icon: Icon }: {
label: string
used: number
+1 -8
View File
@@ -1,20 +1,13 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { HardDrive, Database, Archive, Disc, Box } from 'lucide-react'
import type { ProxmoxStorage } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
interface StorageCardProps {
storage: ProxmoxStorage
onClick: () => void
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function getUsageColor(percent: number): string {
if (percent >= 90) return 'bg-red-500'
if (percent >= 70) return 'bg-yellow-500'
+4 -10
View File
@@ -3,21 +3,15 @@ import { Button } from '@/components/ui/button'
import { ArrowLeft, HardDrive, Database, Archive, Disc, Box, Clock, File } from 'lucide-react'
import { useStorageDetail, useStorageContent } from '@/hooks/useProxmox'
import type { ProxmoxStorageContent } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
interface StorageDetailProps {
connectionId: string
storage: string
node: string
onBack: () => void
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function getUsageColor(percent: number): string {
if (percent >= 90) return 'bg-red-500'
if (percent >= 70) return 'bg-yellow-500'
@@ -68,10 +62,10 @@ function formatContentItem(item: ProxmoxStorageContent): string {
return parts[parts.length - 1] || item.volid
}
export function StorageDetail({ connectionId, storage, onBack }: StorageDetailProps) {
export function StorageDetail({ connectionId, storage, node, onBack }: StorageDetailProps) {
const { data: detail, isLoading: detailLoading } = useStorageDetail(
connectionId,
null,
node,
storage
)
const { data: contentList, isLoading: contentLoading } = useStorageContent(
+2 -2
View File
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
interface StorageOverviewProps {
connectionId: string
onStorageClick?: (storage: string) => void
onStorageClick?: (storage: string, node: string) => void
}
export function StorageOverview({ connectionId, onStorageClick }: StorageOverviewProps) {
@@ -79,7 +79,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
<StorageCard
key={`${storage.node}-${storage.storage}`}
storage={storage}
onClick={() => onStorageClick?.(storage.storage)}
onClick={() => onStorageClick?.(storage.storage, storage.node)}
/>
))}
</div>
+1 -20
View File
@@ -5,32 +5,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
import { Search, Server, Box } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
interface ContainerListProps {
connectionId: string
onContainerClick?: (container: ProxmoxVM) => void
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatUptime(seconds: number): string {
if (seconds === 0) return 'N/A'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
return parts.join(' ') || '< 1m'
}
type ContainerStatusFilter = 'all' | ProxmoxVM['status']
export function ContainerList({ connectionId, onContainerClick }: ContainerListProps) {
+1 -20
View File
@@ -5,32 +5,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
import { Search, Server, Box } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
interface VMListProps {
connectionId: string
onVMClick?: (vm: ProxmoxVM) => void
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatUptime(seconds: number): string {
if (seconds === 0) return 'N/A'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
return parts.join(' ') || '< 1m'
}
type VMTypeFilter = 'all' | 'qemu' | 'lxc'
type VMStatusFilter = 'all' | ProxmoxVM['status']
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog'
import { useResizeDisk } from '@/hooks/useProxmox'
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
interface ResizeDiskDialogProps {
open: boolean
@@ -20,14 +21,6 @@ interface ResizeDiskDialogProps {
disk: ProxmoxDisk
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
export function ResizeDiskDialog({ open, onOpenChange, vm, disk }: ResizeDiskDialogProps) {
const currentSizeGB = Math.round(disk.size / (1024 * 1024 * 1024))
const [newSize, setNewSize] = useState(String(currentSizeGB))
+6
View File
@@ -39,6 +39,10 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
setErrorMessage(message)
}, [])
const handleConnected = useCallback(() => {
setStatus('connected')
}, [])
const handleDisconnect = useCallback(() => {
setStatus('idle')
setErrorMessage(null)
@@ -226,6 +230,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
node={vm.node}
vmid={vm.vmid}
onError={handleError}
onConnected={handleConnected}
/>
) : (
<TerminalConsole
@@ -233,6 +238,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
node={vm.node}
vmid={vm.vmid}
onError={handleError}
onConnected={handleConnected}
/>
)}
</>
+1 -8
View File
@@ -15,20 +15,13 @@ import { AddDiskDialog } from '@/components/vms/dialogs/AddDiskDialog'
import { ResizeDiskDialog } from '@/components/vms/dialogs/ResizeDiskDialog'
import { MoveDiskDialog } from '@/components/vms/dialogs/MoveDiskDialog'
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
interface DisksTabProps {
vm: ProxmoxVM
connectionId: string
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
export function DisksTab({ vm, connectionId }: DisksTabProps) {
const { data: disks, isLoading, error } = useDisks(connectionId, vm.node, vm.vmid)
const removeDisk = useRemoveDisk()
+4 -9
View File
@@ -11,24 +11,19 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/toast'
import { Cpu, MemoryStick, Pencil, Save, X } from 'lucide-react'
import type { ProxmoxVM } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
interface HardwareTabProps {
vm: ProxmoxVM
connectionId: string
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
export function HardwareTab({ vm }: HardwareTabProps) {
const [editOpen, setEditOpen] = useState(false)
const { addToast } = useToast()
const [formData, setFormData] = useState({
name: vm.name,
description: vm.tags ?? '',
@@ -37,7 +32,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {
})
const handleSave = () => {
// TODO: Wire up to backend save mutation
addToast('VM configuration editing is not yet implemented', 'warning')
setEditOpen(false)
}
+1 -8
View File
@@ -14,20 +14,13 @@ import { useNetworkInterfaces, useRemoveNIC } from '@/hooks/useProxmox'
import { AddNICDialog } from '@/components/vms/dialogs/AddNICDialog'
import { EditNICDialog } from '@/components/vms/dialogs/EditNICDialog'
import type { ProxmoxVM, ProxmoxNetwork } from '@/types/proxmox'
import { formatNetworkRate } from '@/lib/format'
interface NetworkTabProps {
vm: ProxmoxVM
connectionId: string
}
function formatNetworkRate(bytes: number): string {
if (bytes === 0) return '0 B/s'
const k = 1024
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
const { data: interfaces, isLoading, error } = useNetworkInterfaces(connectionId, vm.node, vm.vmid)
const removeNIC = useRemoveNIC()
+1 -28
View File
@@ -1,40 +1,13 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Cpu, MemoryStick, HardDrive, Clock, Server, Globe, Tag } from 'lucide-react'
import type { ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime, formatNetworkRate } from '@/lib/format'
interface OverviewTabProps {
vm: ProxmoxVM
connectionId: string
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatUptime(seconds: number): string {
if (seconds === 0) return 'N/A'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
return parts.join(' ') || '< 1m'
}
function formatNetworkRate(bytes: number): string {
if (bytes === 0) return '0 B/s'
const k = 1024
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function ResourceBar({ label, used, total, icon: Icon }: {
label: string
used: number
+261 -111
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '@/lib/tauri'
import { useConnectionStore } from '@/stores/connectionStore'
import { useToast } from '@/components/ui/toast'
import type { AddDiskConfig, AddNICConfig, EditNICConfig, CreateSnapshotConfig, BackupJobConfig, RestoreConfig } from '@/types/proxmox'
// Query keys
@@ -88,91 +89,127 @@ export const useClusterStatus = (connectionId: string | null) => {
// Mutations for VM lifecycle
export const useStartVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
api.startVM(activeConnectionId!, node, vmid),
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.startVM(connId, node, vmid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to start VM', 'error')
},
})
}
export const useStopVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
api.stopVM(activeConnectionId!, node, vmid),
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.stopVM(connId, node, vmid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to stop VM', 'error')
},
})
}
export const useShutdownVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
api.shutdownVM(activeConnectionId!, node, vmid),
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.shutdownVM(connId, node, vmid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to shutdown VM', 'error')
},
})
}
export const useRebootVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
api.rebootVM(activeConnectionId!, node, vmid),
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.rebootVM(connId, node, vmid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to reboot VM', 'error')
},
})
}
export const useSuspendVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
api.suspendVM(activeConnectionId!, node, vmid),
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.suspendVM(connId, node, vmid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to suspend VM', 'error')
},
})
}
export const useResumeVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
api.resumeVM(activeConnectionId!, node, vmid),
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.resumeVM(connId, node, vmid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to resume VM', 'error')
},
})
}
@@ -187,7 +224,7 @@ export const useDisks = (connectionId: string, node: string, vmid: number) => {
export const useAddDisk = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -198,20 +235,27 @@ export const useAddDisk = () => {
node: string
vmid: number
config: AddDiskConfig
}) => api.addDisk(activeConnectionId!, node, vmid, config),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.addDisk(connId, node, vmid, config)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to add disk', 'error')
},
})
}
export const useResizeDisk = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -224,20 +268,27 @@ export const useResizeDisk = () => {
vmid: number
disk: string
size: number
}) => api.resizeDisk(activeConnectionId!, node, vmid, disk, size),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.resizeDisk(connId, node, vmid, disk, size)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to resize disk', 'error')
},
})
}
export const useRemoveDisk = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -248,20 +299,27 @@ export const useRemoveDisk = () => {
node: string
vmid: number
disk: string
}) => api.removeDisk(activeConnectionId!, node, vmid, disk),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.removeDisk(connId, node, vmid, disk)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to remove disk', 'error')
},
})
}
export const useMoveDisk = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -274,14 +332,21 @@ export const useMoveDisk = () => {
vmid: number
disk: string
storage: string
}) => api.moveDisk(activeConnectionId!, node, vmid, disk, storage),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.moveDisk(connId, node, vmid, disk, storage)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to move disk', 'error')
},
})
}
@@ -296,7 +361,7 @@ export const useNetworkInterfaces = (connectionId: string, node: string, vmid: n
export const useAddNIC = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -307,20 +372,27 @@ export const useAddNIC = () => {
node: string
vmid: number
config: AddNICConfig
}) => api.addNIC(activeConnectionId!, node, vmid, config),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.addNIC(connId, node, vmid, config)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.networks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to add NIC', 'error')
},
})
}
export const useEditNIC = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -333,20 +405,27 @@ export const useEditNIC = () => {
vmid: number
nic: string
config: EditNICConfig
}) => api.editNIC(activeConnectionId!, node, vmid, nic, config),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.editNIC(connId, node, vmid, nic, config)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.networks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to edit NIC', 'error')
},
})
}
export const useRemoveNIC = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -357,14 +436,21 @@ export const useRemoveNIC = () => {
node: string
vmid: number
nic: string
}) => api.removeNIC(activeConnectionId!, node, vmid, nic),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.removeNIC(connId, node, vmid, nic)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.networks(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to remove NIC', 'error')
},
})
}
@@ -379,7 +465,7 @@ export const useSnapshots = (connectionId: string, node: string, vmid: number) =
export const useCreateSnapshot = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -390,20 +476,27 @@ export const useCreateSnapshot = () => {
node: string
vmid: number
config: CreateSnapshotConfig
}) => api.createSnapshot(activeConnectionId!, node, vmid, config),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.createSnapshot(connId, node, vmid, config)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.snapshots(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to create snapshot', 'error')
},
})
}
export const useDeleteSnapshot = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -414,20 +507,27 @@ export const useDeleteSnapshot = () => {
node: string
vmid: number
name: string
}) => api.deleteSnapshot(activeConnectionId!, node, vmid, name),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.deleteSnapshot(connId, node, vmid, name)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.snapshots(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to delete snapshot', 'error')
},
})
}
export const useRollbackSnapshot = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -438,21 +538,28 @@ export const useRollbackSnapshot = () => {
node: string
vmid: number
name: string
}) => api.rollbackSnapshot(activeConnectionId!, node, vmid, name),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.rollbackSnapshot(connId, node, vmid, name)
},
onSuccess: (_data, variables) => {
if (activeConnectionId) {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.snapshots(activeConnectionId, variables.node, variables.vmid),
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
})
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to rollback snapshot', 'error')
},
})
}
// VM migration hooks
export const useMigrateVM = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({
@@ -465,12 +572,19 @@ export const useMigrateVM = () => {
vmid: number
targetNode: string
online: boolean
}) => api.migrateVM(activeConnectionId!, node, vmid, targetNode, online),
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.migrateVM(connId, node, vmid, targetNode, online)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to migrate VM', 'error')
},
})
}
@@ -512,91 +626,127 @@ export const useBackups = (connectionId: string | null, storage?: string) => {
export const useCreateBackupJob = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ config }: { config: BackupJobConfig }) =>
api.createBackupJob(activeConnectionId!, config),
mutationFn: ({ config }: { config: BackupJobConfig }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.createBackupJob(connId, config)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to create backup job', 'error')
},
})
}
export const useUpdateBackupJob = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ id, config }: { id: string; config: BackupJobConfig }) =>
api.updateBackupJob(activeConnectionId!, id, config),
mutationFn: ({ id, config }: { id: string; config: BackupJobConfig }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.updateBackupJob(connId, id, config)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to update backup job', 'error')
},
})
}
export const useDeleteBackupJob = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ id }: { id: string }) =>
api.deleteBackupJob(activeConnectionId!, id),
mutationFn: ({ id }: { id: string }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.deleteBackupJob(connId, id)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to delete backup job', 'error')
},
})
}
export const useRunBackup = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ config }: { config: BackupJobConfig }) =>
api.runBackup(activeConnectionId!, config),
mutationFn: ({ config }: { config: BackupJobConfig }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.runBackup(connId, config)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to run backup', 'error')
},
})
}
export const useRestoreBackup = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ volid, config }: { volid: string; config: RestoreConfig }) =>
api.restoreBackup(activeConnectionId!, volid, config),
mutationFn: ({ volid, config }: { volid: string; config: RestoreConfig }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.restoreBackup(connId, volid, config)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to restore backup', 'error')
},
})
}
export const useDeleteBackup = () => {
const queryClient = useQueryClient()
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const { addToast } = useToast()
return useMutation({
mutationFn: ({ volid }: { volid: string }) =>
api.deleteBackup(activeConnectionId!, volid),
mutationFn: ({ volid }: { volid: string }) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.deleteBackup(connId, volid)
},
onSuccess: () => {
if (activeConnectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backups(activeConnectionId) })
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backups(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to delete backup', 'error')
},
})
}
+8 -4
View File
@@ -40,6 +40,7 @@ export const useWebSocket = (connectionId: string | null): UseWebSocketReturn =>
useEffect(() => {
if (!connectionId) return
let cancelled = false
let unlistenFns: Array<() => void> = []
const setupListeners = async () => {
@@ -51,7 +52,7 @@ export const useWebSocket = (connectionId: string | null): UseWebSocketReturn =>
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
}
})
unlistenFns.push(unlistenTask)
if (!cancelled) unlistenFns.push(unlistenTask)
const unlistenNode = await listen('node-status-change', (event: { payload: { connection_id: string } }) => {
if (event.payload.connection_id === connectionId) {
@@ -59,19 +60,22 @@ export const useWebSocket = (connectionId: string | null): UseWebSocketReturn =>
queryClient.invalidateQueries({ queryKey: queryKeys.cluster(connectionId) })
}
})
unlistenFns.push(unlistenNode)
if (!cancelled) unlistenFns.push(unlistenNode)
const unlistenVM = await listen('vm-status-change', (event: { payload: { connection_id: string } }) => {
if (event.payload.connection_id === connectionId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
}
})
unlistenFns.push(unlistenVM)
if (!cancelled) unlistenFns.push(unlistenVM)
}
setupListeners()
setupListeners().catch((err) => {
console.error('[useWebSocket] Failed to setup listeners:', err)
})
return () => {
cancelled = true
unlistenFns.forEach((fn) => fn())
unlistenFns = []
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Format bytes into human-readable string (e.g., "1.5 GB").
* Handles negative values by clamping to 0.
*/
export function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
/**
* Format a Proxmox uptime (seconds) into a human-readable string.
*/
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
/**
* Format network rate (bytes/s) into human-readable string.
*/
export function formatNetworkRate(bytesPerSec: number): string {
if (bytesPerSec === 0) return '0 B/s'
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let i = 0
let val = bytesPerSec
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return `${val.toFixed(1)} ${units[i]}`
}