Merge pull request 'fix(proxmox): comprehensive VM management and UI improvements' (#128) from fix/proxmox-complete-v1.2.3-beta into beta
All checks were successful
Release Beta / autotag (push) Successful in 9s
Release Beta / changelog (push) Successful in 1m32s
Test / frontend-tests (push) Successful in 1m41s
Test / frontend-typecheck (push) Successful in 1m58s
Release Beta / build-macos-arm64 (push) Successful in 7m51s
Release Beta / build-linux-amd64 (push) Successful in 10m51s
Release Beta / build-windows-amd64 (push) Successful in 11m31s
Release Beta / build-linux-arm64 (push) Successful in 13m0s
Test / rust-fmt-check (push) Successful in 18m17s
Test / rust-clippy (push) Successful in 19m43s
Test / rust-tests (push) Successful in 21m41s

Reviewed-on: #128
This commit is contained in:
sarman 2026-06-21 19:11:15 +00:00
commit a2875b60a9
7 changed files with 5815 additions and 51 deletions

6
.gitignore vendored
View File

@ -13,7 +13,7 @@ artifacts/
# kubectl binaries (downloaded during build) # kubectl binaries (downloaded during build)
src-tauri/binaries/ src-tauri/binaries/
SECURITY_AUDIT.md SECURITYAUDIT.md
# Internal / private documents — never commit # Internal / private documents — never commit
USER_GUIDE.md USER_GUIDE.md
@ -25,3 +25,7 @@ PR_DESCRIPTION.md
docs/images/user-guide/ docs/images/user-guide/
*.bak *.bak
.DS_Store .DS_Store
# Logs
.logs/
*.log

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 api_url = config.api_url.trim_end_matches('/');
let url = format!("{api_url}{endpoint_path}"); let url = format!("{api_url}{endpoint_path}");
// Extract system message if present // Extract ALL system messages and combine them (must be at the beginning)
let system_message = messages let system_messages: Vec<String> = messages
.iter() .iter()
.find(|m| m.role == "system") .filter(|m| m.role == "system")
.map(|m| m.content.clone()); .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 // Get last user message as prompt
let prompt = messages let prompt = messages
@ -341,7 +348,7 @@ impl OpenAiProvider {
} }
// Add optional system message // Add optional system message
if let Some(system) = system_message { if let Some(system) = combined_system {
body["system"] = serde_json::Value::String(system); body["system"] = serde_json::Value::String(system);
} }

View File

