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:
87
frontend/src/features/auth/LoginPage.tsx
Normal file
87
frontend/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/features/backups/BackupsPage.tsx
Normal file
150
frontend/src/features/backups/BackupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
frontend/src/features/console/ConsolePage.tsx
Normal file
333
frontend/src/features/console/ConsolePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
344
frontend/src/features/dashboard/DashboardPage.tsx
Normal file
344
frontend/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
frontend/src/features/instances/CreateInstanceWizard.tsx
Normal file
388
frontend/src/features/instances/CreateInstanceWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
frontend/src/features/instances/InstanceDetailPage.tsx
Normal file
183
frontend/src/features/instances/InstanceDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
598
frontend/src/features/instances/InstancesPage.tsx
Normal file
598
frontend/src/features/instances/InstancesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/features/instances/tabs/DestroyTab.tsx
Normal file
83
frontend/src/features/instances/tabs/DestroyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/src/features/instances/tabs/GraphsTab.tsx
Normal file
188
frontend/src/features/instances/tabs/GraphsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/instances/tabs/NetworkingTab.tsx
Normal file
120
frontend/src/features/instances/tabs/NetworkingTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/features/instances/tabs/OverviewTab.tsx
Normal file
175
frontend/src/features/instances/tabs/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/features/instances/tabs/SettingsTab.tsx
Normal file
172
frontend/src/features/instances/tabs/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
frontend/src/features/instances/tabs/SnapshotsTab.tsx
Normal file
263
frontend/src/features/instances/tabs/SnapshotsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/features/ipam/IPAMAllocationsPage.tsx
Normal file
110
frontend/src/features/ipam/IPAMAllocationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/features/ipam/IPAMDashboardPage.tsx
Normal file
76
frontend/src/features/ipam/IPAMDashboardPage.tsx
Normal 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;
|
||||
}
|
||||
92
frontend/src/features/ipam/IPAMHistoryPage.tsx
Normal file
92
frontend/src/features/ipam/IPAMHistoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/features/ipam/IPAMNetworkDetailPage.tsx
Normal file
153
frontend/src/features/ipam/IPAMNetworkDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/features/ipam/IPAMNetworksPage.tsx
Normal file
95
frontend/src/features/ipam/IPAMNetworksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/features/nodes/NodeDetailPage.tsx
Normal file
87
frontend/src/features/nodes/NodeDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/features/nodes/NodesPage.tsx
Normal file
74
frontend/src/features/nodes/NodesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
frontend/src/features/settings/SettingsPage.tsx
Normal file
221
frontend/src/features/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/tasks/TasksPage.tsx
Normal file
120
frontend/src/features/tasks/TasksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/features/templates/TemplatesPage.tsx
Normal file
148
frontend/src/features/templates/TemplatesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
frontend/src/features/users/UsersPage.tsx
Normal file
264
frontend/src/features/users/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/features/workspaces/WorkspacesPage.tsx
Normal file
122
frontend/src/features/workspaces/WorkspacesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user