feat: add marketing site and expand Proxmox management features
This commit is contained in:
+70
-6
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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> ·{' '}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user