253 lines
8.9 KiB
TypeScript
253 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import { use, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import StatusBadge from '@/components/StatusBadge';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import Skeleton from '@/components/ui/Skeleton';
|
|
import { ArrowLeft, Play, Square, RotateCw, HardDrive, Cpu, MemoryStick } from 'lucide-react';
|
|
|
|
interface VMDetails {
|
|
vmid: string;
|
|
name: string;
|
|
status: string;
|
|
mem: string;
|
|
bootdisk: string;
|
|
config: Record<string, string>;
|
|
stats: {
|
|
cpu: number;
|
|
mem: {
|
|
used: number;
|
|
total: number;
|
|
percent: number;
|
|
};
|
|
disk: {
|
|
percent: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
export default function VMDetailPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const resolvedParams = use(params);
|
|
const router = useRouter();
|
|
const [vm, setVm] = useState<VMDetails | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
|
|
const fetchVMDetails = async () => {
|
|
try {
|
|
const res = await fetch(`/api/vms/${resolvedParams.id}`);
|
|
if (!res.ok) throw new Error('Failed to fetch VM details');
|
|
const data = await res.json();
|
|
setVm(data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
|
|
setActionLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/vms/${resolvedParams.id}/action`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action }),
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Failed to perform action');
|
|
|
|
setToast({ message: `VM ${action}ing...`, type: 'success' });
|
|
setTimeout(() => setToast(null), 3000);
|
|
setTimeout(fetchVMDetails, 2000);
|
|
} catch (err) {
|
|
setToast({ message: err instanceof Error ? err.message : 'Action failed', type: 'error' });
|
|
setTimeout(() => setToast(null), 3000);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchVMDetails();
|
|
const interval = setInterval(fetchVMDetails, 5000);
|
|
return () => clearInterval(interval);
|
|
}, [resolvedParams.id]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="container mx-auto px-4 sm:px-6 py-8">
|
|
<Skeleton className="h-10 w-32 mb-6" />
|
|
<Skeleton className="h-24 w-full mb-6" />
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
</div>
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !vm) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
|
<Card hover={false} className="max-w-md">
|
|
<div className="bg-danger/10 border border-danger/30 rounded-xl p-6">
|
|
<p className="text-danger">Error: {error || 'VM not found'}</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground">
|
|
{/* Toast Notification */}
|
|
{toast && (
|
|
<div className="fixed top-4 right-4 z-50 animate-slide-up">
|
|
<div className={`${toast.type === 'success' ? 'bg-success/10 border-success/30 text-success' : 'bg-danger/10 border-danger/30 text-danger'} border rounded-xl px-4 py-3 shadow-elevated`}>
|
|
{toast.message}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="container mx-auto px-4 sm:px-6 py-8">
|
|
{/* Back Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
icon={ArrowLeft}
|
|
onClick={() => router.push('/')}
|
|
className="mb-6"
|
|
>
|
|
Back to Dashboard
|
|
</Button>
|
|
|
|
{/* VM Header */}
|
|
<Card hover={false} className="mb-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl sm:text-4xl font-bold text-foreground mb-2">{vm.name}</h1>
|
|
<p className="text-foreground-muted">VM ID: {vm.vmid}</p>
|
|
</div>
|
|
<StatusBadge status={vm.status} />
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-6">
|
|
<Card hover={false}>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center">
|
|
<Cpu className="w-5 h-5 text-foreground-muted" />
|
|
</div>
|
|
<h3 className="text-sm text-foreground-muted">CPU Usage</h3>
|
|
</div>
|
|
<p className="text-3xl font-bold text-foreground mb-3">{vm.stats.cpu}%</p>
|
|
<div className="h-2 bg-background rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-foreground to-foreground-muted transition-all duration-500"
|
|
style={{ width: `${vm.stats.cpu}%` }}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card hover={false}>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center">
|
|
<MemoryStick className="w-5 h-5 text-foreground-muted" />
|
|
</div>
|
|
<h3 className="text-sm text-foreground-muted">Memory Usage</h3>
|
|
</div>
|
|
<p className="text-3xl font-bold text-foreground mb-1">{vm.stats.mem.percent}%</p>
|
|
<p className="text-xs text-foreground-subtle mb-3">
|
|
{vm.stats.mem.used} MB / {vm.stats.mem.total} MB
|
|
</p>
|
|
<div className="h-2 bg-background rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-foreground to-foreground-muted transition-all duration-500"
|
|
style={{ width: `${vm.stats.mem.percent}%` }}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card hover={false}>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center">
|
|
<HardDrive className="w-5 h-5 text-foreground-muted" />
|
|
</div>
|
|
<h3 className="text-sm text-foreground-muted">Disk Usage</h3>
|
|
</div>
|
|
<p className="text-3xl font-bold text-foreground mb-3">{vm.stats.disk.percent}%</p>
|
|
<div className="h-2 bg-background rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-foreground to-foreground-muted transition-all duration-500"
|
|
style={{ width: `${vm.stats.disk.percent}%` }}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Control Panel */}
|
|
<Card hover={false} className="mb-6">
|
|
<h2 className="text-xl font-bold text-foreground mb-4">Control Panel</h2>
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<Button
|
|
onClick={() => handleAction('start')}
|
|
disabled={actionLoading || vm.status === 'running'}
|
|
variant="secondary"
|
|
icon={Play}
|
|
className="flex-1 border-success/30 hover:bg-success/10 text-success"
|
|
>
|
|
Start
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleAction('stop')}
|
|
disabled={actionLoading || vm.status !== 'running'}
|
|
variant="danger"
|
|
icon={Square}
|
|
className="flex-1"
|
|
>
|
|
Stop
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleAction('restart')}
|
|
disabled={actionLoading || vm.status !== 'running'}
|
|
variant="secondary"
|
|
icon={RotateCw}
|
|
className="flex-1"
|
|
>
|
|
Restart
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Configuration */}
|
|
<Card hover={false}>
|
|
<h2 className="text-xl font-bold text-foreground mb-4">Configuration</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{Object.entries(vm.config).map(([key, value]) => (
|
|
<div key={key} className="pb-3 border-b border-border">
|
|
<p className="text-sm text-foreground-muted mb-1">{key}</p>
|
|
<p className="text-foreground font-mono text-sm">{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|