feat: add marketing site and expand Proxmox management features

This commit is contained in:
Matt
2026-08-10 14:33:06 +00:00
parent 27b9aeb45e
commit 20dd2dc449
70 changed files with 12092 additions and 1282 deletions
+70 -6
View File
@@ -7,6 +7,7 @@ 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'
@@ -18,8 +19,8 @@ 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, updateTrayMenu } from '@/lib/tauri'
import { useEffect, useRef, useState } from 'react'
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({
@@ -121,6 +122,23 @@ function AppContent() {
)
}, [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(() => {
@@ -157,7 +175,36 @@ function AppContent() {
}, [connectionsLoaded, connections.length, connectionDialogOpen])
// WebSocket integration connects when a connection is active
useWebSocket(activeConnectionId)
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(() => {
@@ -204,6 +251,7 @@ function AppContent() {
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
@@ -226,12 +274,18 @@ function AppContent() {
/>
)
case 'nodes':
return <NodesPage connectionId={activeConnectionId} />
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':
@@ -291,8 +345,18 @@ function AppContent() {
activeView={view.type}
onNavigate={(v) => handleNavigate(v as View)}
/>
<main className="flex-1 overflow-hidden">
{renderMainContent()}
<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
+19 -3
View File
@@ -42,7 +42,6 @@ function formatTimestamp(seconds: number): string {
export function BackupList({ connectionId }: BackupListProps) {
const { data: backupJobs, isLoading: jobsLoading, error: jobsError } = useBackupJobs(connectionId)
const { data: backups, isLoading: backupsLoading, error: backupsError } = useBackups(connectionId)
const { data: storage } = useStorage(connectionId)
const deleteBackupJob = useDeleteBackupJob()
@@ -56,6 +55,13 @@ export function BackupList({ connectionId }: BackupListProps) {
const [confirmDeleteJob, setConfirmDeleteJob] = useState<string | null>(null)
const [confirmDeleteBackup, setConfirmDeleteBackup] = useState<string | null>(null)
// Backups are storage-scoped on the backend, so the active filter must be
// passed through or only the default storage would ever be listed.
const { data: backups, isLoading: backupsLoading, error: backupsError } = useBackups(
connectionId,
storageFilter === 'all' ? undefined : storageFilter,
)
const storageOptions = useMemo(() => {
if (!storage) return []
return [...new Set(storage.map((s) => s.storage))].sort()
@@ -363,7 +369,12 @@ export function BackupList({ connectionId }: BackupListProps) {
confirmLabel="Delete"
isLoading={deleteBackupJob.isPending}
onConfirm={() => {
if (confirmDeleteJob) deleteBackupJob.mutate({ id: confirmDeleteJob })
if (confirmDeleteJob) {
deleteBackupJob.mutate(
{ id: confirmDeleteJob },
{ onSettled: () => setConfirmDeleteJob(null) },
)
}
}}
/>
@@ -377,7 +388,12 @@ export function BackupList({ connectionId }: BackupListProps) {
confirmLabel="Delete"
isLoading={deleteBackup.isPending}
onConfirm={() => {
if (confirmDeleteBackup) deleteBackup.mutate({ volid: confirmDeleteBackup })
if (confirmDeleteBackup) {
deleteBackup.mutate(
{ volid: confirmDeleteBackup },
{ onSettled: () => setConfirmDeleteBackup(null) },
)
}
}}
/>
</div>
+1 -1
View File
@@ -321,7 +321,7 @@ export function CommandPalette({
},
)
}
if (vm.status === 'stopped' || vm.status === 'paused') {
if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
items.push({
id: `action-start-${vm.vmid}`,
label: `Start ${vm.name}`,
+18 -31
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
import { isTauri, startConsoleProxy, stopConsoleProxy } from '@/lib/tauri'
interface TerminalConsoleProps {
connectionId: string
@@ -16,8 +17,14 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
const termRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const sessionRef = useRef<string | null>(null)
const cleanup = useCallback(() => {
if (sessionRef.current) {
const sid = sessionRef.current
sessionRef.current = null
stopConsoleProxy(sid).catch(() => {})
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
@@ -64,40 +71,22 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
return
}
// Get WebSocket URL for terminal proxy
// Resolve the WebSocket URL: in Tauri mode a Rust-side loopback proxy
// bridges the self-signed Proxmox WebSocket; in dev mode there is a
// mock shell below so the URL is unused.
let wsUrl: string
try {
const { isTauri, createTermProxy, getWebSocketURL } = await import('@/lib/tauri')
if (isTauri()) {
const [proxyInfo, baseUrl] = await Promise.all([
createTermProxy(connectionId, node, vmid),
getWebSocketURL(connectionId, node),
])
wsUrl = `${baseUrl}/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=${proxyInfo.port}&ticket=${encodeURIComponent(proxyInfo.ticket)}`
} else {
// Dev mode: construct a mock URL
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
}
} catch {
// Fallback for dev mode
if (isTauri()) {
const info = await startConsoleProxy(connectionId, 'term', node, vmid)
if (cancelled) return
sessionRef.current = info.sessionId
wsUrl = info.url
} else {
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
}
if (cancelled) return
// In dev mode, show a mock terminal since we can't connect to real WebSocket
let isTauriMode = false
try {
const { isTauri } = await import('@/lib/tauri')
isTauriMode = isTauri()
} catch {
// not in tauri
}
if (!isTauriMode && !cancelled) {
if (!isTauri()) {
// Dev mode mock terminal
terminal.writeln('\x1b[1;32m╔══════════════════════════════════════════╗\x1b[0m')
terminal.writeln('\x1b[1;32m║ Clustri - Terminal Console ║\x1b[0m')
@@ -165,9 +154,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
}
}
if (cancelled) return
// Production: connect via WebSocket
// Production: connect via the local proxy WebSocket
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
+17 -19
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'
import { isTauri, startConsoleProxy, stopConsoleProxy } from '@/lib/tauri'
interface VNCConsoleProps {
connectionId: string
@@ -14,8 +15,14 @@ export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: V
const containerRef = useRef<HTMLDivElement>(null)
const rfbRef = useRef<unknown>(null)
const stateRef = useRef<ConnectionState>('connecting')
const sessionRef = useRef<string | null>(null)
const cleanup = useCallback(() => {
if (sessionRef.current) {
const sid = sessionRef.current
sessionRef.current = null
stopConsoleProxy(sid).catch(() => {})
}
if (rfbRef.current) {
const rfb = rfbRef.current as { disconnect: () => void }
rfb.disconnect()
@@ -32,26 +39,17 @@ export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: V
try {
stateRef.current = 'connecting'
// Get WebSocket URL for VNC
// In Tauri mode the console stream goes through a Rust-side loopback
// proxy so self-signed Proxmox certificates are accepted; the returned
// local ws:// URL is handed to noVNC.
let wsUrl: string
try {
// Try real IPC first
const { isTauri, createVNCProxy, getWebSocketURL } = await import('@/lib/tauri')
if (isTauri()) {
const [proxyInfo, baseUrl] = await Promise.all([
createVNCProxy(connectionId, node, vmid),
getWebSocketURL(connectionId, node),
])
wsUrl = `${baseUrl}/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=${proxyInfo.port}&vncticket=${encodeURIComponent(proxyInfo.ticket)}`
} else {
// Dev mode: construct a mock URL
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
}
} catch {
// Fallback for dev mode
if (isTauri()) {
const info = await startConsoleProxy(connectionId, 'vnc', node, vmid)
if (cancelled) return
sessionRef.current = info.sessionId
wsUrl = info.url
} else {
// Dev mode: construct a mock URL
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
}
@@ -1,7 +1,6 @@
import { useMemo } from 'react'
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'
import { cn } from '@/lib/utils'
@@ -25,26 +24,11 @@ function getStatusLabel(status: 'online' | 'offline'): string {
return status === 'online' ? 'Online' : 'Offline'
}
/** Generate deterministic mock sparkline data from node stats */
function generateSparklineData(baseValue: number, points: number = 12): { value: number }[] {
const data: { value: number }[] = []
for (let i = 0; i < points; i++) {
// Deterministic pseudo-random variation around the base value
const seed = Math.sin(i * 2.1 + baseValue * 0.01) * 0.3
const variation = baseValue * seed
data.push({ value: Math.max(0, Math.min(1, baseValue + variation)) })
}
return data
}
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
const cpuData = useMemo(() => generateSparklineData(cpuPercent), [cpuPercent])
const memData = useMemo(() => generateSparklineData(memPercent), [memPercent])
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
@@ -59,54 +43,6 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
</span>
</CardHeader>
<CardContent className="space-y-3">
{/* Sparklines */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="mb-1 flex items-center gap-1.5">
<Cpu className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">CPU</span>
</div>
<div className="h-8">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={cpuData}>
<Area
type="monotone"
dataKey="value"
stroke="var(--color-primary)"
fill="var(--color-primary)"
fillOpacity={0.18}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<MemoryStick className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">RAM</span>
</div>
<div className="h-8">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={memData}>
<Area
type="monotone"
dataKey="value"
stroke="var(--color-primary)"
fill="var(--color-primary)"
fillOpacity={0.18}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1.5">
+1 -1
View File
@@ -33,7 +33,7 @@ export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActio
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={() => onNavigate?.('dashboard')}
onClick={() => onNavigate?.('nodes')}
>
<Server className="h-4 w-4" />
<span className="text-xs font-medium">Nodes</span>
+16 -6
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNodes, useVMs, useTasks, queryKeys } from '@/hooks/useProxmox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -31,10 +31,20 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.nodes(connectionId) })
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connectionId) })
// Track actual refresh activity rather than piggybacking on tasksLoading,
// which is true whenever the tasks query is fetching (e.g. background
// polling) and would keep the Refresh button spinning continuously.
const [refreshing, setRefreshing] = useState(false)
const handleRefresh = useCallback(async () => {
setRefreshing(true)
try {
await queryClient.invalidateQueries({ queryKey: queryKeys.nodes(connectionId) })
await queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
await queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connectionId) })
} finally {
setRefreshing(false)
}
}, [queryClient, connectionId])
const handleRetry = useCallback(() => {
@@ -186,7 +196,7 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
</div>
<div>
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} onNavigate={onNavigate} />
<QuickActions onRefresh={handleRefresh} isRefreshing={refreshing} onNavigate={onNavigate} />
</div>
</div>
</div>
+4 -2
View File
@@ -3,12 +3,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxNode } from '@/types/proxmox'
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
interface NodeDetailProps {
node: ProxmoxNode
connectionId: string
onVMClick?: (vm: ProxmoxVM) => void
}
function ResourceBar({ label, used, total, icon: Icon }: {
@@ -47,7 +48,7 @@ function ResourceBar({ label, used, total, icon: Icon }: {
)
}
export function NodeDetail({ node, connectionId }: NodeDetailProps) {
export function NodeDetail({ node, connectionId, onVMClick }: NodeDetailProps) {
const { data: vms, isLoading: vmsLoading } = useVMs(connectionId)
const nodeVMs = vms?.filter((vm) => vm.node === node.node) ?? []
@@ -136,6 +137,7 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
<div
key={`${vm.type}-${vm.vmid}`}
className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2.5 transition-colors duration-150 hover:bg-accent/50 cursor-pointer"
onClick={() => onVMClick?.(vm)}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
+4 -2
View File
@@ -7,11 +7,13 @@ import { useNodes } from '@/hooks/useProxmox'
import { NodeDetail } from '@/components/nodes/NodeDetail'
import { formatBytes, formatUptime } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { ProxmoxVM } from '@/types/proxmox'
interface NodesPageProps {
connectionId: string
initialNodeName?: string
onNavigate?: (view: string) => void
onVMClick?: (vm: ProxmoxVM) => void
}
function nodePercent(used: number, total: number): number {
@@ -38,7 +40,7 @@ function MiniStat({
)
}
export function NodesPage({ connectionId, initialNodeName }: NodesPageProps) {
export function NodesPage({ connectionId, initialNodeName, onVMClick }: NodesPageProps) {
const { data: nodes, isLoading, error } = useNodes(connectionId)
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null)
@@ -153,7 +155,7 @@ export function NodesPage({ connectionId, initialNodeName }: NodesPageProps) {
{/* Selected node detail */}
<div className="flex-1 overflow-hidden">
{selectedNode && (
<NodeDetail node={selectedNode} connectionId={connectionId} />
<NodeDetail node={selectedNode} connectionId={connectionId} onVMClick={onVMClick} />
)}
</div>
</div>
+60 -4
View File
@@ -4,13 +4,16 @@ import { useToast } from '@/components/ui/toast'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { removeConnection as removeConnectionIPC, disconnectFromServer, logout } from '@/lib/tauri'
import { Plus, Pencil, Trash2, Unplug, LogOut } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ConnectionConfig } from '@/types/connection'
export function ConnectionManager() {
const connections = useConnectionStore((s) => s.connections)
const removeConnection = useConnectionStore((s) => s.removeConnection)
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
const setAuthStatus = useConnectionStore((s) => s.setAuthStatus)
const { addToast } = useToast()
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ConnectionConfig | null>(null)
@@ -30,11 +33,42 @@ export function ConnectionManager() {
}
}
const handleDelete = (id: string, name: string) => {
removeConnection(id)
addToast(`Connection "${name}" removed`, 'success')
const handleDelete = async (id: string, name: string) => {
try {
// Remove from the backend first so the deletion persists and the keyring
// credentials are cleaned up, then mirror it in the store.
await removeConnectionIPC(id)
removeConnection(id)
addToast(`Connection "${name}" removed`, 'success')
} catch (err) {
addToast(err instanceof Error ? err.message : 'Failed to remove connection', 'error')
}
}
const handleDisconnect = async (connection: ConnectionConfig) => {
try {
await disconnectFromServer(connection.id)
setConnectionStatus(connection.id, 'disconnected')
addToast(`Connection "${connection.name}" disconnected`, 'success')
} catch (err) {
addToast(err instanceof Error ? err.message : 'Failed to disconnect', 'error')
}
}
const handleLogout = async (connection: ConnectionConfig) => {
try {
await logout(connection.id)
setAuthStatus('unauthenticated')
setConnectionStatus(connection.id, 'disconnected')
addToast(`Logged out of "${connection.name}"`, 'success')
} catch (err) {
addToast(err instanceof Error ? err.message : 'Failed to log out', 'error')
}
}
const isActive = (connection: ConnectionConfig) =>
connection.status === 'connected' || connection.status === 'failover'
const deleteTarget = connections.find((c) => c.id === deleteConfirmId) ?? null
return (
@@ -75,6 +109,28 @@ export function ConnectionManager() {
</div>
</div>
<div className="flex items-center gap-1">
{isActive(connection) && (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
title="Log out"
onClick={() => handleLogout(connection)}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
title="Disconnect"
onClick={() => handleDisconnect(connection)}
>
<Unplug className="h-3.5 w-3.5" />
</Button>
</>
)}
<Button
size="icon"
variant="ghost"
+5 -4
View File
@@ -72,6 +72,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
)
const { data: contentList, isLoading: contentLoading } = useStorageContent(
connectionId,
node,
storage
)
@@ -127,11 +128,11 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
<div>
<div className="flex items-center gap-2">
<Icon className="h-6 w-6 text-muted-foreground" />
<h2 className="font-mono text-2xl font-semibold tracking-tight">{detail.storage}</h2>
<h2 className="font-mono text-2xl font-semibold tracking-tight">{storage}</h2>
</div>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{detail.type.toUpperCase()}</span> storage on{' '}
<span className="font-mono">{detail.node}</span>
<span className="font-mono">{node}</span>
</p>
</div>
</div>
@@ -197,7 +198,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
<div className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-5">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</p>
<p className="font-mono text-sm font-medium">{detail.storage}</p>
<p className="font-mono text-sm font-medium">{storage}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</p>
@@ -205,7 +206,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</p>
<p className="font-mono text-sm font-medium">{detail.node}</p>
<p className="font-mono text-sm font-medium">{node}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Enabled</p>
+17 -6
View File
@@ -15,17 +15,28 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
const { data: storageList, isLoading, error } = useStorage(connectionId)
const [search, setSearch] = useState('')
// The backend returns one entry per (storage, node) pair, so shared storages
// appear once per node (e.g. local-lvm on each node). Dedupe by name for
// display, keeping the first entry's node for the click-through.
const dedupedStorage = useMemo(() => {
const seen = new Set<string>()
return (storageList ?? []).filter((s) => {
if (seen.has(s.storage)) return false
seen.add(s.storage)
return true
})
}, [storageList])
const filteredStorage = useMemo(() => {
if (!storageList) return []
if (!search) return storageList
if (!search) return dedupedStorage
const query = search.toLowerCase()
return storageList.filter(
return dedupedStorage.filter(
(s) =>
s.storage.toLowerCase().includes(query) ||
s.type.toLowerCase().includes(query) ||
s.node.toLowerCase().includes(query)
)
}, [storageList, search])
}, [dedupedStorage, search])
if (isLoading) {
return (
@@ -54,7 +65,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
<div>
<h2 className="text-2xl font-semibold tracking-tight">Storage Pools</h2>
<p className="text-muted-foreground">
{storageList?.length ?? 0} storage pools across the cluster
{dedupedStorage.length} storage pools across the cluster
</p>
</div>
@@ -71,7 +82,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
{/* Storage Grid */}
{filteredStorage.length === 0 ? (
storageList?.length === 0 ? (
dedupedStorage.length === 0 ? (
<EmptyState icon={HardDrive} title="No storage pools found" />
) : (
<EmptyState icon={Search} title="No storage pools match your search" />
+6 -1
View File
@@ -34,7 +34,12 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm()
onOpenChange(false)
// When the parent is running an async operation (isLoading), leave the
// dialog open so the spinner and disabled state give feedback; the parent
// closes it via onSettled/onSuccess.
if (!isLoading) {
onOpenChange(false)
}
}
return (
+39 -7
View File
@@ -20,7 +20,7 @@ import {
PlayCircle,
ArrowRightLeft,
} from 'lucide-react'
import { useStartVM, useStopVM, useShutdownVM, useRebootVM, useSuspendVM, useResumeVM, useClusterStatus } from '@/hooks/useProxmox'
import { useStartVM, useStopVM, useShutdownVM, useRebootVM, useSuspendVM, useResumeVM, useClusterStatus, useVMs } from '@/hooks/useProxmox'
import { OverviewTab } from '@/components/vms/tabs/OverviewTab'
import { HardwareTab } from '@/components/vms/tabs/HardwareTab'
import { DisksTab } from '@/components/vms/tabs/DisksTab'
@@ -54,8 +54,17 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
const resumeVM = useResumeVM()
const clusterStatus = useClusterStatus(connectionId)
const isRunning = vm.status === 'running'
const isStopped = vm.status === 'stopped'
// Re-derive the live guest so the header status and lifecycle buttons track
// the actual VM state after start/stop/resume instead of the snapshot
// captured at navigation time. node/vmid stay stable from the prop; the
// prop object is the fallback while the list is loading.
const { data: vms } = useVMs(connectionId)
const liveVM = vms?.find((v) => v.vmid === vm.vmid && v.type === vm.type)
const status = liveVM?.status ?? vm.status
const isRunning = status === 'running'
const isStopped = status === 'stopped'
const isSuspended = status === 'suspended'
const isBusy = startVM.isPending || stopVM.isPending || shutdownVM.isPending || rebootVM.isPending || suspendVM.isPending || resumeVM.isPending
const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1
@@ -126,7 +135,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-semibold tracking-tight">{vm.name}</h2>
<StatusBadge status={vm.status} />
<StatusBadge status={status} />
</div>
<p className="mt-0.5 text-sm text-muted-foreground">
VMID <span className="font-mono tabular-nums">{vm.vmid}</span> &middot;{' '}
@@ -138,7 +147,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
{/* Lifecycle Actions */}
<div className="flex flex-wrap gap-2">
{isStopped || !isRunning ? (
{isStopped && (
<Button
size="sm"
onClick={handleStart}
@@ -147,7 +156,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
<Play />
Start
</Button>
) : null}
)}
{isRunning && (
<>
@@ -193,7 +202,30 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
</Button>
)}
{vm.status === 'paused' && (
{isSuspended && (
<>
<Button
variant="outline"
size="sm"
onClick={handleShutdown}
disabled={isBusy}
>
<Power />
Shutdown
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleStop}
disabled={isBusy}
>
<Square />
Stop
</Button>
</>
)}
{(status === 'paused' || isSuspended) && (
<Button
variant="outline"
size="sm"
+38 -27
View File
@@ -20,6 +20,7 @@ interface AddNICDialogProps {
}
export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
const isLxc = vm.type === 'lxc'
const [bridge, setBridge] = useState('vmbr0')
const [model, setModel] = useState('virtio')
const [macaddr, setMacaddr] = useState('')
@@ -36,9 +37,11 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
vmType: vm.type,
config: {
bridge,
model,
// Container interfaces always use the veth type; the model and VLAN
// tag concepts only apply to QEMU VMs.
model: isLxc ? 'veth' : model,
macaddr: macaddr || undefined,
tag: tag ? parseInt(tag, 10) : undefined,
tag: isLxc ? undefined : tag ? parseInt(tag, 10) : undefined,
firewall,
},
},
@@ -75,19 +78,25 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<select
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="virtio">VirtIO (paravirtualized)</option>
<option value="e1000">Intel E1000</option>
<option value="rtl8139">Realtek RTL8139</option>
</select>
</div>
{isLxc ? (
<p className="text-sm text-muted-foreground">
Container interfaces use the veth type.
</p>
) : (
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<select
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="virtio">VirtIO (paravirtualized)</option>
<option value="e1000">Intel E1000</option>
<option value="rtl8139">Realtek RTL8139</option>
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="macaddr">MAC Address (optional)</Label>
<Input
@@ -97,18 +106,20 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
placeholder="auto-generated if empty"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tag">VLAN Tag (optional)</Label>
<Input
id="tag"
type="number"
min={0}
max={4094}
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder="none"
/>
</div>
{!isLxc && (
<div className="space-y-2">
<Label htmlFor="tag">VLAN Tag (optional)</Label>
<Input
id="tag"
type="number"
min={0}
max={4094}
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder="none"
/>
</div>
)}
<div className="flex items-center gap-2">
<input
id="firewall"
+4 -1
View File
@@ -38,7 +38,10 @@ export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProp
config: {
bridge: bridge || undefined,
model,
tag: tag ? parseInt(tag, 10) : undefined,
// An emptied tag field clears the tag: the backend treats tag: 0 as
// "remove the tag". `undefined` would be indistinguishable from no
// change, so a set tag could never be removed.
tag: tag === '' ? 0 : parseInt(tag, 10),
firewall,
},
},
+4 -1
View File
@@ -28,7 +28,10 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
const handleDelete = () => {
if (!deleteDisk) return
removeDisk.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, disk: deleteDisk.device })
removeDisk.mutate(
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, disk: deleteDisk.device },
{ onSettled: () => setDeleteDisk(null) },
)
}
if (isLoading) {
+30 -2
View File
@@ -12,6 +12,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/toast'
import { useUpdateVMConfig } from '@/hooks/useProxmox'
import { Cpu, MemoryStick, Pencil, Save, X } from 'lucide-react'
import type { ProxmoxVM } from '@/types/proxmox'
import { formatBytes } from '@/lib/format'
@@ -24,6 +25,7 @@ interface HardwareTabProps {
export function HardwareTab({ vm }: HardwareTabProps) {
const [editOpen, setEditOpen] = useState(false)
const { addToast } = useToast()
const updateVMConfig = useUpdateVMConfig()
const [formData, setFormData] = useState({
name: vm.name,
description: vm.tags ?? '',
@@ -32,8 +34,34 @@ export function HardwareTab({ vm }: HardwareTabProps) {
})
const handleSave = () => {
addToast('VM configuration editing is not yet implemented', 'warning')
setEditOpen(false)
// The form holds memory in GB; Proxmox's `memory` param is MiB.
const cores = parseInt(formData.cores, 10)
const memoryMiB = Math.round(parseFloat(formData.memory) * 1024)
if (Number.isNaN(cores) || cores < 1) {
addToast('CPU cores must be at least 1', 'error')
return
}
if (Number.isNaN(memoryMiB) || memoryMiB < 128) {
addToast('Memory must be at least 128 MiB', 'error')
return
}
updateVMConfig.mutate(
{
node: vm.node,
vmid: vm.vmid,
vmType: vm.type,
config: {
name: formData.name,
cores,
memory: memoryMiB,
},
},
{
onSuccess: () => setEditOpen(false),
},
)
}
return (
+4 -1
View File
@@ -26,7 +26,10 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
const handleDelete = () => {
if (!deleteNic) return
removeNIC.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name })
removeNIC.mutate(
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name },
{ onSettled: () => setDeleteNic(null) },
)
}
if (isLoading) {
+4 -1
View File
@@ -37,7 +37,10 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
const handleDelete = () => {
if (!deleteTarget) return
deleteSnapshot.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name })
deleteSnapshot.mutate(
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name },
{ onSettled: () => setDeleteTarget(null) },
)
}
const handleRollback = () => {
+50 -39
View File
@@ -2,22 +2,20 @@ 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'
import type { AddDiskConfig, AddNICConfig, EditNICConfig, UpdateVMConfig, CreateSnapshotConfig, BackupJobConfig, RestoreConfig } from '@/types/proxmox'
// Query keys
export const queryKeys = {
nodes: (connectionId: string) => ['nodes', connectionId],
vms: (connectionId: string) => ['vms', connectionId],
storage: (connectionId: string) => ['storage', connectionId],
storageContent: (connectionId: string, storage: string) => ['storageContent', connectionId, storage],
storageContent: (connectionId: string, node: string, storage: string) => ['storageContent', connectionId, node, storage],
storageDetail: (connectionId: string, node: string, storage: string) => ['storageDetail', connectionId, node, storage],
tasks: (connectionId: string) => ['tasks', connectionId],
cluster: (connectionId: string) => ['cluster', connectionId],
disks: (connectionId: string, node: string, vmid: number) => ['disks', connectionId, node, vmid],
networks: (connectionId: string, node: string, vmid: number) => ['networks', connectionId, node, vmid],
snapshots: (connectionId: string, node: string, vmid: number) => ['snapshots', connectionId, node, vmid],
vncProxy: (connectionId: string, node: string, vmid: number) => ['vncProxy', connectionId, node, vmid],
termProxy: (connectionId: string, node: string, vmid: number) => ['termProxy', connectionId, node, vmid],
disks: (connectionId: string, node: string, vmid: number, vmType: string) => ['disks', connectionId, node, vmid, vmType],
networks: (connectionId: string, node: string, vmid: number, vmType: string) => ['networks', connectionId, node, vmid, vmType],
snapshots: (connectionId: string, node: string, vmid: number, vmType: string) => ['snapshots', connectionId, node, vmid, vmType],
backupJobs: (connectionId: string) => ['backupJobs', connectionId],
backups: (connectionId: string, storage?: string) => ['backups', connectionId, storage],
}
@@ -50,11 +48,11 @@ export const useStorage = (connectionId: string | null) => {
})
}
export const useStorageContent = (connectionId: string | null, storage: string | null) => {
export const useStorageContent = (connectionId: string | null, node: string | null, storage: string | null) => {
return useQuery({
queryKey: queryKeys.storageContent(connectionId!, storage!),
queryFn: () => api.getStorageContent(connectionId!, storage!),
enabled: !!connectionId && !!storage,
queryKey: queryKeys.storageContent(connectionId!, node!, storage!),
queryFn: () => api.getStorageContent(connectionId!, storage!, node!),
enabled: !!connectionId && !!node && !!storage,
refetchInterval: 30000,
})
}
@@ -222,7 +220,7 @@ export const useResumeVM = () => {
// Disk management hooks
export const useDisks = (connectionId: string, node: string, vmid: number, vmType: string) => {
return useQuery({
queryKey: queryKeys.disks(connectionId, node, vmid),
queryKey: queryKeys.disks(connectionId, node, vmid, vmType),
queryFn: () => api.getDisks(connectionId, node, vmid, vmType),
enabled: !!connectionId && !!node && vmid > 0,
})
@@ -252,7 +250,7 @@ export const useAddDisk = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -288,7 +286,7 @@ export const useResizeDisk = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -322,7 +320,7 @@ export const useRemoveDisk = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -358,7 +356,7 @@ export const useMoveDisk = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -371,7 +369,7 @@ export const useMoveDisk = () => {
// Network management hooks
export const useNetworkInterfaces = (connectionId: string, node: string, vmid: number, vmType: string) => {
return useQuery({
queryKey: queryKeys.networks(connectionId, node, vmid),
queryKey: queryKeys.networks(connectionId, node, vmid, vmType),
queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid, vmType),
enabled: !!connectionId && !!node && vmid > 0,
})
@@ -401,7 +399,7 @@ export const useAddNIC = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
queryKey: queryKeys.networks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -437,7 +435,7 @@ export const useEditNIC = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
queryKey: queryKeys.networks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -471,7 +469,7 @@ export const useRemoveNIC = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
queryKey: queryKeys.networks(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -484,7 +482,7 @@ export const useRemoveNIC = () => {
// Snapshot management hooks
export const useSnapshots = (connectionId: string, node: string, vmid: number, vmType: string) => {
return useQuery({
queryKey: queryKeys.snapshots(connectionId, node, vmid),
queryKey: queryKeys.snapshots(connectionId, node, vmid, vmType),
queryFn: () => api.getSnapshots(connectionId, node, vmid, vmType),
enabled: !!connectionId && !!node && vmid > 0,
})
@@ -514,7 +512,7 @@ export const useCreateSnapshot = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -548,7 +546,7 @@ export const useDeleteSnapshot = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -582,7 +580,7 @@ export const useRollbackSnapshot = () => {
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid, variables.vmType),
})
}
},
@@ -627,22 +625,35 @@ export const useMigrateVM = () => {
})
}
// Console proxy hooks
export const useVNCProxy = (connectionId: string, node: string, vmid: number) => {
return useQuery({
queryKey: queryKeys.vncProxy(connectionId, node, vmid),
queryFn: () => api.createVNCProxy(connectionId, node, vmid),
enabled: false, // Manual trigger only
retry: false,
})
}
export const useUpdateVMConfig = () => {
const queryClient = useQueryClient()
const { addToast } = useToast()
export const useTermProxy = (connectionId: string, node: string, vmid: number) => {
return useQuery({
queryKey: queryKeys.termProxy(connectionId, node, vmid),
queryFn: () => api.createTermProxy(connectionId, node, vmid),
enabled: false, // Manual trigger only
retry: false,
return useMutation({
mutationFn: ({
node,
vmid,
vmType,
config,
}: {
node: string
vmid: number
vmType: string
config: UpdateVMConfig
}) => {
const connId = useConnectionStore.getState().activeConnectionId!
return api.updateVMConfig(connId, node, vmid, vmType, config)
},
onSuccess: () => {
addToast('VM configuration updated', 'success')
const connId = useConnectionStore.getState().activeConnectionId
if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
}
},
onError: (error: Error) => {
addToast(error.message || 'Failed to update VM configuration', 'error')
},
})
}
+2 -2
View File
@@ -5,8 +5,8 @@
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))
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
+91 -138
View File
@@ -25,11 +25,11 @@ import type {
AddDiskConfig,
AddNICConfig,
EditNICConfig,
UpdateVMConfig,
CreateSnapshotConfig,
BackupJobConfig,
RestoreConfig,
VNCProxyResponse,
TermProxyResponse,
ConsoleProxyInfo,
} from '@/types/proxmox'
// Check if we're running in Tauri
@@ -42,39 +42,49 @@ const mockResponse = <T>(data: T): Promise<T> => {
return new Promise((resolve) => setTimeout(() => resolve(data), 300))
}
// Invoke a backend command, normalizing the rejection into a real Error so
// callers see the backend's message instead of a bare string (Tauri rejects
// with a string for command errors).
async function invokeCommand<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauri()) {
throw new Error('Tauri backend not available in browser mode')
}
const { invoke } = await import('@tauri-apps/api/core')
try {
return await invoke<T>(cmd, args)
} catch (e) {
throw new Error(typeof e === 'string' ? e : e instanceof Error ? e.message : String(e))
}
}
// Connection management
export const loadConnections = async (): Promise<LoadConnectionsResult> => {
if (!isTauri()) {
return mockResponse({ activeConnectionId: null, connections: [] })
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('load_connections')
return invokeCommand<LoadConnectionsResult>('load_connections')
}
export const addConnection = async (config: ConnectionConfig): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('add_connection', { config })
return invokeCommand<void>('add_connection', { config })
}
export const removeConnection = async (id: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('remove_connection', { id })
return invokeCommand<void>('remove_connection', { id })
}
export const updateConnection = async (config: ConnectionConfig): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('update_connection', { config })
return invokeCommand<void>('update_connection', { config })
}
export const connectToServer = async (id: string): Promise<ConnectResult> => {
if (!isTauri()) {
return mockResponse({ connectionId: id, mergedInto: null, status: 'connected' })
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('connect_to_server', { id })
return invokeCommand<ConnectResult>('connect_to_server', { id })
}
export const getConnectionStatus = async (
@@ -89,20 +99,17 @@ export const getConnectionStatus = async (
nodes: [],
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_connection_status', { connectionId })
return invokeCommand<ConnectionStatusInfo>('get_connection_status', { connectionId })
}
export const disconnectFromServer = async (id: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('disconnect_from_server', { id })
return invokeCommand<void>('disconnect_from_server', { id })
}
export const setActiveConnection = async (id: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('set_active_connection', { id })
return invokeCommand<void>('set_active_connection', { id })
}
// Authentication
@@ -118,8 +125,7 @@ export const loginWithPassword = async (
csrfToken: 'mock-csrf-' + Date.now(),
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('login_with_password', { url, username, password })
return invokeCommand<LoginResult>('login_with_password', { url, username, password })
}
export const loginWithToken = async (
@@ -133,22 +139,12 @@ export const loginWithToken = async (
csrfToken: '',
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('login_with_token', { url, token })
return invokeCommand<LoginResult>('login_with_token', { url, token })
}
export const logout = async (connectionId: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('logout', { connectionId })
}
export const getStoredCredentials = async (
connectionId: string,
): Promise<string | null> => {
if (!isTauri()) return mockResponse(null)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_stored_credentials', { connectionId })
return invokeCommand<void>('logout', { connectionId })
}
export const getCertificateInfo = async (url: string): Promise<CertificateInfo> => {
@@ -162,14 +158,12 @@ export const getCertificateInfo = async (url: string): Promise<CertificateInfo>
selfSigned: true,
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_certificate_info', { url })
return invokeCommand<CertificateInfo>('get_certificate_info', { url })
}
export const trustCertificate = async (id: string, fingerprint: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('trust_certificate', { id, fingerprint })
return invokeCommand<void>('trust_certificate', { id, fingerprint })
}
// API calls
@@ -192,25 +186,23 @@ export const getNodes = async (connectionId: string): Promise<ProxmoxNode[]> =>
},
])
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_nodes', { connectionId })
return invokeCommand<ProxmoxNode[]>('get_nodes', { connectionId })
}
export const getVMs = async (connectionId: string): Promise<ProxmoxVM[]> => {
if (!isTauri()) return mockResponse([])
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_vms', { connectionId })
return invokeCommand<ProxmoxVM[]>('get_vms', { connectionId })
}
export const getStorage = async (connectionId: string): Promise<ProxmoxStorage[]> => {
if (!isTauri()) return mockResponse([])
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_storage', { connectionId })
return invokeCommand<ProxmoxStorage[]>('get_storage', { connectionId })
}
export const getStorageContent = async (
connectionId: string,
storage: string,
node?: string,
): Promise<ProxmoxStorageContent[]> => {
if (!isTauri()) {
return mockResponse([
@@ -220,8 +212,7 @@ export const getStorageContent = async (
{ content: 'iso', ctime: Date.now() / 1000 - 172800, size: 4 * 1024 * 1024 * 1024, volid: 'local:iso/ubuntu-22.04-desktop-amd64.iso' },
])
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_storage_content', { connectionId, storage })
return invokeCommand<ProxmoxStorageContent[]>('get_storage_content', { connectionId, storage, node })
}
export const getStorageDetail = async (
@@ -243,14 +234,12 @@ export const getStorageDetail = async (
node,
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_storage_detail', { connectionId, node, storage })
return invokeCommand<ProxmoxStorageDetail>('get_storage_detail', { connectionId, node, storage })
}
export const getTasks = async (connectionId: string): Promise<ProxmoxTask[]> => {
if (!isTauri()) return mockResponse([])
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_tasks', { connectionId })
return invokeCommand<ProxmoxTask[]>('get_tasks', { connectionId })
}
export const getClusterStatus = async (connectionId: string): Promise<ProxmoxClusterStatus> => {
@@ -262,45 +251,38 @@ export const getClusterStatus = async (connectionId: string): Promise<ProxmoxClu
nodes: [],
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_cluster_status', { connectionId })
return invokeCommand<ProxmoxClusterStatus>('get_cluster_status', { connectionId })
}
// VM lifecycle
export const startVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('start_vm', { connectionId, node, vmid, vmType })
return invokeCommand<void>('start_vm', { connectionId, node, vmid, vmType })
}
export const stopVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('stop_vm', { connectionId, node, vmid, vmType })
return invokeCommand<void>('stop_vm', { connectionId, node, vmid, vmType })
}
export const shutdownVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('shutdown_vm', { connectionId, node, vmid, vmType })
return invokeCommand<void>('shutdown_vm', { connectionId, node, vmid, vmType })
}
export const rebootVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('reboot_vm', { connectionId, node, vmid, vmType })
return invokeCommand<void>('reboot_vm', { connectionId, node, vmid, vmType })
}
export const suspendVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('suspend_vm', { connectionId, node, vmid, vmType })
return invokeCommand<void>('suspend_vm', { connectionId, node, vmid, vmType })
}
export const resumeVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('resume_vm', { connectionId, node, vmid, vmType })
return invokeCommand<void>('resume_vm', { connectionId, node, vmid, vmType })
}
// Disk management
@@ -316,8 +298,7 @@ export const getDisks = async (
{ device: 'scsi1', size: 64 * 1024 * 1024 * 1024, storage: 'local-lvm', format: 'qcow2' },
])
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_disks', { connectionId, node, vmid, vmType })
return invokeCommand<ProxmoxDisk[]>('get_disks', { connectionId, node, vmid, vmType })
}
export const addDisk = async (
@@ -328,8 +309,7 @@ export const addDisk = async (
config: AddDiskConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('add_disk', { connectionId, node, vmid, vmType, config })
return invokeCommand<void>('add_disk', { connectionId, node, vmid, vmType, config })
}
export const resizeDisk = async (
@@ -341,8 +321,7 @@ export const resizeDisk = async (
size: number,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('resize_disk', { connectionId, node, vmid, vmType, disk, size })
return invokeCommand<void>('resize_disk', { connectionId, node, vmid, vmType, disk, size })
}
export const removeDisk = async (
@@ -353,8 +332,7 @@ export const removeDisk = async (
disk: string,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('remove_disk', { connectionId, node, vmid, vmType, disk })
return invokeCommand<void>('remove_disk', { connectionId, node, vmid, vmType, disk })
}
export const moveDisk = async (
@@ -366,8 +344,7 @@ export const moveDisk = async (
storage: string,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('move_disk', { connectionId, node, vmid, vmType, disk, storage })
return invokeCommand<void>('move_disk', { connectionId, node, vmid, vmType, disk, storage })
}
// Network management
@@ -382,8 +359,7 @@ export const getNetworkInterfaces = async (
{ name: 'net0', model: 'virtio', macaddr: 'BC:24:11:AA:BB:CC', bridge: 'vmbr0', firewall: 1 },
])
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_network_interfaces', { connectionId, node, vmid, vmType })
return invokeCommand<ProxmoxNetwork[]>('get_network_interfaces', { connectionId, node, vmid, vmType })
}
export const addNIC = async (
@@ -394,8 +370,7 @@ export const addNIC = async (
config: AddNICConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('add_nic', { connectionId, node, vmid, vmType, config })
return invokeCommand<void>('add_nic', { connectionId, node, vmid, vmType, config })
}
export const editNIC = async (
@@ -407,8 +382,7 @@ export const editNIC = async (
config: EditNICConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('edit_nic', { connectionId, node, vmid, vmType, nic, config })
return invokeCommand<void>('edit_nic', { connectionId, node, vmid, vmType, nic, config })
}
export const removeNIC = async (
@@ -419,8 +393,7 @@ export const removeNIC = async (
nic: string,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('remove_nic', { connectionId, node, vmid, vmType, nic })
return invokeCommand<void>('remove_nic', { connectionId, node, vmid, vmType, nic })
}
// Snapshot management
@@ -431,8 +404,7 @@ export const getSnapshots = async (
vmType: string,
): Promise<ProxmoxSnapshot[]> => {
if (!isTauri()) return mockResponse([])
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_snapshots', { connectionId, node, vmid, vmType })
return invokeCommand<ProxmoxSnapshot[]>('get_snapshots', { connectionId, node, vmid, vmType })
}
export const createSnapshot = async (
@@ -443,8 +415,7 @@ export const createSnapshot = async (
config: CreateSnapshotConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('create_snapshot', { connectionId, node, vmid, vmType, config })
return invokeCommand<void>('create_snapshot', { connectionId, node, vmid, vmType, config })
}
export const deleteSnapshot = async (
@@ -455,8 +426,7 @@ export const deleteSnapshot = async (
name: string,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('delete_snapshot', { connectionId, node, vmid, vmType, name })
return invokeCommand<void>('delete_snapshot', { connectionId, node, vmid, vmType, name })
}
export const rollbackSnapshot = async (
@@ -467,8 +437,7 @@ export const rollbackSnapshot = async (
name: string,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('rollback_snapshot', { connectionId, node, vmid, vmType, name })
return invokeCommand<void>('rollback_snapshot', { connectionId, node, vmid, vmType, name })
}
// VM migration
@@ -481,40 +450,37 @@ export const migrateVM = async (
online: boolean,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('migrate_vm', { connectionId, node, vmid, vmType, targetNode, online })
return invokeCommand<void>('migrate_vm', { connectionId, node, vmid, vmType, targetNode, online })
}
// VM configuration
export const updateVMConfig = async (
connectionId: string,
node: string,
vmid: number,
vmType: string,
config: UpdateVMConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
return invokeCommand<void>('update_vm_config', { connectionId, node, vmid, vmType, config })
}
// Console proxy
export const createVNCProxy = async (
export const startConsoleProxy = async (
connectionId: string,
kind: 'vnc' | 'term',
node: string,
vmid: number,
): Promise<VNCProxyResponse> => {
): Promise<ConsoleProxyInfo> => {
if (!isTauri()) {
return mockResponse({
ticket: 'mock-vnc-ticket-' + Date.now(),
port: 6000 + (vmid % 1000),
cert: '',
})
return mockResponse({ sessionId: crypto.randomUUID(), url: '' })
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('create_vnc_proxy', { connectionId, node, vmid })
return invokeCommand<ConsoleProxyInfo>('start_console_proxy', { connectionId, kind, node, vmid })
}
export const createTermProxy = async (
connectionId: string,
node: string,
vmid: number,
): Promise<TermProxyResponse> => {
if (!isTauri()) {
return mockResponse({
ticket: 'mock-term-ticket-' + Date.now(),
port: 6100 + (vmid % 1000),
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('create_term_proxy', { connectionId, node, vmid })
export const stopConsoleProxy = async (sessionId: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
return invokeCommand<void>('stop_console_proxy', { sessionId })
}
export const getWebSocketURL = async (
@@ -524,27 +490,23 @@ export const getWebSocketURL = async (
if (!isTauri()) {
return mockResponse(`wss://localhost:8006`)
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_websocket_url', { connectionId, node })
return invokeCommand<string>('get_websocket_url', { connectionId, node })
}
// WebSocket management
export const connectWebSocket = async (connectionId: string, url: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('connect_websocket', { connectionId, url })
return invokeCommand<void>('connect_websocket', { connectionId, url })
}
export const disconnectWebSocket = async (connectionId: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('disconnect_websocket', { connectionId })
return invokeCommand<void>('disconnect_websocket', { connectionId })
}
export const isWebSocketConnected = async (connectionId: string): Promise<boolean> => {
if (!isTauri()) return mockResponse(false)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('is_websocket_connected', { connectionId })
return invokeCommand<boolean>('is_websocket_connected', { connectionId })
}
// Backup management
@@ -562,8 +524,7 @@ export const getBackupJobs = async (connectionId: string): Promise<ProxmoxBackup
},
])
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_backup_jobs', { connectionId })
return invokeCommand<ProxmoxBackupJob[]>('get_backup_jobs', { connectionId })
}
export const getBackups = async (
@@ -571,8 +532,7 @@ export const getBackups = async (
storage?: string,
): Promise<ProxmoxBackup[]> => {
if (!isTauri()) return mockResponse([])
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_backups', { connectionId, storage })
return invokeCommand<ProxmoxBackup[]>('get_backups', { connectionId, storage })
}
export const createBackupJob = async (
@@ -580,8 +540,7 @@ export const createBackupJob = async (
config: BackupJobConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('create_backup_job', { connectionId, config })
return invokeCommand<void>('create_backup_job', { connectionId, config })
}
export const updateBackupJob = async (
@@ -590,14 +549,12 @@ export const updateBackupJob = async (
config: BackupJobConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('update_backup_job', { connectionId, id, config })
return invokeCommand<void>('update_backup_job', { connectionId, id, config })
}
export const deleteBackupJob = async (connectionId: string, id: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('delete_backup_job', { connectionId, id })
return invokeCommand<void>('delete_backup_job', { connectionId, id })
}
export const runBackup = async (
@@ -605,8 +562,7 @@ export const runBackup = async (
config: BackupJobConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('run_backup', { connectionId, config })
return invokeCommand<void>('run_backup', { connectionId, config })
}
export const restoreBackup = async (
@@ -615,14 +571,12 @@ export const restoreBackup = async (
config: RestoreConfig,
): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('restore_backup', { connectionId, volid, config })
return invokeCommand<void>('restore_backup', { connectionId, volid, config })
}
export const deleteBackup = async (connectionId: string, volid: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('delete_backup', { connectionId, volid })
return invokeCommand<void>('delete_backup', { connectionId, volid })
}
// Tray menu
@@ -630,6 +584,5 @@ export const updateTrayMenu = async (
connections: { id: string; name: string; status: string }[],
): Promise<void> => {
if (!isTauri()) return
const { invoke } = await import('@tauri-apps/api/core')
return invoke('update_tray_menu', { connections })
return invokeCommand<void>('update_tray_menu', { connections })
}
+12 -11
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import type { ConnectionConfig, ConnectionStatus, DiscoveredNode } from '@/types/connection'
import type { ConnectionConfig, ConnectionStatus } from '@/types/connection'
export type AuthStatus = 'authenticated' | 'expired' | 'unauthenticated'
@@ -17,7 +17,6 @@ interface ConnectionState {
hydrate: (connections: ConnectionConfig[], activeConnectionId: string | null) => void
setActiveConnection: (id: string | null) => void
setConnectionStatus: (id: string, status: ConnectionStatus) => void
setConnectionNodes: (id: string, nodes: DiscoveredNode[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setAuthStatus: (status: AuthStatus) => void
@@ -52,8 +51,17 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
hydrate: (connections, activeConnectionId) =>
set({ connections, activeConnectionId }),
setActiveConnection: (id) =>
set({ activeConnectionId: id }),
setActiveConnection: (id) => {
set({ activeConnectionId: id })
// Persist the choice so the backend reconnects this connection on the next
// launch. Fire-and-forget: switching is a UI action and a transient failure
// should not block it.
if (id) {
import('@/lib/tauri')
.then(({ setActiveConnection: persistActive }) => persistActive(id))
.catch(() => {})
}
},
setConnectionStatus: (id, status) =>
set((state) => ({
@@ -62,13 +70,6 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
),
})),
setConnectionNodes: (id, nodes) =>
set((state) => ({
connections: state.connections.map((c) =>
c.id === id ? { ...c, nodes } : c
),
})),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
+13
View File
@@ -206,6 +206,14 @@ export interface EditNICConfig {
firewall?: boolean
}
export interface UpdateVMConfig {
name?: string
cores?: number
/** Memory size in MiB. */
memory?: number
description?: string
}
export interface CreateSnapshotConfig {
name: string
description?: string
@@ -224,6 +232,11 @@ export interface TermProxyResponse {
port: number
}
export interface ConsoleProxyInfo {
sessionId: string
url: string
}
export interface WebSocketInfo {
url: string
}