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
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:
commit
a2875b60a9
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||||
|
|||||||
5313
.logs/subtask2.log
5313
.logs/subtask2.log
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user