fix(proxmox): comprehensive VM management and UI improvements #128

Merged
sarman merged 2 commits from fix/proxmox-complete-v1.2.3-beta into beta 2026-06-21 19:11:16 +00:00
6 changed files with 4400 additions and 49 deletions
Showing only changes of commit d922b72f53 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@ -315,11 +315,18 @@ impl OpenAiProvider {
let api_url = config.api_url.trim_end_matches('/');
let url = format!("{api_url}{endpoint_path}");
// Extract system message if present
let system_message = messages
// Extract ALL system messages and combine them (must be at the beginning)
let system_messages: Vec<String> = messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.clone());
.filter(|m| m.role == "system")
.map(|m| m.content.clone())
.collect();
let combined_system = if system_messages.is_empty() {
None
} else {
Some(system_messages.join("\n\n"))
};
// Get last user message as prompt
let prompt = messages

View File

@ -7,6 +7,13 @@ import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, M
import { invoke } from '@tauri-apps/api/core';
import { confirm } from '@tauri-apps/plugin-dialog';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { Checkbox as UICheckbox } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/index';
interface VMInfo {
id: string;
@ -23,8 +30,24 @@ interface VMInfo {
tags?: string[];
}
interface RawVMInfo {
id: number;
vmid?: number;
name?: string;
node?: string;
status?: string;
cpu?: number;
mem?: number;
max_mem?: number;
memory?: number;
disk?: number;
max_disk?: number;
diskTotal?: number;
uptime?: number;
}
interface VMListProps {
vms: VMInfo[];
vms: RawVMInfo[];
onRefresh?: () => void;
isLoading?: boolean;
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
@ -59,7 +82,7 @@ function formatBytes(bytes: number): string {
}
export function VMList({
vms,
vms: rawVms,
onRefresh,
isLoading,
onSnapshotAction,
@ -70,9 +93,30 @@ export function VMList({
onToggleSelect,
}: VMListProps) {
const [clusterId, setClusterId] = useState<string>('');
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
const [targetNode, setTargetNode] = useState<string>('');
const [onlineMigration, setOnlineMigration] = useState(true);
const [maxDowntime, setMaxDowntime] = useState(30);
// Transform raw VM data to VMInfo format
const vms: VMInfo[] = React.useMemo(() => {
return rawVms.map((vm) => ({
id: String(vm.id || vm.vmid),
vmid: vm.vmid || vm.id,
name: vm.name || `VM-${vm.vmid || vm.id}`,
node: vm.node || '',
status: (vm.status || 'stopped') as 'running' | 'stopped' | 'paused',
cpu: vm.cpu || 0,
memory: vm.mem || vm.memory || 0,
memoryTotal: vm.max_mem || vm.memoryTotal || 0,
disk: vm.disk || 0,
diskTotal: vm.max_disk || vm.diskTotal || 0,
uptime: vm.uptime,
tags: vm.tags,
}));
}, [rawVms]);
useEffect(() => {
// Use list_proxmox_clusters and select the first cluster
invoke<string[]>('list_proxmox_clusters')
.then((clusters: any[]) => {
if (clusters.length > 0) {
@ -152,48 +196,53 @@ export function VMList({
toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`);
}, []);
const handleMigrate = useCallback(async (vm: VMInfo) => {
try {
const targetNodes = vms
.map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
if (targetNodes.length === 0) {
toast.error('No target nodes available for migration');
return;
}
const handleMigrate = useCallback((vm: VMInfo) => {
setMigrationVM(vm);
const availableNodes = vms
.map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
setTargetNode(availableNodes[0] || '');
}, [vms]);
const targetNode = window.prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]);
if (!targetNode) {
toast.info('Migration cancelled');
return;
}
const submitMigration = useCallback(async () => {
if (!migrationVM || !targetNode) {
toast.error('Please select a target node');
return;
}
try {
await invoke('migrate_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
nodeId: migrationVM.node,
vmId: migrationVM.vmid,
targetNode,
online: vm.status === 'running',
online: onlineMigration,
max_downtime: maxDowntime,
});
toast.success(`VM ${vm.name} migration started`);
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`);
setMigrationVM(null);
setTargetNode('');
onRefresh?.();
} catch (error) {
console.error('Failed to migrate VM:', error);
toast.error(`Failed to migrate VM ${vm.name}: ${error}`);
toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`);
}
}, [clusterId, vms, onRefresh]);
}, [migrationVM, targetNode, onlineMigration, maxDowntime, clusterId, onRefresh]);
const handleClone = useCallback(async (vm: VMInfo) => {
try {
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${vm.vmid + 1}`);
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
if (!newVmidStr) {
toast.info('Clone cancelled');
return;
}
const newVmid = parseInt(newVmidStr);
if (isNaN(newVmid) || newVmid < 100) {
toast.error('Invalid VM ID. Must be >= 100');
return;
}
const newName = window.prompt(`Enter name for cloned VM:`, `${vm.name}-clone`);
if (!newName) {
toast.info('Clone cancelled');
@ -205,19 +254,24 @@ export function VMList({
nodeId: vm.node,
vmId: vm.vmid,
newVmid,
name: newName || `${vm.name}-clone`,
name: newName,
});
toast.success(`VM ${vm.name} cloned successfully`);
toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`);
onRefresh?.();
} catch (error) {
console.error('Failed to clone VM:', error);
toast.error(`Failed to clone VM ${vm.name}: ${error}`);
}
}, [clusterId, onRefresh]);
}, [clusterId, vms, onRefresh]);
const handleDelete = useCallback(async (vm: VMInfo) => {
if (!confirm(`Are you sure you want to delete VM ${vm.name}?`)) {
const confirmed = await confirm(`Are you sure you want to delete VM ${vm.name} (VMID: ${vm.vmid})? This action cannot be undone!`, {
title: 'Delete VM',
kind: 'warning',
});
if (!confirmed) {
return;
}
@ -228,7 +282,7 @@ export function VMList({
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} deleted`);
toast.success(`VM ${vm.name} deleted successfully`);
onRefresh?.();
} catch (error) {
console.error('Failed to delete VM:', error);
@ -353,6 +407,20 @@ export function VMList({
</Table>
</div>
</CardContent>
<MigrationDialog
vm={migrationVM}
isOpen={!!migrationVM}
onClose={() => setMigrationVM(null)}
onSubmit={submitMigration}
availableNodes={vms}
targetNode={targetNode}
onTargetNodeChange={setTargetNode}
online={onlineMigration}
onOnlineChange={setOnlineMigration}
maxDowntime={maxDowntime}
onMaxDowntimeChange={setMaxDowntime}
/>
</Card>
);
}
@ -548,3 +616,115 @@ function VMActionMenu({
</div>
);
}
interface MigrationDialogProps {
vm: VMInfo | null;
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
availableNodes: VMInfo[];
targetNode: string;
onTargetNodeChange: (node: string) => void;
online: boolean;
onOnlineChange: (online: boolean) => void;
maxDowntime: number;
onMaxDowntimeChange: (downtime: number) => void;
}
function MigrationDialog({
vm,
isOpen,
onClose,
onSubmit,
availableNodes,
targetNode,
onTargetNodeChange,
online,
onOnlineChange,
maxDowntime,
onMaxDowntimeChange,
}: MigrationDialogProps) {
if (!vm) return null;
const availableTargets = availableNodes
.map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Migrate {vm.name} (VM {vm.vmid})</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="targetNode">Target Node</Label>
<Select value={targetNode} onValueChange={onTargetNodeChange}>
<SelectTrigger>
<SelectValue placeholder="Select target node" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((node) => (
<SelectItem key={node} value={node}>
{node}
</SelectItem>
))}
</SelectContent>
</Select>
{availableTargets.length === 0 && (
<p className="text-xs text-muted-foreground">
No other nodes available for migration
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<UICheckbox
id="online"
checked={online}
onCheckedChange={(checked) => onOnlineChange(checked as boolean)}
/>
<Label htmlFor="online">Live Migration</Label>
</div>
<p className="text-xs text-muted-foreground">
{online ? 'Keep VM running during migration' : 'VM will be stopped during migration'}
</p>
</div>
{online && (
<div className="space-y-2">
<Label htmlFor="maxDowntime">Max Downtime (ms)</Label>
<Input
id="maxDowntime"
type="number"
value={maxDowntime}
onChange={(e) => onMaxDowntimeChange(Number(e.target.value))}
min={10}
max={10000}
/>
<p className="text-xs text-muted-foreground">
Maximum allowed downtime during live migration
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={!targetNode || availableTargets.length === 0}>
Start Migration
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,10 +1,14 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react';
import { RefreshCw, Plus } from 'lucide-react';
import { BackupJobList } from '@/components/Proxmox';
import { listProxmoxClusters, listProxmoxBackupJobs } from '@/lib/proxmoxClient';
import type { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
export function ProxmoxBackupPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
@ -12,6 +16,13 @@ export function ProxmoxBackupPage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jobs, setJobs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [showNewJobDialog, setShowNewJobDialog] = useState(false);
// New job form state
const [jobName, setJobName] = useState('');
const [jobNode, setJobNode] = useState('');
const [jobSchedule, setJobSchedule] = useState('');
const [jobVms, setJobVms] = useState('');
useEffect(() => {
listProxmoxClusters()
@ -29,7 +40,6 @@ export function ProxmoxBackupPage() {
if (!clusterId) return;
setIsLoading(true);
try {
// Backup jobs are cluster-level, not node-level
const data = await listProxmoxBackupJobs(clusterId, '');
setJobs(data);
} catch (err) {
@ -44,6 +54,29 @@ export function ProxmoxBackupPage() {
if (selectedClusterId) loadJobs(selectedClusterId);
}, [selectedClusterId, loadJobs]);
const handleNewJob = () => {
setJobName('');
setJobNode('');
setJobSchedule('');
setJobVms('');
setShowNewJobDialog(true);
};
const handleSubmitNewJob = async () => {
if (!jobName || !jobNode || !jobSchedule) {
toast.error('Job name, node, and schedule are required');
return;
}
try {
toast.info(`Creating backup job ${jobName} - implementation pending`);
setShowNewJobDialog(false);
} catch (error) {
console.error('Failed to create backup job:', error);
toast.error(`Failed to create backup job: ${error}`);
}
};
if (clusters.length === 0 && !isLoading) {
return (
<div className="space-y-4">
@ -82,6 +115,10 @@ export function ProxmoxBackupPage() {
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button size="sm" onClick={handleNewJob}>
<Plus className="mr-2 h-4 w-4" />
New Job
</Button>
</div>
</div>
@ -89,6 +126,63 @@ export function ProxmoxBackupPage() {
jobs={jobs}
onRefresh={() => loadJobs(selectedClusterId)}
/>
<Dialog open={showNewJobDialog} onOpenChange={setShowNewJobDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Backup Job</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="jobName">Job Name</Label>
<Input
id="jobName"
value={jobName}
onChange={(e) => setJobName(e.target.value)}
placeholder="daily-backup"
/>
</div>
<div className="space-y-2">
<Label htmlFor="jobNode">Node</Label>
<Input
id="jobNode"
value={jobNode}
onChange={(e) => setJobNode(e.target.value)}
placeholder="pve"
/>
</div>
<div className="space-y-2">
<Label htmlFor="jobSchedule">Schedule (cron format)</Label>
<Input
id="jobSchedule"
value={jobSchedule}
onChange={(e) => setJobSchedule(e.target.value)}
placeholder="0 2 * * *"
/>
<p className="text-xs text-muted-foreground">
Example: "0 2 * * *" for daily at 2:00 AM
</p>
</div>
<div className="space-y-2">
<Label htmlFor="jobVms">VMs to Backup (comma-separated IDs)</Label>
<Input
id="jobVms"
value={jobVms}
onChange={(e) => setJobVms(e.target.value)}
placeholder="100, 101, 102"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewJobDialog(false)}>
Cancel
</Button>
<Button onClick={handleSubmitNewJob}>
Create Job
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -2,8 +2,12 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Badge } from '@/components/ui/index';
import { RefreshCw, Network } from 'lucide-react';
import { RefreshCw, Network, Plus, Edit, Trash2 } from 'lucide-react';
import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/lib/proxmoxClient';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { toast } from 'sonner';
export function ProxmoxNetworkPage() {
const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]);
@ -11,6 +15,16 @@ export function ProxmoxNetworkPage() {
const [nodeId] = useState('localhost');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null);
// Form state
const [ifaceName, setIfaceName] = useState('');
const [ifaceType, setIfaceType] = useState('eth');
const [address, setAddress] = useState('');
const [netmask, setNetmask] = useState('');
const [gateway, setGateway] = useState('');
const [active, setActive] = useState(true);
const loadInterfaces = useCallback(async (cId: string, nId: string) => {
if (!cId) return;
@ -37,6 +51,60 @@ export function ProxmoxNetworkPage() {
.catch(console.error);
}, [loadInterfaces, nodeId]);
const handleAddInterface = () => {
setEditingInterface(null);
setIfaceName('');
setIfaceType('eth');
setAddress('');
setNetmask('');
setGateway('');
setActive(true);
setShowAddDialog(true);
};
const handleEditInterface = (iface: NetworkInterface) => {
setEditingInterface(iface);
setIfaceName(iface.iface);
setIfaceType(iface.type);
setAddress(iface.address || '');
setNetmask(iface.netmask || '');
setGateway(iface.gateway || '');
setActive(iface.active);
setShowAddDialog(true);
};
const handleSubmit = async () => {
if (!ifaceName || !ifaceType) {
toast.error('Interface name and type are required');
return;
}
try {
if (editingInterface) {
toast.info(`Updating interface ${ifaceName} - implementation pending`);
} else {
toast.info(`Creating interface ${ifaceName} - implementation pending`);
}
setShowAddDialog(false);
} catch (error) {
console.error('Failed to save interface:', error);
toast.error(`Failed to save interface: ${error}`);
}
};
const handleDeleteInterface = async (iface: NetworkInterface) => {
if (!confirm(`Are you sure you want to delete interface ${iface.iface}?`)) {
return;
}
try {
toast.info(`Deleting interface ${iface.iface} - implementation pending`);
} catch (error) {
console.error('Failed to delete interface:', error);
toast.error(`Failed to delete interface: ${error}`);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@ -44,15 +112,16 @@ export function ProxmoxNetworkPage() {
<h1 className="text-2xl font-bold">Network</h1>
<p className="text-muted-foreground">Network interfaces and bridges</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void loadInterfaces(clusterId, nodeId)}
disabled={loading || !clusterId}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={() => void loadInterfaces(clusterId, nodeId)} disabled={loading || !clusterId}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button size="sm" onClick={handleAddInterface}>
<Plus className="mr-2 h-4 w-4" />
Add Interface
</Button>
</div>
</div>
{error && (
@ -107,12 +176,101 @@ export function ProxmoxNetworkPage() {
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
className="rounded p-1 hover:bg-accent"
onClick={() => handleEditInterface(iface)}
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
className="rounded p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => handleDeleteInterface(iface)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingInterface ? 'Edit Network Interface' : 'Add Network Interface'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="iface">Interface Name</Label>
<Input
id="iface"
value={ifaceName}
onChange={(e) => setIfaceName(e.target.value)}
placeholder="eth0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Interface Type</Label>
<Input
id="type"
value={ifaceType}
onChange={(e) => setIfaceType(e.target.value)}
placeholder="eth, bond, bridge, vlan"
/>
</div>
<div className="space-y-2">
<Label htmlFor="address">IP Address</Label>
<Input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="192.168.1.100"
/>
</div>
<div className="space-y-2">
<Label htmlFor="netmask">Netmask</Label>
<Input
id="netmask"
value={netmask}
onChange={(e) => setNetmask(e.target.value)}
placeholder="24"
/>
</div>
<div className="space-y-2">
<Label htmlFor="gateway">Gateway</Label>
<Input
id="gateway"
value={gateway}
onChange={(e) => setGateway(e.target.value)}
placeholder="192.168.1.1"
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="active"
checked={active}
onChange={(e) => setActive(e.target.checked)}
className="rounded"
/>
<Label htmlFor="active">Active</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>
{editingInterface ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -25,7 +25,13 @@ export function ProxmoxViewsPage() {
const v = await listClusterViews(cId);
setViews(v);
} catch (e) {
setError(String(e));
const errorMsg = String(e);
// Handle 501 Not Implemented error gracefully
if (errorMsg.includes('501') || errorMsg.includes('Not Implemented')) {
setError('Cluster views feature is not implemented by this Proxmox server. This is a server-side limitation.');
} else {
setError(errorMsg);
}
}
}, []);