fix: frontend-backend integration — 14 issues resolved

- nginx: add proxy routes for /settings/, /templates/, /ipam/, /logs/, /admin/, /health
- dashboard: fix data format mismatch (servers[] not nodes[]/vms[])
- dashboard: add WebSocket subscription to dashboard_metrics for real-time updates
- dashboard: connect activity feed to /logs/api/logs endpoint
- dashboard: reduce polling to 15s with WS as primary update source
- graphs: fix rrddata response unwrap (backend returns {data:[],timeframe:''})
- graphs: add i18n translations for chart titles
- console: add xterm.js CSS import for proper terminal rendering
- console: add i18n translations (connecting, reconnect)
- workspace: add switcher dropdown in TopBar
- workspace: add edit dialog in WorkspacesPage
- workspace: create Zustand store with localStorage persistence
- workspace: add X-Active-Workspace header to API client
- sidebar: replace generic icon with actual favicon.svg logo
- instances: translate RowActionMenu and InstanceDetailPage strings
- locales: add graphs, console, workspace, instance tab keys to en/ru
This commit is contained in:
markmorado
2026-04-07 00:45:43 +02:00
parent 4546a798d0
commit 1d08c815a7
138 changed files with 17826 additions and 32024 deletions

View File

@@ -0,0 +1,87 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store';
import { useTranslation } from 'react-i18next';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const { t } = useTranslation();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await login({ username, password });
navigate('/dashboard');
} catch {
// Error is already handled in store
}
};
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-lg">{t('login.title', 'Sign in to your account')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">{t('login.username', 'Username')}</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
clearError();
}}
placeholder={t('login.username_placeholder', 'Enter username')}
autoComplete="username"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password', 'Password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError();
}}
placeholder={t('login.password_placeholder', 'Enter password')}
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('login.signing_in', 'Signing in...')}
</span>
) : (
t('login.sign_in', 'Sign in')
)}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, Play, Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useServers } from '@/hooks/use-nodes';
import { useBackupJobs, useBackupList, useBackupStorages, useToggleBackupJob, useRunBackupJob } from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
export default function BackupsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState('files');
const [selectedServer, setSelectedServer] = useState<string>('');
const [selectedNode, setSelectedNode] = useState('');
const [selectedStorage, setSelectedStorage] = useState('');
const { data: servers = [] } = useServers();
const sid = selectedServer ? Number(selectedServer) : 0;
const { data: storagesData } = useBackupStorages(sid);
const { data: backupsData } = useBackupList(sid, selectedNode, selectedStorage);
const { data: jobsData } = useBackupJobs();
const toggleJob = useToggleBackupJob();
const runJob = useRunBackupJob();
const storages = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
const backups = (backupsData?.backups || []) as { volid: string; vmid: number; size: number; ctime: number; format: string; notes?: string }[];
const jobs = (jobsData?.jobs || []) as { id: number; server_id: number; vmids: number[]; storage: string; mode: string; cron_expression: string; enabled: boolean; notes?: string }[];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.backups')}</h1>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="files">{t('backups.files')}</TabsTrigger>
<TabsTrigger value="jobs">{t('backups.scheduled_jobs')}</TabsTrigger>
</TabsList>
<TabsContent value="files" className="space-y-4 mt-4">
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(''); setSelectedStorage(''); } }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Input className="w-[160px]" placeholder={t('backups.node')} value={selectedNode} onChange={e => setSelectedNode(e.target.value)} />
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Backup files table */}
{backups.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>VMID</TableHead>
<TableHead>{t('backups.volume')}</TableHead>
<TableHead>{t('backups.size')}</TableHead>
<TableHead>{t('backups.date')}</TableHead>
<TableHead>{t('backups.format')}</TableHead>
<TableHead>{t('backups.notes')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((b, i) => (
<TableRow key={i}>
<TableCell className="font-mono">{b.vmid}</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
<TableCell>{formatBytes(b.size)}</TableCell>
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<Archive className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{selectedServer ? t('backups.no_backups') : t('backups.select_server_first')}</p>
</div>
)}
</TabsContent>
<TabsContent value="jobs" className="space-y-4 mt-4">
{jobs.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t('backups.vmids')}</TableHead>
<TableHead>{t('backups.storage_col')}</TableHead>
<TableHead>{t('backups.schedule')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(job => (
<TableRow key={job.id}>
<TableCell>{job.id}</TableCell>
<TableCell className="font-mono text-xs">{job.vmids?.join(', ') || '—'}</TableCell>
<TableCell>{job.storage}</TableCell>
<TableCell className="font-mono text-xs">{job.cron_expression}</TableCell>
<TableCell>
<Badge variant={job.enabled ? 'default' : 'secondary'}>
{job.enabled ? t('backups.enabled') : t('backups.disabled')}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => toggleJob.mutate(job.id)}>
{job.enabled ? t('backups.disable') : t('backups.enable')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
<Play className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<Calendar className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('backups.no_jobs')}</p>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router';
import { Maximize, Minimize, Keyboard, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiClient } from '@/lib/api-client';
import { vmTypeLabel } from '@/lib/format';
import '@xterm/xterm/css/xterm.css';
// noVNC and xterm.js will be loaded dynamically
// We use dynamic imports for code splitting
interface VNCData {
port: number;
ticket: string;
password?: string;
host: string;
node: string;
vmid: number;
type: string;
auth_ticket?: string;
}
export default function ConsolePage() {
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const node = searchParams.get('node') || '';
const type = searchParams.get('type') || 'qemu';
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [errorMsg, setErrorMsg] = useState('');
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const rfbRef = useRef<unknown>(null);
const termRef = useRef<unknown>(null);
const wsRef = useRef<WebSocket | null>(null);
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
const sid = Number(serverId);
const vid = Number(vmid);
// ==================== VNC Console (QEMU) ====================
const connectVNC = useCallback(async () => {
try {
const data = await apiClient.get<VNCData>(
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
);
// Dynamic import noVNC
// @ts-expect-error noVNC doesn't have TS declarations
const { default: RFB } = await import('@novnc/novnc/lib/rfb');
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const params = new URLSearchParams({
port: String(data.port),
vncticket: data.ticket,
});
if (data.password) params.set('vnc_password', data.password);
if (data.auth_ticket) params.set('auth_ticket', data.auth_ticket);
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
if (!containerRef.current) return;
// Pass URL string to RFB, NOT an open WebSocket
const rfb = new RFB(containerRef.current, wsUrl, {
credentials: { password: data.password || '' },
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
setStatus('connected');
});
rfb.addEventListener('disconnect', (e: CustomEvent) => {
if (e.detail?.clean) {
setStatus('error');
setErrorMsg('Connection closed');
} else {
setStatus('error');
setErrorMsg('Connection lost');
}
});
rfbRef.current = rfb;
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
}
}, [sid, vid, node]);
// ==================== Terminal Console (LXC) ====================
const connectTerminal = useCallback(async () => {
try {
const data = await apiClient.get<VNCData>(
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
);
// Dynamic import xterm.js
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
]);
if (!containerRef.current) return;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
theme: {
background: '#09090B',
foreground: '#F0F0F3',
cursor: '#F0F0F3',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
// Wait a frame, then fit
requestAnimationFrame(() => {
fitAddon.fit();
});
// WebSocket connection
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
// Send resize
const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`);
// Keepalive every 120s
keepaliveRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('2');
}
}, 120000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data));
} else {
terminal.write(event.data);
}
};
ws.onclose = () => {
setStatus('error');
setErrorMsg('Terminal connection closed');
};
ws.onerror = () => {
setStatus('error');
setErrorMsg('Terminal connection error');
};
// User input → WebSocket
terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
const encoder = new TextEncoder();
const byteLength = encoder.encode(data).byteLength;
ws.send(`0:${byteLength}:${data}`);
}
});
// Resize handler
terminal.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`1:${cols}:${rows}:`);
}
});
// Window resize
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
}
}, [sid, vid, node]);
// ==================== Connect on mount ====================
useEffect(() => {
if (type === 'qemu') {
connectVNC();
} else {
connectTerminal();
}
return () => {
// Cleanup
if (rfbRef.current) {
try { (rfbRef.current as { disconnect: () => void }).disconnect(); } catch { /* */ }
}
if (wsRef.current) {
wsRef.current.close();
}
if (keepaliveRef.current) {
clearInterval(keepaliveRef.current);
}
if (termRef.current) {
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
t.cleanup();
t.terminal.dispose();
}
};
}, [type, connectVNC, connectTerminal]);
// ==================== Fullscreen ====================
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
// ==================== Send Ctrl+Alt+Del (VNC) ====================
const sendCtrlAltDel = () => {
if (rfbRef.current) {
(rfbRef.current as { sendCtrlAltDel: () => void }).sendCtrlAltDel();
}
};
return (
<div className="flex h-screen flex-col bg-[#09090B]">
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border/30 bg-[#111113] px-3 py-1.5">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs border-border/50">
{vmTypeLabel(type)} #{vmid}
</Badge>
<span className="text-xs text-muted-foreground">Node: {node}</span>
<Badge
variant="secondary"
className={`text-[10px] ${
status === 'connected'
? 'bg-green-500/10 text-green-500'
: status === 'connecting'
? 'bg-amber-500/10 text-amber-500'
: 'bg-red-500/10 text-red-500'
}`}
>
{status}
</Badge>
</div>
<div className="flex items-center gap-1">
{type === 'qemu' && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={sendCtrlAltDel}
>
<Keyboard className="mr-1 h-3 w-3" />
Ctrl+Alt+Del
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize className="h-3.5 w-3.5" /> : <Maximize className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* Console viewport */}
<div className="relative flex-1">
{status === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">{t('console.connecting')}</p>
</div>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-red-500">{errorMsg}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setStatus('connecting');
setErrorMsg('');
if (type === 'qemu') connectVNC();
else connectTerminal();
}}
>
{t('console.reconnect')}
</Button>
</div>
</div>
)}
<div
ref={containerRef}
className="h-full w-full"
style={{ minHeight: 'calc(100vh - 40px)' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,344 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Server,
Monitor,
Container,
AlertTriangle,
Cpu,
MemoryStick,
HardDrive,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { apiClient } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
import { getTasksWebSocket } from '@/lib/websocket';
// ==================== Stat Card ====================
interface StatCardProps {
label: string;
value: number | string;
subtitle?: string;
icon: React.ComponentType<{ className?: string }>;
color?: string;
}
function StatCard({ label, value, subtitle, icon: Icon, color = 'text-primary' }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 ${color}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="text-2xl font-bold tabular-nums">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</CardContent>
</Card>
);
}
// ==================== Resource Card ====================
interface ResourceCardProps {
label: string;
percent: number;
used: string;
total: string;
icon: React.ComponentType<{ className?: string }>;
}
function ResourceCard({ label, percent, used, total, icon: Icon }: ResourceCardProps) {
const barColor =
percent >= 90 ? 'bg-red' : percent >= 70 ? 'bg-amber' : 'bg-green';
return (
<Card>
<CardContent className="p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
</div>
<p className="mb-2 text-2xl font-bold tabular-nums">{percent.toFixed(1)}%</p>
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{used} / {total}
</p>
</CardContent>
</Card>
);
}
// ==================== Activity Item ====================
interface ActivityItem {
time: string;
user?: string;
action: string;
resource?: string;
type?: string;
}
function ActivityFeed({ items }: { items: ActivityItem[] }) {
const { t } = useTranslation();
const typeColor: Record<string, string> = {
success: 'bg-green/20 text-green',
error: 'bg-red/20 text-red',
warning: 'bg-amber/20 text-amber',
info: 'bg-blue/20 text-blue',
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t('dashboard.recent_activity', 'Recent Activity')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data', 'No recent activity')}</p>
) : (
items.map((item, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground font-mono tabular-nums">
{item.time}
</span>
<div className="min-w-0 flex-1">
<span className="text-muted-foreground">{item.user && `${item.user} `}</span>
<span>{item.action}</span>
{item.resource && (
<span className="font-medium"> {item.resource}</span>
)}
</div>
{item.type && (
<Badge variant="secondary" className={`shrink-0 text-[10px] ${typeColor[item.type] ?? ''}`}>
{item.type}
</Badge>
)}
</div>
))
)}
</CardContent>
</Card>
);
}
// ==================== Format Helpers ====================
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));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
// ==================== Dashboard Page ====================
export default function DashboardPage() {
const { t } = useTranslation();
const qc = useQueryClient();
// Fetch all resources
const { data: resources } = useQuery({
queryKey: ['resources'],
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
refetchInterval: 15000,
});
// Fetch alerts
const { data: alerts } = useQuery({
queryKey: ['dashboard-alerts'],
queryFn: () => apiClient.get<{ alerts: unknown[]; count: number }>('/api/dashboard/alerts'),
refetchInterval: 60000,
});
// Fetch recent activity from audit logs
const { data: recentLogs } = useQuery({
queryKey: ['dashboard-activity'],
queryFn: () => apiClient.get<{ items: Array<{ timestamp: string; username?: string; message: string; category?: string; level?: string }> }>('/logs/api/logs?limit=10'),
refetchInterval: 30000,
});
// Subscribe to WebSocket 'dashboard_metrics' for real-time updates
useEffect(() => {
const ws = getTasksWebSocket();
if (!ws.isConnected) ws.connect();
const unsub = ws.subscribe('dashboard_metrics', (data: unknown) => {
const payload = data as { data?: Record<string, unknown> };
if (payload?.data) {
qc.setQueryData(['resources'], payload.data);
}
});
return unsub;
}, [qc]);
// Parse resource data — API returns { servers: [{vms, containers, nodes}], total_vms, total_containers }
const serverList = ((resources as Record<string, unknown>)?.servers as Array<Record<string, unknown>>) ?? [];
const allVMs = serverList.flatMap((s) => [
...((s.vms as Array<Record<string, unknown>>) ?? []),
...((s.containers as Array<Record<string, unknown>>) ?? []),
]);
const nodesOnline = serverList.filter((s) => !s.error).length;
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
const lxcCTs = allVMs.filter((v) => v.type === 'lxc');
const runningVMs = qemuVMs.filter((v) => v.status === 'running').length;
const runningCTs = lxcCTs.filter((v) => v.status === 'running').length;
// Aggregate resource metrics from node-level data (WS) or fallback to VM-level
const nodeStats = serverList.flatMap((s) => (s.nodes as Array<Record<string, unknown>>) ?? []);
let cpuPercent = 0;
let cpuUsedLabel = '';
let cpuTotalLabel = '';
let memPercent = 0;
let memUsedLabel = '';
let memTotalLabel = '';
let diskPercent = 0;
let diskUsedLabel = '';
let diskTotalLabel = '';
if (nodeStats.length > 0) {
// Use precise node-level stats from WebSocket metrics
cpuPercent = nodeStats.reduce((s, n) => s + (Number(n.cpu_pct) || 0), 0) / nodeStats.length;
cpuUsedLabel = `${cpuPercent.toFixed(1)}%`;
cpuTotalLabel = `${nodeStats.length} nodes`;
memPercent = nodeStats.reduce((s, n) => s + (Number(n.ram_pct) || 0), 0) / nodeStats.length;
memUsedLabel = `${memPercent.toFixed(1)}%`;
memTotalLabel = `${nodeStats.length} nodes`;
diskPercent = nodeStats.reduce((s, n) => s + (Number(n.disk_pct) || 0), 0) / nodeStats.length;
diskUsedLabel = `${diskPercent.toFixed(1)}%`;
diskTotalLabel = `${nodeStats.length} nodes`;
} else {
// Fallback: aggregate from VM data
const totalCPU = allVMs.reduce((s, v) => s + (Number(v.maxcpu) || 0), 0);
const usedCPU = allVMs.reduce((s, v) => s + (Number(v.cpu) || 0) * (Number(v.maxcpu) || 0), 0);
cpuPercent = totalCPU > 0 ? (usedCPU / totalCPU) * 100 : 0;
cpuUsedLabel = `${usedCPU.toFixed(1)} cores`;
cpuTotalLabel = `${totalCPU} cores`;
const totalMem = allVMs.reduce((s, v) => s + (Number(v.maxmem) || 0), 0);
const usedMem = allVMs.reduce((s, v) => s + (Number(v.mem) || 0), 0);
memPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
memUsedLabel = formatBytes(usedMem);
memTotalLabel = formatBytes(totalMem);
const totalDisk = allVMs.reduce((s, v) => s + (Number(v.maxdisk) || 0), 0);
const usedDisk = allVMs.reduce((s, v) => s + (Number(v.disk) || 0), 0);
diskPercent = totalDisk > 0 ? (usedDisk / totalDisk) * 100 : 0;
diskUsedLabel = formatBytes(usedDisk);
diskTotalLabel = formatBytes(totalDisk);
}
return (
<div className="space-y-6">
<h1 className="text-[22px] font-semibold">{t('dashboard.title', 'Dashboard')}</h1>
{/* Stat Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('dashboard.nodes', 'Nodes')}
value={serverList.length}
subtitle={`${nodesOnline} online`}
icon={Server}
/>
<StatCard
label={t('dashboard.virtual_machines', 'Virtual Machines')}
value={qemuVMs.length}
subtitle={`${runningVMs} running`}
icon={Monitor}
color="text-blue"
/>
<StatCard
label={t('dashboard.containers', 'Containers')}
value={lxcCTs.length}
subtitle={`${runningCTs} running`}
icon={Container}
color="text-green"
/>
<StatCard
label={t('dashboard.alerts', 'Alerts')}
value={alerts?.count ?? 0}
icon={AlertTriangle}
color="text-amber"
/>
</div>
{/* Resource Cards */}
<div className="grid gap-4 sm:grid-cols-3">
<ResourceCard
label="CPU"
percent={cpuPercent}
used={cpuUsedLabel}
total={cpuTotalLabel}
icon={Cpu}
/>
<ResourceCard
label={t('dashboard.memory', 'Memory')}
percent={memPercent}
used={memUsedLabel}
total={memTotalLabel}
icon={MemoryStick}
/>
<ResourceCard
label={t('dashboard.storage', 'Storage')}
percent={diskPercent}
used={diskUsedLabel}
total={diskTotalLabel}
icon={HardDrive}
/>
</div>
{/* Bottom row */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Recent instances */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t('dashboard.instances', 'Instances')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{allVMs.slice(0, 6).map((vm, i) => (
<div key={i} className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
<span
className={`h-2 w-2 shrink-0 rounded-full ${
vm.status === 'running' ? 'bg-green' : 'bg-muted-foreground'
}`}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{String(vm.name ?? `VM ${vm.vmid}`)}
</span>
<Badge variant="secondary" className="text-[10px]">
{String(vm.type ?? 'vm').toUpperCase()}
</Badge>
<span className="text-xs text-muted-foreground">{String(vm.node ?? '')}</span>
</div>
))}
{allVMs.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
)}
</div>
</CardContent>
</Card>
{/* Activity */}
<ActivityFeed items={(recentLogs?.items ?? []).map((log) => {
const d = new Date(log.timestamp);
return {
time: `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`,
user: log.username,
action: log.message,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARNING' ? 'warning' : log.category === 'auth' ? 'info' : 'success',
};
})} />
</div>
</div>
);
}

View File

@@ -0,0 +1,388 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers } from '@/hooks/use-nodes';
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { apiClient } from '@/lib/api-client';
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
import { toast } from 'sonner';
const STEPS = ['server', 'template', 'config', 'confirm'] as const;
type Step = (typeof STEPS)[number];
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleClose = onClose ?? (() => navigate(-1));
const [step, setStep] = useState<Step>('server');
const stepIdx = STEPS.indexOf(step);
// State
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
const [selectedNode, setSelectedNode] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
const [config, setConfig] = useState<{
name: string;
cores: number;
memory: number;
disk: number;
storage: string;
bridge: string;
ipam_network_id: number | null;
ip_address: string;
gateway: string;
start_after_create: boolean;
cloud_init_user: string;
cloud_init_password: string;
ssh_keys: string;
}>({
name: '',
cores: 2,
memory: 2048,
disk: 30,
storage: 'local-lvm',
bridge: 'vmbr0',
ipam_network_id: null,
ip_address: '',
gateway: '',
start_after_create: true,
cloud_init_user: '',
cloud_init_password: '',
ssh_keys: '',
});
// Queries
const { data: servers = [] } = useServers();
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
const { data: networks = [] } = useIPAMNetworks();
const deploy = useMutation({
mutationFn: (data: VMDeployRequest) => apiClient.post<{ success: boolean; vmid: number; name: string }>('/proxmox/api/deploy', data),
onSuccess: () => {
toast.success(t('wizard.deploy_success', { name: config.name }));
handleClose();
navigate('/instances');
},
onError: (err: Error) => toast.error(err.message),
});
const next = () => {
const i = STEPS.indexOf(step);
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
};
const prev = () => {
const i = STEPS.indexOf(step);
if (i > 0) setStep(STEPS[i - 1]);
};
const canNext = () => {
if (step === 'server') return !!selectedServer;
if (step === 'template') return !!selectedTemplate;
if (step === 'config') return config.name.length >= 2;
return true;
};
const handleDeploy = () => {
if (!selectedTemplate) return;
deploy.mutate({
template_id: selectedTemplate.id,
name: config.name,
target_node: selectedNode || undefined,
cores: config.cores,
memory: config.memory,
disk: config.disk,
target_storage: config.storage,
start_after_create: config.start_after_create,
network_bridge: config.bridge,
ip_address: config.ip_address || undefined,
gateway: config.gateway || undefined,
ipam_network_id: config.ipam_network_id || undefined,
cloud_init_user: config.cloud_init_user || undefined,
cloud_init_password: config.cloud_init_password || undefined,
ssh_keys: config.ssh_keys || undefined,
});
};
// Apply template defaults when template selected
const selectTemplate = (tpl: OSTemplate) => {
setSelectedTemplate(tpl);
setConfig(prev => ({
...prev,
cores: tpl.default_cores,
memory: tpl.default_memory,
disk: tpl.default_disk,
}));
};
return (
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEPS.map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
i < stepIdx ? 'bg-green-500/20 text-green-400' :
i === stepIdx ? 'bg-blue-500/20 text-blue-400 ring-2 ring-blue-500/50' :
'bg-muted text-muted-foreground'
}`}>
{i < stepIdx ? <CheckCircle className="h-4 w-4" /> : i + 1}
</div>
{i < STEPS.length - 1 && <div className={`h-0.5 w-8 ${i < stepIdx ? 'bg-green-500/50' : 'bg-border'}`} />}
</div>
))}
</div>
{/* Step 1: Server */}
{step === 'server' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_server')}</h3>
<div className="grid gap-3 sm:grid-cols-2">
{servers.map(srv => (
<Card
key={srv.id}
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedServer?.id === srv.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
onClick={() => setSelectedServer(srv)}
>
<CardContent className="flex items-center gap-3 p-4">
<Server className="h-8 w-8 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{srv.name}</p>
<p className="text-sm text-muted-foreground font-mono">{srv.ip_address}:{srv.port}</p>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Step 2: Template */}
{step === 'template' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
{groups.map(group => {
const groupTemplates = templates.filter(t => t.group_id === group.id);
if (groupTemplates.length === 0) return null;
return (
<div key={group.id} className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{group.name}</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{groupTemplates.map(tpl => (
<Card
key={tpl.id}
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedTemplate?.id === tpl.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
onClick={() => selectTemplate(tpl)}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
{tpl.group_icon ? (
<span className="text-lg" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
) : (
<Monitor className="h-5 w-5 text-muted-foreground" />
)}
<span className="font-medium text-sm truncate">{tpl.name}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{tpl.default_cores} vCPU · {tpl.default_memory} MB · {tpl.default_disk} GB
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
})}
{templates.length === 0 && (
<p className="text-center text-muted-foreground py-8">{t('wizard.no_templates')}</p>
)}
</div>
)}
{/* Step 3: Configuration */}
{step === 'config' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<div className="grid gap-6 sm:grid-cols-2">
{/* Basic */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.basic')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('common.name')}</Label>
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder="my-vm-01" />
</div>
<div>
<Label>{t('wizard.target_node')}</Label>
<Input value={selectedNode} onChange={e => setSelectedNode(e.target.value)} placeholder={t('wizard.auto_select')} />
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="start_after"
checked={config.start_after_create}
onChange={e => setConfig(p => ({ ...p, start_after_create: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="start_after">{t('wizard.start_after_create')}</Label>
</div>
</CardContent>
</Card>
{/* Resources */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Cpu className="h-4 w-4" />{t('wizard.resources')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>vCPU</Label>
<Input
type="number"
min={selectedTemplate?.min_cores || 1}
value={config.cores}
onChange={e => setConfig(p => ({ ...p, cores: Number(e.target.value) }))}
/>
</div>
<div>
<Label>{t('wizard.memory_mb')}</Label>
<Input
type="number"
min={selectedTemplate?.min_memory || 256}
step={256}
value={config.memory}
onChange={e => setConfig(p => ({ ...p, memory: Number(e.target.value) }))}
/>
</div>
<div>
<Label>{t('wizard.disk_gb')}</Label>
<Input
type="number"
min={selectedTemplate?.min_disk || 5}
value={config.disk}
onChange={e => setConfig(p => ({ ...p, disk: Number(e.target.value) }))}
/>
</div>
</CardContent>
</Card>
{/* Network */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.network')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.bridge')}</Label>
<Input value={config.bridge} onChange={e => setConfig(p => ({ ...p, bridge: e.target.value }))} />
</div>
<div>
<Label>IPAM {t('wizard.network')}</Label>
<Select value={String(config.ipam_network_id || '')} onValueChange={v => setConfig(p => ({ ...p, ipam_network_id: v ? Number(v) : null }))}>
<SelectTrigger><SelectValue placeholder={t('wizard.manual_ip')} /></SelectTrigger>
<SelectContent>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!config.ipam_network_id && (
<>
<div>
<Label>IP</Label>
<Input value={config.ip_address} onChange={e => setConfig(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.10.10.5" />
</div>
<div>
<Label>Gateway</Label>
<Input value={config.gateway} onChange={e => setConfig(p => ({ ...p, gateway: e.target.value }))} placeholder="10.10.10.1" />
</div>
</>
)}
</CardContent>
</Card>
{/* Cloud-init */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">Cloud-Init</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.ci_user')}</Label>
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder="ubuntu" />
</div>
<div>
<Label>{t('wizard.ci_password')}</Label>
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
</div>
<div>
<Label>SSH Keys</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={config.ssh_keys}
onChange={e => setConfig(p => ({ ...p, ssh_keys: e.target.value }))}
placeholder="ssh-rsa AAAA..."
/>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Step 4: Confirm */}
{step === 'confirm' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
<Card>
<CardContent className="p-6 space-y-3">
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
<Row label={t('common.name')} value={config.name} />
<Row label="vCPU" value={String(config.cores)} />
<Row label={t('wizard.memory_mb')} value={`${config.memory} MB`} />
<Row label={t('wizard.disk_gb')} value={`${config.disk} GB`} />
<Row label={t('wizard.bridge')} value={config.bridge} />
{config.ipam_network_id && <Row label="IPAM" value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
{config.ip_address && <Row label="IP" value={config.ip_address} />}
{config.cloud_init_user && <Row label={t('wizard.ci_user')} value={config.cloud_init_user} />}
</CardContent>
</Card>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between border-t pt-4">
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
</Button>
{step === 'confirm' ? (
<Button onClick={handleDeploy} disabled={deploy.isPending}>
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('wizard.deploy')}
</Button>
) : (
<Button onClick={next} disabled={!canNext()}>
{t('wizard.next')}<ChevronRight className="ml-1 h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useParams, useSearchParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Play,
Square,
RotateCcw,
Terminal,
Monitor,
Container,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
import { vmTypeLabel } from '@/lib/format';
import { toast } from 'sonner';
import OverviewTab from './tabs/OverviewTab';
import GraphsTab from './tabs/GraphsTab';
import SnapshotsTab from './tabs/SnapshotsTab';
import NetworkingTab from './tabs/NetworkingTab';
import SettingsTab from './tabs/SettingsTab';
import DestroyTab from './tabs/DestroyTab';
export default function InstanceDetailPage() {
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const node = searchParams.get('node') || '';
const type = searchParams.get('type') || 'qemu';
const activeTab = searchParams.get('tab') || 'overview';
const sid = Number(serverId);
const vid = Number(vmid);
// Find VM from cached list for basic info
const { data: allVMs, isLoading } = useVirtualMachines();
const vm = allVMs?.find((v) => v.server_id === sid && v.vmid === vid);
const power = usePowerAction(sid, vid, type, node);
const isRunning = vm?.status === 'running';
const TypeIcon = type === 'qemu' ? Monitor : Container;
const handlePower = (action: string) => {
power.mutate(
{ action },
{
onSuccess: () => toast.success(`${action} sent successfully`),
onError: (err) => toast.error(err.message),
}
);
};
const setTab = (tab: string) => {
const params = new URLSearchParams(searchParams);
params.set('tab', tab);
setSearchParams(params, { replace: true });
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Back link + header */}
<div>
<Link to="/instances" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-3">
<ArrowLeft className="h-4 w-4" />
{t('nav.instances', 'Instances')}
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<TypeIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-[22px] font-semibold">{vm?.name || `${vmTypeLabel(type)} ${vmid}`}</h1>
<Badge variant="outline" className="text-xs">#{vmid}</Badge>
<Badge variant="secondary" className="text-xs">{vmTypeLabel(type)}</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<StatusDot status={vm?.status || 'unknown'} pulse />
{vm?.status || 'unknown'}
</span>
<span>Node: {node}</span>
{vm?.ip_address && <span className="font-mono">{vm.ip_address}</span>}
</div>
</div>
</div>
{/* Power buttons */}
<div className="flex items-center gap-2">
<Button
render={<Link to={`/console/${serverId}/${vmid}?node=${node}&type=${type}`} target="_blank" />}
variant="outline"
size="sm"
>
<Terminal className="mr-1.5 h-4 w-4" />
{t('common.console', 'Console')}
</Button>
{!isRunning ? (
<Button
size="sm"
onClick={() => handlePower('start')}
disabled={power.isPending}
>
<Play className="mr-1.5 h-4 w-4" />
{t('common.start', 'Start')}
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => handlePower('restart')}
disabled={power.isPending}
>
<RotateCcw className="mr-1.5 h-4 w-4" />
{t('common.restart', 'Restart')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePower('stop')}
disabled={power.isPending}
>
<Square className="mr-1.5 h-4 w-4" />
{t('common.stop', 'Stop')}
</Button>
</>
)}
</div>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">{t('instances.overview', 'Overview')}</TabsTrigger>
<TabsTrigger value="graphs">{t('instances.graphs', 'Graphs')}</TabsTrigger>
<TabsTrigger value="snapshots">{t('common.snapshots', 'Snapshots')}</TabsTrigger>
<TabsTrigger value="networking">{t('instances.networking', 'Networking')}</TabsTrigger>
<TabsTrigger value="settings">{t('nav.settings', 'Settings')}</TabsTrigger>
<TabsTrigger value="destroy" className="text-destructive data-[state=active]:text-destructive">
{t('instances.destroy', 'Destroy')}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<OverviewTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="graphs" className="mt-4">
<GraphsTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="snapshots" className="mt-4">
<SnapshotsTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="networking" className="mt-4">
<NetworkingTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<SettingsTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="destroy" className="mt-4">
<DestroyTab serverId={sid} vmid={vid} type={type} node={node} name={vm?.name} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,598 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
} from '@tanstack/react-table';
import {
Plus,
Search,
Play,
Square,
Trash2,
MoreHorizontal,
Terminal,
RotateCcw,
Camera,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Monitor,
Container,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, useBulkOperation, usePowerAction } from '@/hooks/use-instances';
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
import type { VMInstance } from '@/types';
import { toast } from 'sonner';
// ==================== Inline Progress Bar ====================
function InlineProgress({ value, max, className }: { value: number; max: number; className?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
return (
<div className="flex items-center gap-2">
<div className={`h-1.5 w-16 overflow-hidden rounded-full bg-muted ${className}`}>
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
</div>
);
}
// ==================== Row Action Menu ====================
function RowActionMenu({ vm }: { vm: VMInstance }) {
const { t } = useTranslation();
const power = usePowerAction(vm.server_id!, vm.vmid, vm.type, vm.node);
const isRunning = vm.status === 'running';
const handleAction = (action: string) => {
power.mutate(
{ action },
{
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
onError: (err) => toast.error(err.message),
}
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-8 w-8" />}>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} />}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{!isRunning ? (
<DropdownMenuItem onClick={() => handleAction('start')}>
<Play className="mr-2 h-4 w-4" /> {t('common.start', 'Start')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => handleAction('restart')}>
<RotateCcw className="mr-2 h-4 w-4" /> {t('common.restart', 'Restart')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('stop')}>
<Square className="mr-2 h-4 w-4" /> {t('common.stop', 'Stop')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=snapshots`} />}>
<Camera className="mr-2 h-4 w-4" /> {t('common.snapshots', 'Snapshots')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ==================== Instances Page ====================
export default function InstancesPage() {
const { t } = useTranslation();
const { data: vms, isLoading } = useVirtualMachines();
const bulkOp = useBulkOperation();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [nodeFilter, setNodeFilter] = useState<string>('all');
const [typeTab, setTypeTab] = useState<string>('all');
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Filter data
const filteredVMs = useMemo(() => {
if (!vms) return [];
return vms.filter((vm) => {
// Type tab filter
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
// Status filter
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
// Node filter
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
// Search filter
if (search) {
const q = search.toLowerCase();
const name = (vm.name || '').toLowerCase();
const ip = (vm.ip_address || '').toLowerCase();
const vmid = String(vm.vmid);
if (!name.includes(q) && !ip.includes(q) && !vmid.includes(q)) return false;
}
return true;
});
}, [vms, typeTab, statusFilter, nodeFilter, search]);
// Unique nodes for filter
const uniqueNodes = useMemo(() => {
if (!vms) return [];
return [...new Set(vms.map((v) => v.node))].sort();
}, [vms]);
// Counts for tabs
const counts = useMemo(() => {
if (!vms) return { all: 0, vm: 0, lxc: 0 };
return {
all: vms.length,
vm: vms.filter((v) => v.type === 'qemu').length,
lxc: vms.filter((v) => v.type === 'lxc').length,
};
}, [vms]);
// Selected VMs
const selectedVMs = useMemo(() => {
return Object.keys(rowSelection)
.filter((k) => rowSelection[k])
.map((idx) => filteredVMs[Number(idx)])
.filter(Boolean);
}, [rowSelection, filteredVMs]);
// Bulk action handler
const handleBulk = (action: string) => {
if (!selectedVMs.length) return;
bulkOp.mutate(
{
action,
items: selectedVMs.map((vm) => ({
server_id: vm.server_id!,
vmid: vm.vmid,
vm_type: vm.type,
name: vm.name || String(vm.vmid),
node: vm.node,
})),
},
{
onSuccess: () => {
toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`);
setRowSelection({});
},
onError: (err) => toast.error(err.message),
}
);
};
// ==================== Table Columns ====================
const columns = useMemo<ColumnDef<VMInstance>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded border-border"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded border-border"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
size: 32,
enableSorting: false,
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{t('common.name', 'Name')}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
),
cell: ({ row }) => {
const vm = row.original;
return (
<Link
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
className="group flex items-center gap-2"
>
<StatusDot status={vm.status} pulse />
<div>
<span className="font-medium text-foreground group-hover:text-primary transition-colors">
{vm.name || `VM ${vm.vmid}`}
</span>
<span className="ml-2 text-xs text-muted-foreground">#{vm.vmid}</span>
</div>
</Link>
);
},
},
{
accessorKey: 'status',
header: t('common.status', 'Status'),
cell: ({ getValue }) => {
const status = getValue<string>();
return (
<Badge
variant="secondary"
className={
status === 'running'
? 'bg-green-500/10 text-green-500'
: 'bg-muted text-muted-foreground'
}
>
{status}
</Badge>
);
},
size: 100,
},
{
accessorKey: 'node',
header: t('common.node', 'Node'),
cell: ({ getValue }) => (
<span className="text-muted-foreground">{getValue<string>()}</span>
),
size: 100,
},
{
accessorKey: 'type',
header: t('common.type', 'Type'),
cell: ({ getValue }) => {
const type = getValue<string>();
const Icon = type === 'qemu' ? Monitor : Container;
return (
<Tooltip>
<TooltipTrigger render={<span />}>
<Badge variant="outline" className="gap-1 font-normal">
<Icon className="h-3 w-3" />
{vmTypeLabel(type)}
</Badge>
</TooltipTrigger>
<TooltipContent>{type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'}</TooltipContent>
</Tooltip>
);
},
size: 80,
},
{
id: 'ip',
accessorKey: 'ip_address',
header: 'IP',
cell: ({ getValue }) => {
const ip = getValue<string>();
return ip ? (
<span className="font-mono text-xs">{ip}</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
);
},
size: 130,
},
{
id: 'cpu',
header: 'CPU',
cell: ({ row }) => {
const vm = row.original;
if (!vm.cpu && vm.cpu !== 0) return <span className="text-xs text-muted-foreground">—</span>;
return <InlineProgress value={vm.cpu * 100} max={100} />;
},
size: 120,
},
{
id: 'memory',
header: t('dashboard.memory', 'Memory'),
cell: ({ row }) => {
const vm = row.original;
if (!vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
return (
<Tooltip>
<TooltipTrigger>
<InlineProgress value={vm.mem} max={vm.maxmem} />
</TooltipTrigger>
<TooltipContent>
{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}
</TooltipContent>
</Tooltip>
);
},
size: 120,
},
{
id: 'disk',
header: 'Disk',
cell: ({ row }) => {
const vm = row.original;
if (!vm.disk && !vm.maxdisk) return <span className="text-xs text-muted-foreground">—</span>;
return (
<Tooltip>
<TooltipTrigger>
<InlineProgress value={vm.disk || 0} max={vm.maxdisk || 0} />
</TooltipTrigger>
<TooltipContent>
{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}
</TooltipContent>
</Tooltip>
);
},
size: 120,
},
{
id: 'uptime',
header: 'Uptime',
cell: ({ row }) => {
const up = row.original.uptime;
return <span className="text-xs text-muted-foreground">{up ? formatUptime(up) : '—'}</span>;
},
size: 80,
},
{
id: 'tags',
accessorKey: 'tags',
header: 'Tags',
cell: ({ getValue }) => {
const tags = getValue<string>();
if (!tags) return null;
return (
<div className="flex gap-1 flex-wrap">
{tags.split(/[;,]/).filter(Boolean).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">
{tag.trim()}
</Badge>
))}
</div>
);
},
size: 120,
},
{
id: 'actions',
cell: ({ row }) => <RowActionMenu vm={row.original} />,
size: 48,
enableSorting: false,
},
],
[t]
);
// ==================== TanStack Table ====================
const table = useReactTable({
data: filteredVMs,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
});
// ==================== Render ====================
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-[22px] font-semibold">{t('nav.instances', 'Instances')}</h1>
<Button render={<Link to="/instances/create" />} size="sm">
<Plus className="mr-1.5 h-4 w-4" />
{t('instances.create', 'Create Instance')}
</Button>
</div>
{/* Type Tabs */}
<Tabs value={typeTab} onValueChange={setTypeTab}>
<TabsList>
<TabsTrigger value="all">
All <Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.all}</Badge>
</TabsTrigger>
<TabsTrigger value="vm">
<Monitor className="mr-1 h-3.5 w-3.5" /> VM
<Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.vm}</Badge>
</TabsTrigger>
<TabsTrigger value="lxc">
<Container className="mr-1 h-3.5 w-3.5" /> LXC
<Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.lxc}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Filters bar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t('instances.search_placeholder', 'Search by name, IP, or VMID...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('common.status', 'Status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('instances.all_statuses', 'All statuses')}</SelectItem>
<SelectItem value="running">{t('common.running', 'Running')}</SelectItem>
<SelectItem value="stopped">{t('common.stopped', 'Stopped')}</SelectItem>
</SelectContent>
</Select>
<Select value={nodeFilter} onValueChange={v => { if (v !== null) setNodeFilter(v); }}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('common.node', 'Node')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('instances.all_nodes', 'All nodes')}</SelectItem>
{uniqueNodes.map((n) => (
<SelectItem key={n} value={n}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bulk actions toolbar */}
{selectedVMs.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
<span className="text-sm font-medium">{selectedVMs.length} selected</span>
<Button variant="outline" size="sm" onClick={() => handleBulk('start')} disabled={bulkOp.isPending}>
<Play className="mr-1 h-3 w-3" /> Start
</Button>
<Button variant="outline" size="sm" onClick={() => handleBulk('stop')} disabled={bulkOp.isPending}>
<Square className="mr-1 h-3 w-3" /> Stop
</Button>
<Button variant="destructive" size="sm" onClick={() => handleBulk('delete')} disabled={bulkOp.isPending}>
<Trash2 className="mr-1 h-3 w-3" /> Delete
</Button>
<Button variant="ghost" size="sm" onClick={() => setRowSelection({})}>
Clear
</Button>
</div>
)}
{/* Table */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredVMs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Monitor className="mb-3 h-10 w-10 opacity-40" />
<p className="text-sm font-medium">{t('instances.no_instances', 'No instances found')}</p>
<p className="text-xs">{search ? t('instances.try_other_search', 'Try a different search query') : t('instances.create_first', 'Create your first instance')}</p>
</div>
) : (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Pagination */}
{filteredVMs.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{t('instances.showing', 'Showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
filteredVMs.length
)}{' '}
{t('instances.of', 'of')} {filteredVMs.length}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2 tabular-nums">
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useDeleteVM } from '@/hooks/use-instances';
import { vmTypeLabel } from '@/lib/format';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
name?: string;
}
export default function DestroyTab({ serverId, vmid, type, node, name }: Props) {
const navigate = useNavigate();
const deleteVM = useDeleteVM(serverId, vmid, type, node);
const [confirmation, setConfirmation] = useState('');
const displayName = name || `${vmTypeLabel(type)} ${vmid}`;
const canDelete = confirmation === displayName;
const handleDestroy = () => {
if (!canDelete) return;
deleteVM.mutate(undefined, {
onSuccess: () => {
toast.success(`${displayName} has been destroyed`);
navigate('/instances');
},
onError: (err) => toast.error(err.message),
});
};
return (
<div className="max-w-xl">
<Card className="border-destructive/30">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Destroy {vmTypeLabel(type)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<p className="font-medium">This action is irreversible!</p>
<p className="mt-1 text-destructive/80">
This will permanently delete <strong>{displayName}</strong> (#{vmid}) and all its data,
including disks and snapshots. IP allocations will be released.
</p>
</div>
<div>
<Label htmlFor="confirm-name">
Type <strong className="text-foreground">{displayName}</strong> to confirm
</Label>
<Input
id="confirm-name"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
placeholder={displayName}
className="mt-1"
/>
</div>
<Button
variant="destructive"
onClick={handleDestroy}
disabled={!canDelete || deleteVM.isPending}
className="w-full"
>
{deleteVM.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Permanently Destroy {vmTypeLabel(type)}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { useVMRrddata } from '@/hooks/use-instances';
import { formatBytes } from '@/lib/format';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
const TIMEFRAMES = [
{ value: 'hour', label: '1h' },
{ value: 'day', label: '24h' },
{ value: 'week', label: '7d' },
{ value: 'month', label: '30d' },
{ value: 'year', label: '1y' },
];
function formatTime(ts: number): string {
const d = new Date(ts * 1000);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
interface ChartCardProps {
title: string;
data: Array<Record<string, unknown>>;
dataKey: string;
color: string;
formatValue?: (v: number) => string;
unit?: string;
}
function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<defs>
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
className="text-[10px] fill-muted-foreground"
tick={{ fontSize: 10 }}
/>
<YAxis
className="text-[10px] fill-muted-foreground"
tick={{ fontSize: 10 }}
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
width={50}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(val: any) => [
val != null && typeof val === 'number' && formatValue ? formatValue(val) : `${val ?? ''}${unit || ''}`,
title,
]}
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={1.5}
fill={`url(#grad-${dataKey})`}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const [timeframe, setTimeframe] = useState('hour');
const { data: rrdData, isLoading } = useVMRrddata(serverId, vmid, type, node, timeframe);
// Parse RRD data into chart-friendly format
const chartData = (rrdData as Array<Record<string, unknown>> | undefined)?.map((point) => ({
time: Number(point.time),
cpu: (Number(point.cpu) || 0) * 100,
mem: Number(point.mem) || Number(point.memused) || 0,
maxmem: Number(point.maxmem) || 0,
netin: Number(point.netin) || 0,
netout: Number(point.netout) || 0,
diskread: Number(point.diskread) || 0,
diskwrite: Number(point.diskwrite) || 0,
})) || [];
return (
<div className="space-y-4">
{/* Timeframe selector */}
<div className="flex gap-1">
{TIMEFRAMES.map((tf) => (
<Button
key={tf.value}
variant={timeframe === tf.value ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeframe(tf.value)}
>
{tf.label}
</Button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard
title={t('graphs.cpu_usage')}
data={chartData}
dataKey="cpu"
color="hsl(217, 91%, 60%)"
formatValue={(v) => `${v.toFixed(1)}%`}
unit="%"
/>
<ChartCard
title={t('graphs.memory_usage')}
data={chartData}
dataKey="mem"
color="hsl(142, 76%, 36%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.network_in')}
data={chartData}
dataKey="netin"
color="hsl(262, 83%, 58%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.network_out')}
data={chartData}
dataKey="netout"
color="hsl(25, 95%, 53%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.disk_read')}
data={chartData}
dataKey="diskread"
color="hsl(199, 89%, 48%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.disk_write')}
data={chartData}
dataKey="diskwrite"
color="hsl(0, 84%, 60%)"
formatValue={(v) => formatBytes(v)}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { Globe, Wifi } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useVMInterfaces, useVMConfig } from '@/hooks/use-instances';
import { formatBytes } from '@/lib/format';
import { useVMStatus } from '@/hooks/use-instances';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: status } = useVMStatus(serverId, vmid, type, node);
// Extract network configs from VM config (net0, net1, ...)
const netDevices = config
? Object.entries(config)
.filter(([k]) => /^net\d+$/.test(k))
.sort(([a], [b]) => a.localeCompare(b))
: [];
return (
<div className="space-y-4">
{/* Traffic summary */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardContent className="p-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">Network In</p>
<p className="text-xl font-bold tabular-nums">{status?.netin !== undefined ? formatBytes(status.netin) : '—'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">Network Out</p>
<p className="text-xl font-bold tabular-nums">{status?.netout !== undefined ? formatBytes(status.netout) : '—'}</p>
</CardContent>
</Card>
</div>
{/* Configured network devices */}
{netDevices.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
Network Devices
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{netDevices.map(([key, value]) => {
const parts = String(value).split(',');
const bridgePart = parts.find((p) => p.includes('bridge='));
const bridge = bridgePart?.split('=')[1];
const modelPart = parts[0]; // e.g. "virtio=XX:XX:XX:XX:XX:XX"
const [model, mac] = modelPart?.includes('=') ? modelPart.split('=') : [modelPart, ''];
return (
<div key={key} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">{key}</Badge>
<span className="text-sm font-medium">{model}</span>
</div>
{bridge && (
<Badge variant="secondary" className="text-xs">{bridge}</Badge>
)}
</div>
{mac && (
<p className="mt-1 font-mono text-xs text-muted-foreground">{mac}</p>
)}
</div>
);
})}
</CardContent>
</Card>
)}
{/* Live interfaces */}
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wifi className="h-4 w-4" />
Live Interfaces (Guest Agent)
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{ifaces.interfaces.map((iface) => (
<div key={iface.name} className="rounded-md border p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{iface.name}</span>
{iface.mac && (
<span className="font-mono text-xs text-muted-foreground">{iface.mac}</span>
)}
</div>
{iface.ips && iface.ips.length > 0 ? (
<div className="space-y-0.5">
{iface.ips.map((ip) => (
<div key={ip.address} className="flex items-center gap-2">
<span className="font-mono text-xs">{ip.address}/{ip.prefix}</span>
<Badge variant="secondary" className="text-[10px]">{ip.type}</Badge>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No addresses</p>
)}
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
function MetricCard({
label,
value,
subtitle,
percent,
icon: Icon,
}: {
label: string;
value: string;
subtitle?: string;
percent?: number;
icon: React.ComponentType<{ className?: string }>;
}) {
const barColor =
percent !== undefined
? percent >= 90
? 'bg-red-500'
: percent >= 70
? 'bg-amber-500'
: 'bg-primary'
: 'bg-primary';
return (
<Card>
<CardContent className="p-4">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<Icon className="h-4 w-4" />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<p className="text-xl font-bold tabular-nums">{value}</p>
{percent !== undefined && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
)}
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
</CardContent>
</Card>
);
}
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
return (
<div className="flex justify-between py-2 border-b border-border/50 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value ?? '—'}</span>
</div>
);
}
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
const { data: status } = useVMStatus(serverId, vmid, type, node);
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
const cpuPercent = status ? (status.cpu || 0) * 100 : 0;
const memPercent = status && status.maxmem ? (status.mem / status.maxmem) * 100 : 0;
const diskPercent = status && status.maxdisk ? (status.disk / status.maxdisk) * 100 : 0;
const primaryIP = ifaces?.interfaces
?.flatMap((i) => i.ips || [])
.find((ip) => ip.type === 'ipv4' && !ip.address.startsWith('127.'));
return (
<div className="space-y-6">
{/* Metric cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="CPU"
value={formatPercent(cpuPercent)}
subtitle={config?.cores ? `${config.cores} cores` : undefined}
percent={cpuPercent}
icon={Cpu}
/>
<MetricCard
label="Memory"
value={status ? formatBytes(status.mem) : '—'}
subtitle={status ? `of ${formatBytes(status.maxmem)}` : undefined}
percent={memPercent}
icon={MemoryStick}
/>
<MetricCard
label="Disk"
value={status ? formatBytes(status.disk) : '—'}
subtitle={status ? `of ${formatBytes(status.maxdisk)}` : undefined}
percent={diskPercent}
icon={HardDrive}
/>
<MetricCard
label="Uptime"
value={status?.uptime ? formatUptime(status.uptime) : '—'}
icon={Clock}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Info card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
Instance Info
</CardTitle>
</CardHeader>
<CardContent>
<InfoRow label="VMID" value={vmid} />
<InfoRow label="Node" value={node} />
<InfoRow label="Type" value={type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'} />
<InfoRow label="vCPU" value={config?.cores} />
<InfoRow label="Memory" value={config?.memory ? `${config.memory} MB` : undefined} />
<InfoRow label="OS Type" value={config?.ostype as string} />
<InfoRow label="Boot Order" value={config?.boot as string} />
<InfoRow label="QEMU Agent" value={config?.agent ? 'Enabled' : 'Disabled'} />
<InfoRow label="Start on Boot" value={config?.onboot ? 'Yes' : 'No'} />
</CardContent>
</Card>
{/* Network card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
Network
</CardTitle>
</CardHeader>
<CardContent>
{primaryIP && <InfoRow label="Primary IP" value={primaryIP.address} />}
{status?.netin !== undefined && (
<InfoRow label="Network In" value={formatBytes(status.netin)} />
)}
{status?.netout !== undefined && (
<InfoRow label="Network Out" value={formatBytes(status.netout)} />
)}
{config?.net0 && <InfoRow label="net0" value={String(config.net0)} />}
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Interfaces</p>
{ifaces.interfaces.map((iface) => (
<div key={iface.name} className="rounded-md border p-2 text-xs">
<div className="flex items-center gap-2 font-medium">
<Wifi className="h-3 w-3 text-muted-foreground" />
{iface.name}
{iface.mac && <span className="text-muted-foreground font-mono">{iface.mac}</span>}
</div>
{iface.ips?.map((ip) => (
<div key={ip.address} className="ml-5 text-muted-foreground font-mono">
{ip.address}/{ip.prefix} ({ip.type})
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react';
import { Settings, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useVMConfig, useUpdateConfig, useResizeDisk } from '@/hooks/use-instances';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type);
const resizeDisk = useResizeDisk(serverId, vmid, type);
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const [diskSize, setDiskSize] = useState('');
const [diskDevice, setDiskDevice] = useState('scsi0');
// Initialize from config
const currentCores = config?.cores || 0;
const currentMemory = config?.memory || 0;
const handleConfigUpdate = () => {
const updates: Record<string, unknown> = {};
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
if (memory && Number(memory) !== currentMemory) updates.memory = Number(memory);
if (Object.keys(updates).length === 0) {
toast.info('No changes to apply');
return;
}
updateConfig.mutate(updates, {
onSuccess: () => {
toast.success('Configuration updated');
setCores('');
setMemory('');
},
onError: (err) => toast.error(err.message),
});
};
const handleDiskResize = () => {
if (!diskSize) return;
resizeDisk.mutate(
{ disk: diskDevice, size: `${diskSize}G` },
{
onSuccess: () => {
toast.success(`Disk resized to ${diskSize}G`);
setDiskSize('');
},
onError: (err) => toast.error(err.message),
}
);
};
return (
<div className="space-y-6 max-w-2xl">
{/* CPU & Memory */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />
Compute Resources
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="cores">vCPU Cores</Label>
<Input
id="cores"
type="number"
min={1}
max={128}
placeholder={String(currentCores)}
value={cores}
onChange={(e) => setCores(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">Current: {currentCores} cores</p>
</div>
<div>
<Label htmlFor="memory">Memory (MB)</Label>
<Input
id="memory"
type="number"
min={128}
step={128}
placeholder={String(currentMemory)}
value={memory}
onChange={(e) => setMemory(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">Current: {currentMemory} MB</p>
</div>
</div>
<Button onClick={handleConfigUpdate} disabled={updateConfig.isPending} size="sm">
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Apply Changes
</Button>
<p className="text-xs text-muted-foreground">
Note: VM may need to be restarted for changes to take effect.
</p>
</CardContent>
</Card>
{/* Disk Resize */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Disk Resize</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="disk-device">Disk Device</Label>
<Input
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder="scsi0"
/>
</div>
<div>
<Label htmlFor="disk-size">New Size (GB)</Label>
<Input
id="disk-size"
type="number"
min={1}
value={diskSize}
onChange={(e) => setDiskSize(e.target.value)}
placeholder="e.g. 100"
/>
</div>
</div>
<Button
onClick={handleDiskResize}
disabled={!diskSize || resizeDisk.isPending}
size="sm"
variant="outline"
>
{resizeDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Resize Disk
</Button>
<p className="text-xs text-muted-foreground">
Disks can only be enlarged. Use the full target size.
</p>
</CardContent>
</Card>
{/* Raw Config */}
{config && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Raw Configuration</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-auto rounded-md bg-muted p-3 text-xs font-mono max-h-64">
{JSON.stringify(config, null, 2)}
</pre>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,263 @@
import { useState } from 'react';
import { Plus, Trash2, RotateCcw, Loader2, Camera } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
useSnapshots,
useCreateSnapshot,
useDeleteSnapshot,
useRollbackSnapshot,
} from '@/hooks/use-instances';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
const { data, isLoading } = useSnapshots(serverId, vmid, type, node);
const createSnap = useCreateSnapshot(serverId, vmid, type);
const deleteSnap = useDeleteSnapshot(serverId, vmid, type, node);
const rollbackSnap = useRollbackSnapshot(serverId, vmid, type, node);
const [showCreate, setShowCreate] = useState(false);
const [snapName, setSnapName] = useState('');
const [snapDesc, setSnapDesc] = useState('');
const [includeRAM, setIncludeRAM] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [rollbackTarget, setRollbackTarget] = useState<string | null>(null);
const snapshots = data?.snapshots || [];
const handleCreate = () => {
if (!snapName.trim()) return;
createSnap.mutate(
{ snapname: snapName.trim(), description: snapDesc, vmstate: includeRAM },
{
onSuccess: () => {
toast.success(`Snapshot "${snapName}" created`);
setShowCreate(false);
setSnapName('');
setSnapDesc('');
setIncludeRAM(false);
},
onError: (err) => toast.error(err.message),
}
);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteSnap.mutate(deleteTarget, {
onSuccess: () => {
toast.success(`Snapshot "${deleteTarget}" deleted`);
setDeleteTarget(null);
},
onError: (err) => toast.error(err.message),
});
};
const handleRollback = () => {
if (!rollbackTarget) return;
rollbackSnap.mutate(rollbackTarget, {
onSuccess: () => {
toast.success(`Rolled back to "${rollbackTarget}"`);
setRollbackTarget(null);
},
onError: (err) => toast.error(err.message),
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{snapshots.length} snapshot{snapshots.length !== 1 ? 's' : ''}
</h3>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Create Snapshot
</Button>
</div>
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : snapshots.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Camera className="mb-2 h-8 w-8 opacity-40" />
<p className="text-sm">No snapshots</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Date</TableHead>
<TableHead>RAM</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snap) => (
<TableRow key={snap.name}>
<TableCell className="font-medium">{snap.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{snap.description || '—'}
</TableCell>
<TableCell className="text-sm tabular-nums">
{snap.snaptime
? new Date(snap.snaptime * 1000).toLocaleString()
: '—'}
</TableCell>
<TableCell>
{snap.vmstate ? (
<Badge variant="secondary" className="text-[10px]">RAM</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setRollbackTarget(snap.name)}
title="Rollback"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(snap.name)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Snapshot</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="snap-name">Name</Label>
<Input
id="snap-name"
value={snapName}
onChange={(e) => setSnapName(e.target.value)}
placeholder="e.g. before-update"
/>
</div>
<div>
<Label htmlFor="snap-desc">Description (optional)</Label>
<Input
id="snap-desc"
value={snapDesc}
onChange={(e) => setSnapDesc(e.target.value)}
placeholder="Description"
/>
</div>
{type === 'qemu' && (
<div className="flex items-center gap-2">
<input
id="snap-ram"
type="checkbox"
checked={includeRAM}
onChange={(e) => setIncludeRAM(e.target.checked)}
className="rounded border-border"
/>
<Label htmlFor="snap-ram" className="text-sm">Include VM memory (RAM state)</Label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!snapName.trim() || createSnap.isPending}>
{createSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Snapshot</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete snapshot <strong>"{deleteTarget}"</strong>? This cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteSnap.isPending}>
{deleteSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rollback confirmation */}
<Dialog open={!!rollbackTarget} onOpenChange={() => setRollbackTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback to Snapshot</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to rollback to <strong>"{rollbackTarget}"</strong>?
Current state will be lost.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRollbackTarget(null)}>Cancel</Button>
<Button onClick={handleRollback} disabled={rollbackSnap.isPending}>
{rollbackSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Rollback
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useIPAMAllocations, useIPAMNetworks, useDeleteAllocation } from '@/hooks/use-ipam';
import { Trash2 } from 'lucide-react';
export default function IPAMAllocationsPage() {
const { t } = useTranslation();
const [networkId, setNetworkId] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const { data: networks = [] } = useIPAMNetworks();
const { data: allocations = [] } = useIPAMAllocations({
network_id: networkId !== 'all' ? Number(networkId) : undefined,
limit: 200,
});
const deleteAllocation = useDeleteAllocation();
const filtered = allocations.filter(a => {
if (statusFilter !== 'all' && a.status !== statusFilter) return false;
if (search) {
const s = search.toLowerCase();
return (
a.ip_address?.toLowerCase().includes(s) ||
a.resource_name?.toLowerCase().includes(s) ||
a.mac_address?.toLowerCase().includes(s) ||
a.description?.toLowerCase().includes(s)
);
}
return true;
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('ipam.allocations')}</h1>
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<Select value={networkId} onValueChange={v => { if (v !== null) setNetworkId(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('ipam.network')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
<SelectItem value="allocated">Allocated</SelectItem>
<SelectItem value="reserved">Reserved</SelectItem>
<SelectItem value="available">Available</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('ipam.network')}</TableHead>
<TableHead>{t('ipam.resource')}</TableHead>
<TableHead>MAC</TableHead>
<TableHead>{t('ipam.description')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(a => (
<TableRow key={a.id}>
<TableCell className="font-mono">{a.ip_address}</TableCell>
<TableCell>
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
</TableCell>
<TableCell className="text-xs">{a.network_name || '—'}</TableCell>
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteAllocation.mutate(a.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { Network, Globe, AlertTriangle, Activity } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useIPAMSummary, useIPAMNetworks } from '@/hooks/use-ipam';
export default function IPAMDashboardPage() {
const { t } = useTranslation();
const { data: summary } = useIPAMSummary();
const { data: networks = [] } = useIPAMNetworks();
const s = summary as Record<string, number> | undefined;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard icon={Globe} label={t('ipam.networks_count')} value={s?.networks_count ?? 0} to="/ipam/networks" />
<SummaryCard icon={Network} label={t('ipam.total_allocated')} value={s?.total_allocated ?? 0} to="/ipam/allocations" />
<SummaryCard icon={Activity} label={t('ipam.total_reserved')} value={s?.total_reserved ?? 0} />
<SummaryCard icon={AlertTriangle} label={t('ipam.vm_count')} value={(s?.vm_count ?? 0) + (s?.lxc_count ?? 0)} />
</div>
{/* Quick network overview */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('ipam.networks')}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{networks.slice(0, 6).map(net => (
<Link key={net.id} to={`/ipam/network/${net.id}`}>
<Card className="hover:border-blue-500/50 transition-colors h-full">
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{net.name}</span>
<span className="text-xs font-mono text-muted-foreground">{net.network}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(net.utilization_percent || 0, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0}</span>
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{networks.length > 6 && (
<Link to="/ipam/networks" className="text-sm text-blue-400 hover:underline">{t('ipam.view_all_networks')}</Link>
)}
</div>
</div>
);
}
function SummaryCard({ icon: Icon, label, value, to }: { icon: React.ElementType; label: string; value: number; to?: string }) {
const content = (
<Card className={to ? 'hover:border-blue-500/50 transition-colors' : ''}>
<CardContent className="p-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
return to ? <Link to={to}>{content}</Link> : content;
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, History } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useIPAMHistory, useIPAMNetworks } from '@/hooks/use-ipam';
export default function IPAMHistoryPage() {
const { t } = useTranslation();
const [networkId, setNetworkId] = useState<string>('all');
const [search, setSearch] = useState('');
const { data: networks = [] } = useIPAMNetworks();
const { data: history = [] } = useIPAMHistory(networkId !== 'all' ? { network_id: Number(networkId) } : undefined);
const filtered = history.filter(h => {
if (!search) return true;
const s = search.toLowerCase();
return (
h.action?.toLowerCase().includes(s) ||
h.ip_address?.toLowerCase().includes(s) ||
h.username?.toLowerCase().includes(s) ||
h.details?.toLowerCase().includes(s)
);
});
const actionColor = (action: string) => {
if (action.includes('create') || action.includes('allocate')) return 'default';
if (action.includes('delete') || action.includes('release')) return 'destructive';
return 'secondary';
};
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<History className="h-6 w-6" />
<h1 className="text-2xl font-bold">{t('ipam.history')}</h1>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<Select value={networkId} onValueChange={v => { if (v !== null) setNetworkId(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('ipam.network')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.date')}</TableHead>
<TableHead>{t('ipam.action')}</TableHead>
<TableHead>IP</TableHead>
<TableHead>{t('ipam.network')}</TableHead>
<TableHead>{t('common.user')}</TableHead>
<TableHead>{t('ipam.details')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((h, i) => (
<TableRow key={h.id ?? i}>
<TableCell className="text-xs whitespace-nowrap">{h.created_at ? new Date(h.created_at).toLocaleString() : '—'}</TableCell>
<TableCell><Badge variant={actionColor(h.action || '') as any}>{h.action}</Badge></TableCell>
<TableCell className="font-mono text-xs">{h.ip_address || '—'}</TableCell>
<TableCell className="text-xs">{h.network_name || '—'}</TableCell>
<TableCell className="text-xs">{h.username || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[300px] truncate">{h.details || '—'}</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useIPAMNetwork, useIPAMNetworkStats, useIPAMAllocations, useIPAMPools } from '@/hooks/use-ipam';
export default function IPAMNetworkDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const nid = Number(id);
const { data: network } = useIPAMNetwork(nid);
const { data: stats } = useIPAMNetworkStats(nid);
const { data: pools = [] } = useIPAMPools(nid);
const { data: allocations = [] } = useIPAMAllocations({ network_id: nid, limit: 100 });
if (!network) return <div className="py-12 text-center text-muted-foreground">{t('common.loading')}</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button render={<Link to="/ipam/networks" />} variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
<div>
<h1 className="text-2xl font-bold">{network.name}</h1>
<p className="text-sm text-muted-foreground font-mono">{network.network}</p>
</div>
<Badge variant={network.is_active ? 'default' : 'secondary'} className="ml-auto">{network.is_active ? 'Active' : 'Inactive'}</Badge>
</div>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-4">
<StatCard label={t('ipam.total_ips')} value={stats?.total_ips ?? network.total_ips ?? 0} />
<StatCard label={t('ipam.allocated')} value={stats?.allocated_ips ?? network.used_ips ?? 0} />
<StatCard label={t('ipam.reserved')} value={stats?.reserved_ips ?? 0} />
<StatCard label={t('ipam.available')} value={stats?.available_ips ?? network.available_ips ?? 0} />
</div>
{/* Utilization bar */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{t('ipam.utilization')}</span>
<span className="text-sm">{(stats?.utilization_percent ?? network.utilization_percent ?? 0).toFixed(1)}%</span>
</div>
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(stats?.utilization_percent ?? network.utilization_percent ?? 0, 100)}%` }}
/>
</div>
</CardContent>
</Card>
{/* Network info */}
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.network_info')}</CardTitle></CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<InfoRow label="Gateway" value={network.gateway || '—'} />
<InfoRow label="VLAN" value={network.vlan_id ? String(network.vlan_id) : '—'} />
<InfoRow label="DNS" value={network.dns_primary || '—'} />
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}
</CardContent>
</Card>
{/* Pools */}
{pools.length > 0 && (
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.pools')} ({pools.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead>{t('ipam.range')}</TableHead>
<TableHead>{t('ipam.usage')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pools.map(pool => (
<TableRow key={pool.id}>
<TableCell className="font-medium">{pool.name}</TableCell>
<TableCell><Badge variant="outline">{pool.pool_type}</Badge></TableCell>
<TableCell className="font-mono text-xs">{pool.range_start} — {pool.range_end}</TableCell>
<TableCell className="text-xs">{pool.used_ips ?? 0} / {pool.total_ips ?? 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Allocations */}
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.allocations')} ({allocations.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('ipam.resource')}</TableHead>
<TableHead>MAC</TableHead>
<TableHead>{t('ipam.description')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allocations.map(a => (
<TableRow key={a.id}>
<TableCell className="font-mono">{a.ip_address}</TableCell>
<TableCell>
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
</TableCell>
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{a.description || '—'}</TableCell>
</TableRow>
))}
{allocations.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</CardContent>
</Card>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Plus, Globe, Search } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useIPAMNetworks, useCreateNetwork } from '@/hooks/use-ipam';
import { toast } from 'sonner';
export default function IPAMNetworksPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState({ name: '', network: '', gateway: '', description: '' });
const { data: networks = [], isLoading } = useIPAMNetworks();
const createNet = useCreateNetwork();
const filtered = networks.filter(n =>
n.name.toLowerCase().includes(search.toLowerCase()) ||
n.network.includes(search)
);
const handleCreate = () => {
createNet.mutate(form, {
onSuccess: () => { toast.success(t('ipam.network_created')); setDialogOpen(false); setForm({ name: '', network: '', gateway: '', description: '' }); },
onError: (err: Error) => toast.error(err.message),
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('ipam.networks')}</h1>
<Button onClick={() => setDialogOpen(true)}><Plus className="mr-2 h-4 w-4" />{t('ipam.add_network')}</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
</div>
{isLoading ? (
<p className="text-center py-12 text-muted-foreground">{t('common.loading')}</p>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Globe className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('ipam.no_networks')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map(net => (
<Link key={net.id} to={`/ipam/network/${net.id}`}>
<Card className="hover:border-blue-500/50 transition-colors h-full">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="font-semibold">{net.name}</span>
<Badge variant={net.is_active ? 'default' : 'secondary'}>{net.is_active ? 'Active' : 'Inactive'}</Badge>
</div>
<p className="text-sm font-mono text-muted-foreground">{net.network}</p>
{net.gateway && <p className="text-xs text-muted-foreground">GW: {net.gateway}</p>}
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${Math.min(net.utilization_percent || 0, 100)}%` }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0} {t('ipam.ips_used')}</span>
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
</div>
{net.server_name && <p className="text-xs text-muted-foreground">{net.server_name}</p>}
</CardContent>
</Card>
</Link>
))}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ipam.add_network')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
<div><Label>CIDR</Label><Input value={form.network} onChange={e => setForm(p => ({ ...p, network: e.target.value }))} placeholder="10.0.0.0/24" /></div>
<div><Label>Gateway</Label><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} placeholder="10.0.0.1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></div>
<Button onClick={handleCreate} disabled={!form.name || !form.network || createNet.isPending} className="w-full">{t('common.create')}</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useServer, useNodes } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import { StatusDot } from '@/components/shared/status-dot';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
export default function NodeDetailPage() {
const { t } = useTranslation();
const { serverId } = useParams<{ serverId: string }>();
const sid = Number(serverId);
const { data: server } = useServer(sid);
const { data: nodesData } = useNodes(sid);
const { data: allVMs = [] } = useVirtualMachines();
const nodes = nodesData?.nodes || [];
const serverVMs = allVMs.filter(v => v.server_id === sid);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button render={<Link to="/nodes" />} variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold">{server?.name || `Server #${sid}`}</h1>
<p className="text-sm text-muted-foreground font-mono">{server?.ip_address}:{server?.port}</p>
</div>
<Badge variant={server?.is_online ? 'default' : 'destructive'} className="ml-auto">
{server?.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>
{/* Nodes in cluster */}
{nodes.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.cluster_nodes')}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map(node => (
<Card key={node.node}>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="font-medium">{node.node}</span>
<StatusDot status={node.status} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1"><Cpu className="h-3 w-3" />{formatPercent(node.cpu || 0)} CPU</div>
<div className="flex items-center gap-1"><MemoryStick className="h-3 w-3" />{formatBytes(node.mem || 0)} / {formatBytes(node.maxmem || 0)}</div>
<div className="flex items-center gap-1"><HardDrive className="h-3 w-3" />{formatBytes(node.disk || 0)} / {formatBytes(node.maxdisk || 0)}</div>
<div className="flex items-center gap-1"><Clock className="h-3 w-3" />{formatUptime(node.uptime || 0)}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* VMs on this server */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
<div className="rounded-lg border divide-y">
{serverVMs.map(vm => (
<Link
key={`${vm.server_id}-${vm.vmid}`}
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
>
<StatusDot status={vm.status} />
<span className="font-medium min-w-0 truncate flex-1">{vm.name || `VM ${vm.vmid}`}</span>
<Badge variant="outline" className="text-xs">{vm.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
<span className="text-xs text-muted-foreground">#{vm.vmid}</span>
<span className="text-xs text-muted-foreground">{vm.node}</span>
</Link>
))}
{serverVMs.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Server, Monitor, Container } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useServers } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
export default function NodesPage() {
const { t } = useTranslation();
const { data: servers = [], isLoading } = useServers();
const { data: vms = [] } = useVirtualMachines();
if (isLoading) {
return <div className="flex items-center justify-center py-20 text-muted-foreground">{t('common.loading')}</div>;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{servers.map(srv => {
const srvVMs = vms.filter(v => v.server_id === srv.id && v.type === 'qemu');
const srvCTs = vms.filter(v => v.server_id === srv.id && v.type === 'lxc');
return (
<Link key={srv.id} to={`/nodes/${srv.id}`}>
<Card className="transition-colors hover:border-blue-500/50 h-full">
<CardContent className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5" />
</div>
<div>
<p className="font-semibold">{srv.name}</p>
<p className="text-xs text-muted-foreground font-mono">{srv.ip_address}</p>
</div>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Monitor className="h-3.5 w-3.5" />
<span>{srvVMs.length} VM</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Container className="h-3.5 w-3.5" />
<span>{srvCTs.length} LXC</span>
</div>
</div>
{srv.description && (
<p className="text-xs text-muted-foreground truncate">{srv.description}</p>
)}
</CardContent>
</Card>
</Link>
);
})}
</div>
{servers.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
<Server className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('nodes.no_servers')}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import {
useProfile, useUpdateProfile, useChangePassword,
usePanelSettings, useUpdatePanelSettings,
useSecuritySettings, useUpdateSecuritySettings,
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
useAppVersion, useCheckUpdates,
} from '@/hooks/use-settings';
export default function SettingsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.settings')}</h1>
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
<TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>
<TabsTrigger value="security">{t('settings.security')}</TabsTrigger>
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
</TabsList>
<TabsContent value="profile"><ProfileTab /></TabsContent>
<TabsContent value="panel"><PanelTab /></TabsContent>
<TabsContent value="security"><SecurityTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
<TabsContent value="about"><AboutTab /></TabsContent>
</Tabs>
</div>
);
}
function ProfileTab() {
const { t } = useTranslation();
const { data: profile } = useProfile();
const updateProfile = useUpdateProfile();
const changePassword = useChangePassword();
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [currentPwd, setCurrentPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
// Init from profile
const inited = displayName || email;
if (profile && !inited) {
setDisplayName(profile.full_name || '');
setEmail(profile.email || '');
}
return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.profile_info')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.username')}</Label><Input value={profile?.username || ''} disabled className="mt-1" /></div>
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
<Button size="sm" onClick={() => updateProfile.mutate({ full_name: displayName, email })}>{t('common.save')}</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.change_password')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.current_password')}</Label><Input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.new_password')}</Label><Input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.confirm_password')}</Label><Input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} className="mt-1" /></div>
<Button size="sm" onClick={() => {
if (newPwd !== confirmPwd) { toast.error(t('settings.password_mismatch')); return; }
changePassword.mutate({ current_password: currentPwd, new_password: newPwd, confirm_password: confirmPwd });
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
}}>{t('settings.change_password')}</Button>
</CardContent>
</Card>
</div>
);
}
function PanelTab() {
const { t } = useTranslation();
const { data: settings } = usePanelSettings();
const updatePanel = useUpdatePanelSettings();
const [panelName, setPanelName] = useState('');
const [lang, setLang] = useState('');
if (settings && !panelName) {
setPanelName(settings.panel_name || '');
setLang(settings.language || 'ru');
}
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.panel_settings')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.panel_name')}</Label><Input value={panelName} onChange={e => setPanelName(e.target.value)} className="mt-1" /></div>
<div>
<Label>{t('settings.language')}</Label>
<select value={lang} onChange={e => setLang(e.target.value)} className="mt-1 block w-full rounded-md border bg-background px-3 py-2 text-sm">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<Button size="sm" onClick={() => updatePanel.mutate({ panel_name: panelName, language: lang })}>{t('common.save')}</Button>
</CardContent>
</Card>
);
}
function SecurityTab() {
const { t } = useTranslation();
const { data: security } = useSecuritySettings();
const updateSecurity = useUpdateSecuritySettings();
const [maxAttempts, setMaxAttempts] = useState('');
const [lockoutDuration, setLockoutDuration] = useState('');
const [sessionTimeout, setSessionTimeout] = useState('');
if (security && !maxAttempts) {
setMaxAttempts(String(security.max_login_attempts ?? 5));
setLockoutDuration(String(security.lockout_duration ?? 300));
setSessionTimeout(String(security.session_timeout ?? 3600));
}
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.security_settings')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.max_attempts')}</Label><Input type="number" value={maxAttempts} onChange={e => setMaxAttempts(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.lockout_duration')}</Label><Input type="number" value={lockoutDuration} onChange={e => setLockoutDuration(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
<div><Label>{t('settings.session_timeout')}</Label><Input type="number" value={sessionTimeout} onChange={e => setSessionTimeout(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
<Button size="sm" onClick={() => updateSecurity.mutate({
max_login_attempts: Number(maxAttempts),
lockout_duration: Number(lockoutDuration),
session_timeout: Number(sessionTimeout),
})}>{t('common.save')}</Button>
</CardContent>
</Card>
);
}
function NotificationsTab() {
const { t } = useTranslation();
const { data: channels } = useNotificationChannels();
const updateSmtp = useUpdateSMTP();
const updateTg = useUpdateTelegram();
const [smtpHost, setSmtpHost] = useState('');
const [smtpPort, setSmtpPort] = useState('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [smtpFrom, setSmtpFrom] = useState('');
const [tgToken, setTgToken] = useState('');
const [tgChatId, setTgChatId] = useState('');
if (channels?.smtp && !smtpHost) {
setSmtpHost(String(channels.smtp.host ?? ''));
setSmtpPort(String(channels.smtp.port ?? 587));
setSmtpUser(String(channels.smtp.username ?? ''));
setSmtpFrom(String(channels.smtp.from_email ?? ''));
}
if (channels?.telegram && !tgToken) {
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
setTgChatId(String(channels.telegram.chat_id ?? ''));
}
return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-sm">SMTP</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="grid sm:grid-cols-2 gap-3">
<div><Label>Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} className="mt-1" /></div>
<div><Label>Port</Label><Input type="number" value={smtpPort} onChange={e => setSmtpPort(e.target.value)} className="mt-1" /></div>
<div><Label>Username</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} className="mt-1" /></div>
<div><Label>Password</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} className="mt-1" /></div>
<div><Label>From Email</Label><Input value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} className="mt-1" /></div>
</div>
<Button size="sm" onClick={() => updateSmtp.mutate({ host: smtpHost, port: Number(smtpPort), username: smtpUser, password: smtpPass || undefined, from_email: smtpFrom })}>{t('common.save')}</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm">Telegram</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>Bot Token</Label><Input value={tgToken} onChange={e => setTgToken(e.target.value)} className="mt-1" /></div>
<div><Label>Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken })}>{t('common.save')}</Button>
</CardContent>
</Card>
</div>
);
}
function AboutTab() {
const { t } = useTranslation();
const { data: version } = useAppVersion();
const checkUpdates = useCheckUpdates();
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
<Badge variant="outline">{version?.version || '—'}</Badge>
</div>
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()}>
{t('settings.check_updates')}
</Button>
{checkUpdates.data && (
<p className="text-sm">{checkUpdates.data.has_update ? `${t('settings.update_available')}: ${checkUpdates.data.latest_version}` : t('settings.up_to_date')}</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, X, ClipboardList, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAllTasks, useCancelTask, useClearCompletedTasks } from '@/hooks/use-tasks';
export default function TasksPage() {
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState('all');
const { data: tasksData, refetch } = useAllTasks();
const tasks = tasksData?.tasks ?? [];
const cancelTask = useCancelTask();
const clearCompleted = useClearCompletedTasks();
const filtered = tasks.filter(task => {
if (statusFilter === 'all') return true;
return task.status === statusFilter;
});
const statusColor = (status: string) => {
switch (status) {
case 'running': case 'in_progress': return 'default';
case 'completed': case 'success': return 'secondary';
case 'failed': case 'error': return 'destructive';
case 'queued': case 'pending': return 'outline';
default: return 'outline';
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="h-6 w-6" />
<h1 className="text-2xl font-bold">{t('nav.tasks')}</h1>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
</Button>
<Button variant="outline" size="sm" onClick={() => clearCompleted.mutate()}>
<Trash2 className="h-4 w-4 mr-1" />{t('tasks.clear_completed')}
</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
<SelectItem value="running">{t('tasks.running')}</SelectItem>
<SelectItem value="queued">{t('tasks.queued')}</SelectItem>
<SelectItem value="completed">{t('tasks.completed')}</SelectItem>
<SelectItem value="failed">{t('tasks.failed')}</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">{filtered.length} {t('tasks.tasks_count')}</span>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead>{t('tasks.description')}</TableHead>
<TableHead>{t('tasks.server')}</TableHead>
<TableHead>{t('tasks.node')}</TableHead>
<TableHead>{t('common.user')}</TableHead>
<TableHead>{t('tasks.started')}</TableHead>
<TableHead>{t('tasks.progress')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((task, i) => (
<TableRow key={task.id ?? task.upid ?? i}>
<TableCell><Badge variant={statusColor(task.status) as any}>{task.status}</Badge></TableCell>
<TableCell className="text-xs font-mono">{task.type || String(task.task_type ?? '') || '—'}</TableCell>
<TableCell className="text-xs max-w-[250px] truncate">{String(task.description ?? '') || task.upid || '—'}</TableCell>
<TableCell className="text-xs">{task.server_name || '—'}</TableCell>
<TableCell className="text-xs">{task.node || '—'}</TableCell>
<TableCell className="text-xs">{task.user || '—'}</TableCell>
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(task.created_at).toLocaleString() : '—'}</TableCell>
<TableCell className="text-xs">
{task.progress != null ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${task.progress}%` }} />
</div>
<span>{Number(task.progress ?? 0)}%</span>
</div>
) : '—'}
</TableCell>
<TableCell>
{(task.status === 'running' || task.status === 'queued' || task.status === 'in_progress') && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => cancelTask.mutate(Number(task.id ?? 0))}>
<X className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Monitor, Search, Download, Trash2, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTemplates, useTemplateGroups, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
import { useServers } from '@/hooks/use-nodes';
import { toast } from 'sonner';
export default function TemplatesPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string>('');
const [serverFilter, setServerFilter] = useState<string>('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>('');
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [], isLoading } = useTemplates(
groupFilter ? Number(groupFilter) : undefined,
serverFilter ? Number(serverFilter) : undefined,
);
const { data: servers = [] } = useServers();
const autoImport = useAutoImportTemplates();
const deleteTpl = useDeleteTemplate();
const filtered = templates.filter(tpl =>
tpl.name.toLowerCase().includes(search.toLowerCase())
);
const handleImport = () => {
if (!importServerId) return;
autoImport.mutate(Number(importServerId), {
onSuccess: (res) => {
toast.success(`${t('templates.imported')}: ${res.count}`);
setImportDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
<Button onClick={() => setImportDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Select value={groupFilter} onValueChange={v => { if (v !== null) setGroupFilter(v); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
<SelectContent>
<SelectItem value="">{t('templates.all_groups')}</SelectItem>
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={serverFilter} onValueChange={v => { if (v !== null) setServerFilter(v); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
<SelectContent>
<SelectItem value="">{t('templates.all_servers')}</SelectItem>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Templates grid */}
{isLoading ? (
<div className="text-center py-16 text-muted-foreground">{t('common.loading')}</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Monitor className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('templates.no_templates')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map(tpl => (
<Card key={tpl.id} className="group">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{tpl.group_icon ? (
<span className="text-xl" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
) : (
<Monitor className="h-5 w-5 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="font-medium text-sm truncate">{tpl.name}</p>
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
onClick={() => { if (confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">{tpl.default_cores} vCPU</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_memory} MB</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_disk} GB</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{tpl.server_name}</span>
<span>VMID: {tpl.vmid}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('templates.auto_import')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Select value={importServerId} onValueChange={v => { if (v !== null) setImportServerId(v); }}>
<SelectTrigger><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Button onClick={handleImport} disabled={!importServerId || autoImport.isPending} className="w-full">
{autoImport.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('templates.import_btn')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Plus, Shield, Trash2, RotateCcw, Unlock } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
useUsers, useCreateUser, useDeleteUser,
useResetPassword, useUnlockUser,
useRoles, useCreateRole, useDeleteRole,
useSessions, useTerminateSession,
} from '@/hooks/use-users';
export default function UsersPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.users')}</h1>
<Tabs defaultValue="users">
<TabsList>
<TabsTrigger value="users">{t('users.users')}</TabsTrigger>
<TabsTrigger value="roles">{t('users.roles')}</TabsTrigger>
<TabsTrigger value="sessions">{t('users.sessions')}</TabsTrigger>
</TabsList>
<TabsContent value="users"><UsersTab /></TabsContent>
<TabsContent value="roles"><RolesTab /></TabsContent>
<TabsContent value="sessions"><SessionsTab /></TabsContent>
</Tabs>
</div>
);
}
function UsersTab() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState('user');
const { data: users = [] } = useUsers();
const { data: roles = [] } = useRoles();
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const resetPassword = useResetPassword();
const unlockUser = useUnlockUser();
const filtered = users.filter(u => {
if (!search) return true;
const s = search.toLowerCase();
return u.username?.toLowerCase().includes(s) || u.full_name?.toLowerCase().includes(s) || u.email?.toLowerCase().includes(s);
});
const handleCreate = () => {
createUser.mutate({ username, password, full_name: displayName, email }, {
onSuccess: () => { setOpen(false); setUsername(''); setPassword(''); setDisplayName(''); setEmail(''); setRole('user'); },
});
};
return (
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('users.create')}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t('users.create')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('settings.username')}</Label><Input value={username} onChange={e => setUsername(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.new_password')}</Label><Input type="password" value={password} onChange={e => setPassword(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
<div>
<Label>{t('users.role')}</Label>
<Select value={role} onValueChange={v => { if (v !== null) setRole(v); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{roles.map(r => <SelectItem key={r.id ?? r.name} value={r.name}>{r.name}</SelectItem>)}
{roles.length === 0 && <>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="user">user</SelectItem>
</>}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={handleCreate}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.username')}</TableHead>
<TableHead>{t('settings.display_name')}</TableHead>
<TableHead>Email</TableHead>
<TableHead>{t('users.role')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('users.last_login')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(u => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.full_name || '—'}</TableCell>
<TableCell className="text-xs">{u.email || '—'}</TableCell>
<TableCell><Badge variant="outline">{u.role_name || '—'}</Badge></TableCell>
<TableCell>
<Badge variant={u.is_locked ? 'destructive' : u.is_active !== false ? 'default' : 'secondary'}>
{u.is_locked ? t('users.locked') : u.is_active !== false ? t('users.active') : t('users.inactive')}
</Badge>
</TableCell>
<TableCell className="text-xs">{u.last_login ? new Date(u.last_login).toLocaleString() : '—'}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('users.reset_password')} onClick={() => resetPassword.mutate(u.id)}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
{u.is_locked && (
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('users.unlock')} onClick={() => unlockUser.mutate(u.id)}>
<Unlock className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteUser.mutate(u.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
function RolesTab() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [roleName, setRoleName] = useState('');
const [description, setDescription] = useState('');
const { data: roles = [] } = useRoles();
const createRole = useCreateRole();
const deleteRole = useDeleteRole();
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">{t('users.roles')}</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('users.create_role')}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t('users.create_role')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={roleName} onChange={e => setRoleName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => { createRole.mutate({ name: roleName, display_name: roleName, description, permissions: [] }); setOpen(false); setRoleName(''); setDescription(''); }}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('ipam.description')}</TableHead>
<TableHead>{t('users.permissions_count')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{roles.map(r => (
<TableRow key={r.id ?? r.name}>
<TableCell className="font-medium"><div className="flex items-center gap-2"><Shield className="h-4 w-4" />{r.name}</div></TableCell>
<TableCell className="text-xs text-muted-foreground">{r.description || '—'}</TableCell>
<TableCell>{r.permissions?.length ?? '—'}</TableCell>
<TableCell>
{!r.is_system && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteRole.mutate(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
function SessionsTab() {
const { t } = useTranslation();
const { data: sessions = [] } = useSessions();
const terminateSession = useTerminateSession();
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('users.active_sessions')}</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.username')}</TableHead>
<TableHead>IP</TableHead>
<TableHead>{t('users.user_agent')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
<TableHead>{t('users.expires')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{sessions.map(s => (
<TableRow key={s.id ?? s.session_id}>
<TableCell className="font-medium">{s.username}</TableCell>
<TableCell className="font-mono text-xs">{s.ip_address || '—'}</TableCell>
<TableCell className="text-xs max-w-[200px] truncate">{s.user_agent || '—'}</TableCell>
<TableCell className="text-xs">{s.created_at ? new Date(s.created_at).toLocaleString() : '—'}</TableCell>
<TableCell className="text-xs">{s.expires_at ? new Date(s.expires_at).toLocaleString() : '—'}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => terminateSession.mutate(s.id ?? s.session_id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
{sessions.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Search, Trash2, Users, Server, Pencil } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace } from '@/hooks/use-workspaces';
export default function WorkspacesPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const { data: workspaces = [] } = useWorkspaces();
const createWorkspace = useCreateWorkspace();
const updateWorkspace = useUpdateWorkspace();
const deleteWorkspace = useDeleteWorkspace();
const [editOpen, setEditOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const filtered = workspaces.filter(w => {
if (!search) return true;
const s = search.toLowerCase();
return w.name?.toLowerCase().includes(s) || w.description?.toLowerCase().includes(s);
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.workspaces')}</h1>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('workspaces.create')}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t('workspaces.create')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={name} onChange={e => setName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => { createWorkspace.mutate({ name, description }); setOpen(false); setName(''); setDescription(''); }}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map(ws => (
<Card key={ws.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-base">{ws.name}</CardTitle>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
setEditId(ws.id);
setEditName(ws.name);
setEditDescription(ws.description || '');
setEditOpen(true);
}}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteWorkspace.mutate(ws.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{ws.description && <p className="text-xs text-muted-foreground">{ws.description}</p>}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1"><Server className="h-3.5 w-3.5" />{ws.servers?.length ?? 0} {t('workspaces.servers')}</div>
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5" />{ws.users?.length ?? 0} {t('workspaces.users')}</div>
</div>
{ws.servers && ws.servers.length > 0 && (
<div className="flex flex-wrap gap-1">
{ws.servers.map((s: any) => (
<Badge key={s.id ?? s} variant="outline" className="text-xs">{s.name ?? s}</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">{t('workspaces.created')}: {ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '—'}</p>
</CardContent>
</Card>
))}
{filtered.length === 0 && (
<div className="col-span-full text-center text-muted-foreground py-12">{t('common.no_data')}</div>
)}
</div>
{/* Edit Workspace Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('workspaces.edit')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={editName} onChange={e => setEditName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={editDescription} onChange={e => setEditDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => {
if (editId) {
updateWorkspace.mutate({ id: editId, name: editName, description: editDescription });
}
setEditOpen(false);
}}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}