@ -7,6 +7,13 @@ import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, M
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { confirm } from '@tauri-apps/plugin-dialog'; import { confirm } from '@tauri-apps/plugin-dialog';
import { toast } from 'sonner'; 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 { interface VMInfo {
id: string; id: string;
@ -23,8 +30,26 @@ interface VMInfo {
tags?: string[]; tags?: string[];
} }
interface RawVMInfo {
id: number;
vmid?: number;
name?: string;
node?: string;
status?: string;
cpu?: number;
mem?: number;
max_mem?: number;
memory?: number;
memoryTotal?: number;
disk?: number;
max_disk?: number;
diskTotal?: number;
uptime?: number;
tags?: string[];
}
interface VMListProps { interface VMListProps {
vms: VMInfo[]; vms: RawVMInfo[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
@ -59,7 +84,7 @@ function formatBytes(bytes: number): string {
} }
export function VMList({ export function VMList({
vms, vms: rawVms,
onRefresh, onRefresh,
isLoading, isLoading,
onSnapshotAction, onSnapshotAction,
@ -70,9 +95,30 @@ export function VMList({
onToggleSelect, onToggleSelect,
}: VMListProps) { }: VMListProps) {
const [clusterId, setClusterId] = useState<string>(''); 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(() => { useEffect(() => {
// Use list_proxmox_clusters and select the first cluster
invoke<string[]>('list_proxmox_clusters') invoke<string[]>('list_proxmox_clusters')
.then((clusters: any[]) => { .then((clusters: any[]) => {
if (clusters.length > 0) { if (clusters.length > 0) {
@ -152,48 +198,53 @@ export function VMList({
toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`); toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`);
}, []); }, []);
const handleMigrate = useCallback(async (vm: VMInfo) => { const handleMigrate = useCallback((vm: VMInfo) => {
try { setMigrationVM(vm);
const targetNodes = vms const availableNodes = vms
.map((v) => v.node) .map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
setTargetNode(availableNodes[0] || '');
}, [vms]);
if (targetNodes.length === 0) { const submitMigration = useCallback(async () => {
toast.error('No target nodes available for migration'); if (!migrationVM || !targetNode) {
return; toast.error('Please select a target node');
}
const targetNode = window.prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]);
if (!targetNode) {
toast.info('Migration cancelled');
return; return;
} }
try {
await invoke('migrate_vm', { await invoke('migrate_vm', {
clusterId, clusterId,
nodeId: vm.node, nodeId: migrationVM.node,
vmId: vm.vmid, vmId: migrationVM.vmid,
targetNode, 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?.(); onRefresh?.();
} catch (error) { } catch (error) {
console.error('Failed to migrate VM:', 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) => { const handleClone = useCallback(async (vm: VMInfo) => {
try { 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) { if (!newVmidStr) {
toast.info('Clone cancelled'); toast.info('Clone cancelled');
return; return;
} }
const newVmid = parseInt(newVmidStr); 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`); const newName = window.prompt(`Enter name for cloned VM:`, `${vm.name}-clone`);
if (!newName) { if (!newName) {
toast.info('Clone cancelled'); toast.info('Clone cancelled');
@ -205,19 +256,24 @@ export function VMList({
nodeId: vm.node, nodeId: vm.node,
vmId: vm.vmid, vmId: vm.vmid,
newVmid, 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?.(); onRefresh?.();
} catch (error) { } catch (error) {
console.error('Failed to clone VM:', error); console.error('Failed to clone VM:', error);
toast.error(`Failed to clone VM ${vm.name}: ${error}`); toast.error(`Failed to clone VM ${vm.name}: ${error}`);
} }
}, [clusterId, onRefresh]); }, [clusterId, vms, onRefresh]);
const handleDelete = useCallback(async (vm: VMInfo) => { 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; return;
} }
@ -228,7 +284,7 @@ export function VMList({
vmId: vm.vmid, vmId: vm.vmid,
}); });
toast.success(`VM ${vm.name} deleted`); toast.success(`VM ${vm.name} deleted successfully`);
onRefresh?.(); onRefresh?.();
} catch (error) { } catch (error) {
console.error('Failed to delete VM:', error); console.error('Failed to delete VM:', error);
@ -353,6 +409,20 @@ export function VMList({
</Table> </Table>
</div> </div>
</CardContent> </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> </Card>
); );
} }
@ -548,3 +618,115 @@ function VMActionMenu({
</div> </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 React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, Plus } from 'lucide-react';
import { BackupJobList } from '@/components/Proxmox'; import { BackupJobList } from '@/components/Proxmox';
import { listProxmoxClusters, listProxmoxBackupJobs } from '@/lib/proxmoxClient'; import { listProxmoxClusters, listProxmoxBackupJobs } from '@/lib/proxmoxClient';
import type { ClusterInfo } from '@/lib/domain'; import type { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner'; 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() { export function ProxmoxBackupPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]); const [clusters, setClusters] = useState<ClusterInfo[]>([]);
@ -12,6 +16,13 @@ export function ProxmoxBackupPage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jobs, setJobs] = useState<any[]>([]); const [jobs, setJobs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); 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(() => { useEffect(() => {
listProxmoxClusters() listProxmoxClusters()
@ -29,7 +40,6 @@ export function ProxmoxBackupPage() {
if (!clusterId) return; if (!clusterId) return;
setIsLoading(true); setIsLoading(true);
try { try {
// Backup jobs are cluster-level, not node-level
const data = await listProxmoxBackupJobs(clusterId, ''); const data = await listProxmoxBackupJobs(clusterId, '');
setJobs(data); setJobs(data);
} catch (err) { } catch (err) {
@ -44,6 +54,29 @@ export function ProxmoxBackupPage() {
if (selectedClusterId) loadJobs(selectedClusterId); if (selectedClusterId) loadJobs(selectedClusterId);
}, [selectedClusterId, loadJobs]); }, [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) { if (clusters.length === 0 && !isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -82,6 +115,10 @@ export function ProxmoxBackupPage() {
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
<Button size="sm" onClick={handleNewJob}>
<Plus className="mr-2 h-4 w-4" />
New Job
</Button>
</div> </div>
</div> </div>
@ -89,6 +126,63 @@ export function ProxmoxBackupPage() {
jobs={jobs} jobs={jobs}
onRefresh={() => loadJobs(selectedClusterId)} 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> </div>
); );
} }

View File

@ -2,8 +2,12 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { Badge } 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 { 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() { export function ProxmoxNetworkPage() {
const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]); const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]);
@ -11,6 +15,16 @@ export function ProxmoxNetworkPage() {
const [nodeId] = useState('localhost'); const [nodeId] = useState('localhost');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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) => { const loadInterfaces = useCallback(async (cId: string, nId: string) => {
if (!cId) return; if (!cId) return;
@ -37,6 +51,60 @@ export function ProxmoxNetworkPage() {
.catch(console.error); .catch(console.error);
}, [loadInterfaces, nodeId]); }, [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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -44,15 +112,16 @@ export function ProxmoxNetworkPage() {
<h1 className="text-2xl font-bold">Network</h1> <h1 className="text-2xl font-bold">Network</h1>
<p className="text-muted-foreground">Network interfaces and bridges</p> <p className="text-muted-foreground">Network interfaces and bridges</p>
</div> </div>
<Button <div className="flex items-center space-x-2">
variant="outline" <Button variant="outline" size="sm" onClick={() => void loadInterfaces(clusterId, nodeId)} disabled={loading || !clusterId}>
size="sm"
onClick={() => void loadInterfaces(clusterId, nodeId)}
disabled={loading || !clusterId}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
<Button size="sm" onClick={handleAddInterface}>
<Plus className="mr-2 h-4 w-4" />
Add Interface
</Button>
</div>
</div> </div>
{error && ( {error && (
@ -107,12 +176,101 @@ export function ProxmoxNetworkPage() {
</div> </div>
)} )}
</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>
))} ))}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </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> </div>
); );
} }

View File

@ -25,7 +25,13 @@ export function ProxmoxViewsPage() {
const v = await listClusterViews(cId); const v = await listClusterViews(cId);
setViews(v); setViews(v);
} catch (e) { } 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);
}
} }
}, []); }, []);