feat: Proxmox management suite — hardware, firewall, pools, disks, storage, access, node admin
- VM/LXC hardware editor with PCI/USB passthrough (BIOS/OVMF + efidisk, machine type, VGA, SCSI controller, CPU sockets); passthrough locked while running - Firewall at datacenter/node/VM levels: rules, security groups, IP sets, aliases, options, log — with rule dialog referencing security groups - Resource pools (CRUD + membership) and per-instance tags editor - Node disks: physical disk list, SMART viewer, ZFS pool create/destroy, wipe - Storage: add LVM/LVM-thin/ZFS/RBD/CephFS types + enable/disable toggle - PVE Access: API tokens (create with one-time secret, delete) and realms (LDAP/AD/OpenID) - Node admin: systemd services (start/stop/restart), APT updates & repositories - Shared: SelectTrigger defaults to full width (fixes narrow dropdowns) Also bundles related in-progress work: auth screen refresh, node shell console page, server-updates badge, and node:*/access:* RBAC permissions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BIN
frontend/src/assets/auth/auth-bg-dark.png
Normal file
BIN
frontend/src/assets/auth/auth-bg-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/src/assets/auth/auth-bg-light.png
Normal file
BIN
frontend/src/assets/auth/auth-bg-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Декоративный геометрический паттерн для левой панели страницы входа.
|
||||
* Тонкие полупрозрачные линии (круги, квадраты, треугольники, ломаные),
|
||||
* бесшовно повторяющиеся через SVG <pattern>. Цвет линий — currentColor.
|
||||
*/
|
||||
export default function AuthPattern({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="auth-geo"
|
||||
width="200"
|
||||
height="200"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.5"
|
||||
>
|
||||
{/* сетка-рамки */}
|
||||
<rect x="0.5" y="0.5" width="199" height="199" />
|
||||
{/* круг */}
|
||||
<circle cx="100" cy="60" r="46" />
|
||||
{/* квадрат со срезом-диагональю */}
|
||||
<rect x="20" y="120" width="56" height="56" />
|
||||
<line x1="20" y1="176" x2="76" y2="120" />
|
||||
{/* треугольники из диагоналей */}
|
||||
<path d="M120 120 L176 120 L176 176 Z" />
|
||||
<path d="M120 176 L120 120 L176 176 Z" strokeOpacity="0.25" />
|
||||
{/* ломаная линия */}
|
||||
<path d="M0 40 L40 40 L40 80 L0 80" strokeOpacity="0.35" />
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#auth-geo)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export default function ConsolePage() {
|
||||
const { t } = useTranslation();
|
||||
const node = searchParams.get('node') || '';
|
||||
const type = searchParams.get('type') || 'qemu';
|
||||
const isSerial = type === 'qemu' && searchParams.get('mode') === 'serial';
|
||||
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
@@ -114,118 +115,107 @@ export default function ConsolePage() {
|
||||
}
|
||||
}, [sid, vid, node]);
|
||||
|
||||
// ==================== xterm.js session (LXC terminal / VM serial) ====================
|
||||
// Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
|
||||
const setupXtermSession = useCallback(async (wsPath: string) => {
|
||||
// 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);
|
||||
requestAnimationFrame(() => fitAddon.fit());
|
||||
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const authToken = apiClient.getToken();
|
||||
const tokenParam = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
||||
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected');
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
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'); };
|
||||
|
||||
terminal.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const byteLength = new TextEncoder().encode(data).byteLength;
|
||||
ws.send(`0:${byteLength}:${data}`);
|
||||
}
|
||||
});
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(`1:${cols}:${rows}:`);
|
||||
});
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
window.addEventListener('resize', handleResize);
|
||||
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||
}, []);
|
||||
|
||||
// ==================== 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 authToken = apiClient.getToken();
|
||||
const tokenParam = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}${tokenParam}`;
|
||||
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) };
|
||||
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${data.node}/${vid}`);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node]);
|
||||
}, [sid, vid, node, setupXtermSession]);
|
||||
|
||||
// ==================== Serial Console (QEMU) ====================
|
||||
const connectSerial = useCallback(async () => {
|
||||
try {
|
||||
// Убедиться, что у VM есть serial0 (добавит при отсутствии)
|
||||
await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`);
|
||||
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node, setupXtermSession]);
|
||||
|
||||
// ==================== Connect on mount ====================
|
||||
useEffect(() => {
|
||||
if (type === 'qemu') {
|
||||
if (isSerial) {
|
||||
connectSerial();
|
||||
} else if (type === 'qemu') {
|
||||
connectVNC();
|
||||
} else {
|
||||
connectTerminal();
|
||||
@@ -248,7 +238,7 @@ export default function ConsolePage() {
|
||||
t.terminal.dispose();
|
||||
}
|
||||
};
|
||||
}, [type, connectVNC, connectTerminal]);
|
||||
}, [type, isSerial, connectVNC, connectTerminal, connectSerial]);
|
||||
|
||||
// ==================== Fullscreen ====================
|
||||
const toggleFullscreen = () => {
|
||||
@@ -291,7 +281,7 @@ export default function ConsolePage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{type === 'qemu' && (
|
||||
{type === 'qemu' && !isSerial && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -302,6 +292,9 @@ export default function ConsolePage() {
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
)}
|
||||
{isSerial && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-blue-500/10 text-blue-500">serial</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -334,7 +327,8 @@ export default function ConsolePage() {
|
||||
onClick={() => {
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
if (type === 'qemu') connectVNC();
|
||||
if (isSerial) connectSerial();
|
||||
else if (type === 'qemu') connectVNC();
|
||||
else connectTerminal();
|
||||
}}
|
||||
>
|
||||
|
||||
201
frontend/src/features/console/NodeShellPage.tsx
Normal file
201
frontend/src/features/console/NodeShellPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { Maximize, Minimize, Loader2, PackageCheck, TerminalSquare } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
/**
|
||||
* Root-терминал на уровне ноды: установка обновлений (cmd=upgrade → apt dist-upgrade)
|
||||
* или обычный shell (cmd=login). Работает через node-termproxy Proxmox,
|
||||
* аналогично штатной кнопке «Upgrade» в интерфейсе Proxmox.
|
||||
*/
|
||||
export default function NodeShellPage() {
|
||||
const { serverId, node } = useParams<{ serverId: string; node: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const cmd = searchParams.get('cmd') === 'login' ? 'login' : 'upgrade';
|
||||
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null);
|
||||
|
||||
const sid = Number(serverId);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
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);
|
||||
requestAnimationFrame(() => fitAddon.fit());
|
||||
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const authToken = apiClient.getToken();
|
||||
const params = new URLSearchParams({ cmd });
|
||||
if (authToken) params.set('token', authToken);
|
||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected');
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
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(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
||||
};
|
||||
ws.onerror = () => {
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
|
||||
};
|
||||
|
||||
terminal.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const byteLength = new TextEncoder().encode(data).byteLength;
|
||||
ws.send(`0:${byteLength}:${data}`);
|
||||
}
|
||||
});
|
||||
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(`1:${cols}:${rows}:`);
|
||||
});
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
window.addEventListener('resize', handleResize);
|
||||
termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, node, cmd, t]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
if (keepaliveRef.current) clearInterval(keepaliveRef.current);
|
||||
if (termRef.current) {
|
||||
termRef.current.cleanup();
|
||||
termRef.current.terminal.dispose();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-[#09090B]">
|
||||
<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">
|
||||
{cmd === 'upgrade' ? (
|
||||
<><PackageCheck className="mr-1 h-3 w-3" />{t('nodeadm.upgrade', 'Upgrade')}</>
|
||||
) : (
|
||||
<><TerminalSquare className="mr-1 h-3 w-3" />Shell</>
|
||||
)}
|
||||
</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>
|
||||
<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 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('');
|
||||
connect();
|
||||
}}
|
||||
>
|
||||
{t('console.reconnect', 'Переподключиться')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className="h-full w-full" style={{ minHeight: 'calc(100vh - 40px)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,11 @@ export default function InstanceDetailPage() {
|
||||
<DropdownMenuItem onClick={() => setDialog('migrate')}>
|
||||
<ArrowRightLeft className="mr-2 h-4 w-4" /> {t('instances.migrate', 'Миграция')}
|
||||
</DropdownMenuItem>
|
||||
{isQemu && (
|
||||
<DropdownMenuItem render={<Link to={`/console/${serverId}/${vmid}?node=${node}&type=${type}&mode=serial`} target="_blank" />}>
|
||||
<Terminal className="mr-2 h-4 w-4" /> {t('instances.serial_console', 'Serial-консоль')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isQemu && (
|
||||
<DropdownMenuItem onClick={() => setDialog('iso')}>
|
||||
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Loader2, Power } from 'lucide-react';
|
||||
import { Settings, Loader2, Power, Cpu, HardDrive, Trash2, SlidersHorizontal, LockOpen, ListOrdered, ArrowUp, ArrowDown, Cloud, Eye, EyeOff } 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, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig } from '@/hooks/use-instances';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig, useMoveDisk, useAddDisk, useDetachDisk, useUnlockInstance, useUpdateCloudInit } from '@/hooks/use-instances';
|
||||
import { useLXCStorages } from '@/hooks/use-lxc-templates';
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -356,6 +358,635 @@ function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number })
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Cloud-Init ====================
|
||||
|
||||
function CloudInitCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const update = useUpdateCloudInit(serverId, vmid, node);
|
||||
|
||||
const [user, setUser] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [ipMode, setIpMode] = useState<'dhcp' | 'static' | 'none'>('dhcp');
|
||||
const [ipCidr, setIpCidr] = useState('');
|
||||
const [gateway, setGateway] = useState('');
|
||||
const [nameserver, setNameserver] = useState('');
|
||||
const [searchdomain, setSearchdomain] = useState('');
|
||||
const [sshkeys, setSshkeys] = useState('');
|
||||
|
||||
// Наличие cloud-init диска (ide2/scsiN со значением *-cloudinit)
|
||||
const hasCloudInit = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return Object.values(config).some(v => typeof v === 'string' && v.includes('cloudinit'));
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
setUser(typeof config.ciuser === 'string' ? config.ciuser : '');
|
||||
setNameserver(typeof config.nameserver === 'string' ? config.nameserver : '');
|
||||
setSearchdomain(typeof config.searchdomain === 'string' ? config.searchdomain : '');
|
||||
// sshkeys приходит URL-кодированным
|
||||
let keys = typeof config.sshkeys === 'string' ? config.sshkeys : '';
|
||||
try { keys = decodeURIComponent(keys); } catch { /* оставляем как есть */ }
|
||||
setSshkeys(keys);
|
||||
// ipconfig0
|
||||
const ip = typeof config.ipconfig0 === 'string' ? config.ipconfig0 : '';
|
||||
if (!ip) { setIpMode('none'); setIpCidr(''); setGateway(''); }
|
||||
else if (ip.includes('ip=dhcp')) { setIpMode('dhcp'); setIpCidr(''); setGateway(''); }
|
||||
else {
|
||||
setIpMode('static');
|
||||
setIpCidr(ip.match(/ip=([^,]+)/)?.[1] ?? '');
|
||||
setGateway(ip.match(/gw=([^,]+)/)?.[1] ?? '');
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const save = () => {
|
||||
let ipconfig0: string | undefined;
|
||||
if (ipMode === 'dhcp') ipconfig0 = 'ip=dhcp';
|
||||
else if (ipMode === 'none') ipconfig0 = '';
|
||||
else {
|
||||
if (!ipCidr.trim()) { toast.error(t('instances.ci_ip_required')); return; }
|
||||
ipconfig0 = `ip=${ipCidr.trim()}${gateway.trim() ? `,gw=${gateway.trim()}` : ''}`;
|
||||
}
|
||||
const payload: Record<string, string> = {
|
||||
ciuser: user,
|
||||
ipconfig0,
|
||||
nameserver,
|
||||
searchdomain,
|
||||
sshkeys,
|
||||
};
|
||||
if (password) payload.cipassword = password; // пусто = не менять
|
||||
|
||||
update.mutate(payload, {
|
||||
onSuccess: () => { toast.success(t('instances.ci_saved')); setPassword(''); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4" />{t('instances.cloud_init')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!hasCloudInit && (
|
||||
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-500">
|
||||
{t('instances.ci_no_drive')}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.cloud_user')}</Label>
|
||||
<Input value={user} onChange={e => setUser(e.target.value)} placeholder={t('common.placeholder_ubuntu')} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('wizard.ci_password')}</Label>
|
||||
<div className="relative">
|
||||
<Input type={showPw ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} placeholder={t('instances.ci_password_keep')} />
|
||||
<Button type="button" variant="ghost" size="icon" className="absolute right-1 top-1 h-7 w-7" onClick={() => setShowPw(s => !s)}>
|
||||
{showPw ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.ci_ip_mode')}</Label>
|
||||
<Select value={ipMode} onValueChange={(v) => v && setIpMode(v as 'dhcp' | 'static' | 'none')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dhcp">DHCP</SelectItem>
|
||||
<SelectItem value="static">{t('instances.ci_static')}</SelectItem>
|
||||
<SelectItem value="none">{t('instances.ci_ip_none')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{ipMode === 'static' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.ci_ip_cidr')}</Label>
|
||||
<Input value={ipCidr} onChange={e => setIpCidr(e.target.value)} placeholder="10.0.0.5/24" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('netif.gateway')}</Label>
|
||||
<Input value={gateway} onChange={e => setGateway(e.target.value)} placeholder="10.0.0.1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>DNS ({t('instances.ci_nameserver')})</Label>
|
||||
<Input value={nameserver} onChange={e => setNameserver(e.target.value)} placeholder="1.1.1.1" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.ci_searchdomain')}</Label>
|
||||
<Input value={searchdomain} onChange={e => setSearchdomain(e.target.value)} placeholder="example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('wizard.ssh_keys')}</Label>
|
||||
<textarea
|
||||
value={sshkeys}
|
||||
onChange={e => setSshkeys(e.target.value)}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder={t('common.placeholder_ssh_key')}
|
||||
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={save} disabled={update.isPending}>
|
||||
{update.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('instances.ci_reboot_hint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Boot order ====================
|
||||
|
||||
/** Человекочитаемая подпись загрузочного устройства по его конфигу. */
|
||||
function bootDeviceLabel(dev: string, spec: unknown, t: (k: string) => string): string {
|
||||
if (dev.startsWith('net')) return `${dev} · ${t('instances.boot_network')}`;
|
||||
if (typeof spec === 'string' && spec.includes('media=cdrom')) return `${dev} · CD-ROM`;
|
||||
return `${dev} · ${t('nodes.disk')}`;
|
||||
}
|
||||
|
||||
function BootOrderCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||
|
||||
// Все загрузочные устройства из конфига: диски (вкл. cdrom) + сетевые.
|
||||
const allDevices = useMemo(() => {
|
||||
if (!config) return [] as string[];
|
||||
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+|net\d+)$/;
|
||||
return Object.entries(config)
|
||||
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('cloudinit'))
|
||||
.map(([k]) => k);
|
||||
}, [config]);
|
||||
|
||||
// Текущий порядок из boot=order=dev1;dev2
|
||||
const currentOrder = useMemo(() => {
|
||||
const raw = typeof config?.boot === 'string' ? config.boot : '';
|
||||
const list = raw.match(/order=([^,]+)/)?.[1]?.split(';').map(s => s.trim()).filter(Boolean) ?? [];
|
||||
return list.filter(d => allDevices.includes(d));
|
||||
}, [config?.boot, allDevices]);
|
||||
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
useEffect(() => { setOrder(currentOrder); }, [currentOrder]);
|
||||
|
||||
const disabled = allDevices.filter(d => !order.includes(d));
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= order.length) return;
|
||||
const next = [...order];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
setOrder(next);
|
||||
};
|
||||
const enable = (dev: string) => setOrder([...order, dev]);
|
||||
const disable = (dev: string) => setOrder(order.filter(d => d !== dev));
|
||||
|
||||
const dirty = order.join(';') !== currentOrder.join(';');
|
||||
|
||||
const save = () => {
|
||||
updateConfig.mutate(
|
||||
{ boot: `order=${order.join(';')}` },
|
||||
{
|
||||
onSuccess: () => toast.success(t('instances.boot_order_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4" />{t('instances.boot_order')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t('instances.boot_order_hint')}</p>
|
||||
|
||||
{/* Активный порядок */}
|
||||
<div className="space-y-1.5">
|
||||
{order.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t('instances.boot_none_enabled')}</p>
|
||||
) : order.map((dev, i) => (
|
||||
<div key={dev} className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm">
|
||||
<span className="w-5 text-center text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="flex-1 font-mono text-xs">{bootDeviceLabel(dev, config?.[dev], t)}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => move(i, -1)}>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === order.length - 1} onClick={() => move(i, 1)}>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground" onClick={() => disable(dev)}>
|
||||
{t('instances.boot_disable')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Отключённые устройства */}
|
||||
{disabled.length > 0 && (
|
||||
<div className="space-y-1.5 border-t pt-3">
|
||||
<Label className="text-xs text-muted-foreground">{t('instances.boot_disabled')}</Label>
|
||||
{disabled.map((dev) => (
|
||||
<div key={dev} className="flex items-center gap-2 rounded-md border border-dashed px-3 py-1.5 text-sm">
|
||||
<span className="flex-1 font-mono text-xs text-muted-foreground">{bootDeviceLabel(dev, config?.[dev], t)}</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => enable(dev)}>
|
||||
{t('instances.boot_enable')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button size="sm" onClick={save} disabled={!dirty || updateConfig.isPending}>
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== VM options & unlock ====================
|
||||
|
||||
const HOTPLUG_DEFAULT = 'network,disk,usb';
|
||||
|
||||
/** Простой тумблер-строка. */
|
||||
function OptionToggle({ label, hint, checked, onChange }: {
|
||||
label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-start justify-between gap-3 cursor-pointer py-1">
|
||||
<div>
|
||||
<span className="text-sm">{label}</span>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input accent-primary" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function VmOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||
const unlock = useUnlockInstance(serverId, vmid, type, node);
|
||||
|
||||
const [agent, setAgent] = useState(false);
|
||||
const [tablet, setTablet] = useState(true);
|
||||
const [kvm, setKvm] = useState(true);
|
||||
const [hotplug, setHotplug] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
const agentRaw = typeof config.agent === 'string' ? config.agent : String(config.agent ?? '');
|
||||
setAgent(agentRaw.startsWith('1'));
|
||||
setTablet(config.tablet == null ? true : Number(config.tablet) === 1);
|
||||
setKvm(config.kvm == null ? true : Number(config.kvm) === 1);
|
||||
const hp = typeof config.hotplug === 'string' ? config.hotplug : '';
|
||||
setHotplug(config.hotplug == null ? true : hp !== '0' && hp !== '');
|
||||
}, [config]);
|
||||
|
||||
const lock = typeof config?.lock === 'string' ? config.lock : '';
|
||||
|
||||
const save = () => {
|
||||
const updates: Record<string, unknown> = {
|
||||
agent: agent ? 1 : 0,
|
||||
tablet: tablet ? 1 : 0,
|
||||
kvm: kvm ? 1 : 0,
|
||||
hotplug: hotplug ? HOTPLUG_DEFAULT : '0',
|
||||
};
|
||||
updateConfig.mutate(updates, {
|
||||
onSuccess: () => toast.success(t('instances.vm_options_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const doUnlock = () => {
|
||||
unlock.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('instances.unlocked')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />{t('instances.vm_options')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<OptionToggle label={t('instances.opt_agent')} hint={t('instances.opt_agent_hint')} checked={agent} onChange={setAgent} />
|
||||
<OptionToggle label={t('instances.opt_tablet')} hint={t('instances.opt_tablet_hint')} checked={tablet} onChange={setTablet} />
|
||||
<OptionToggle label={t('instances.opt_kvm')} hint={t('instances.opt_kvm_hint')} checked={kvm} onChange={setKvm} />
|
||||
<OptionToggle label={t('instances.opt_hotplug')} hint={t('instances.opt_hotplug_hint')} checked={hotplug} onChange={setHotplug} />
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Unlock */}
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<div>
|
||||
<span className="text-sm">{t('instances.unlock')}</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lock ? t('instances.lock_current', { lock }) : t('instances.no_lock')}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={doUnlock} disabled={unlock.isPending}>
|
||||
{unlock.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LockOpen className="mr-2 h-4 w-4" />}
|
||||
{t('instances.unlock')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== CPU options ====================
|
||||
|
||||
const CPU_TYPES = ['default', 'host', 'x86-64-v2', 'x86-64-v2-AES', 'x86-64-v3', 'x86-64-v4', 'kvm64', 'qemu64'];
|
||||
|
||||
function CpuOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||
|
||||
const [cpuType, setCpuType] = useState('default');
|
||||
const [sockets, setSockets] = useState('');
|
||||
const [cpulimit, setCpulimit] = useState('');
|
||||
const [cpuunits, setCpuunits] = useState('');
|
||||
const [numa, setNuma] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// cpu из конфига может быть "host" или "cputype=host,flags=..."
|
||||
const raw = typeof config.cpu === 'string' ? config.cpu : '';
|
||||
const parsed = raw.includes('cputype=') ? (raw.match(/cputype=([^,]+)/)?.[1] ?? '') : raw;
|
||||
setCpuType(parsed && CPU_TYPES.includes(parsed) ? parsed : (parsed ? parsed : 'default'));
|
||||
setSockets(config.sockets != null ? String(config.sockets) : '');
|
||||
setCpulimit(config.cpulimit != null ? String(config.cpulimit) : '');
|
||||
setCpuunits(config.cpuunits != null ? String(config.cpuunits) : '');
|
||||
setNuma(Number(config.numa) === 1);
|
||||
}, [config]);
|
||||
|
||||
const save = () => {
|
||||
const updates: Record<string, unknown> = {
|
||||
sockets: sockets ? Number(sockets) : 1,
|
||||
numa: numa ? 1 : 0,
|
||||
};
|
||||
if (cpuType && cpuType !== 'default') updates.cpu = cpuType;
|
||||
else updates.delete = 'cpu';
|
||||
// cpulimit: 0 = без лимита; cpuunits: вес планировщика
|
||||
updates.cpulimit = cpulimit ? Number(cpulimit) : 0;
|
||||
if (cpuunits) updates.cpuunits = Number(cpuunits);
|
||||
|
||||
updateConfig.mutate(updates, {
|
||||
onSuccess: () => toast.success(t('instances.cpu_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />{t('instances.cpu_options')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.cpu_type')}</Label>
|
||||
<Select value={cpuType} onValueChange={(v) => v && setCpuType(v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CPU_TYPES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c === 'default' ? `default (kvm64)` : c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('instances.cpu_type_hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.sockets')}</Label>
|
||||
<Input type="number" min={1} max={8} value={sockets} onChange={(e) => setSockets(e.target.value)} placeholder="1" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.cpulimit')}</Label>
|
||||
<Input type="number" min={0} max={128} step="0.1" value={cpulimit} onChange={(e) => setCpulimit(e.target.value)} placeholder="0" />
|
||||
<p className="text-xs text-muted-foreground">{t('instances.cpulimit_hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.cpuunits')}</Label>
|
||||
<Input type="number" min={1} max={262144} value={cpuunits} onChange={(e) => setCpuunits(e.target.value)} placeholder="100" />
|
||||
<p className="text-xs text-muted-foreground">{t('instances.cpuunits_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={numa} onChange={(e) => setNuma(e.target.checked)} className="h-4 w-4 rounded border-input accent-primary" />
|
||||
<span className="text-sm">{t('instances.numa')}</span>
|
||||
</label>
|
||||
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('instances.apply_changes_note')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Disk management (move / add / detach) ====================
|
||||
|
||||
/** Достать имя хранилища из спецификации диска ("local-lvm:vm-100-disk-0,size=32G"). */
|
||||
function parseDiskStorage(spec: unknown): string | null {
|
||||
if (typeof spec !== 'string') return null;
|
||||
return spec.split(':', 1)[0] || null;
|
||||
}
|
||||
|
||||
/** Следующее свободное имя устройства для заданной шины (scsi/virtio/sata). */
|
||||
function nextFreeDevice(bus: string, existing: string[]): string {
|
||||
for (let i = 0; i < 31; i++) {
|
||||
const name = `${bus}${i}`;
|
||||
if (!existing.includes(name)) return name;
|
||||
}
|
||||
return `${bus}0`;
|
||||
}
|
||||
|
||||
function DiskManagementCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: storages = [] } = useLXCStorages(serverId, node);
|
||||
const moveDisk = useMoveDisk(serverId, vmid, node);
|
||||
const addDisk = useAddDisk(serverId, vmid, node);
|
||||
const detachDisk = useDetachDisk(serverId, vmid, node);
|
||||
|
||||
// Реальные дисковые устройства (без cd-rom и cloudinit)
|
||||
const disks = useMemo(() => {
|
||||
if (!config) return [] as string[];
|
||||
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
|
||||
return Object.entries(config)
|
||||
.filter(([k, v]) => re.test(k) && typeof v === 'string'
|
||||
&& !v.includes('media=cdrom') && !v.includes('cloudinit'))
|
||||
.map(([k]) => k);
|
||||
}, [config]);
|
||||
|
||||
const [moveDiskDev, setMoveDiskDev] = useState('');
|
||||
const [moveStorage, setMoveStorage] = useState('');
|
||||
const [moveDelete, setMoveDelete] = useState(true);
|
||||
|
||||
const [addBus, setAddBus] = useState('scsi');
|
||||
const [addStorage, setAddStorage] = useState('');
|
||||
const [addSize, setAddSize] = useState('16');
|
||||
const [addSsd, setAddSsd] = useState(false);
|
||||
const [addDiscard, setAddDiscard] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disks.length && !disks.includes(moveDiskDev)) setMoveDiskDev(disks[0]);
|
||||
}, [disks, moveDiskDev]);
|
||||
|
||||
const doMove = () => {
|
||||
if (!moveDiskDev || !moveStorage) return;
|
||||
moveDisk.mutate(
|
||||
{ disk: moveDiskDev, target_storage: moveStorage, delete: moveDelete },
|
||||
{
|
||||
onSuccess: () => { toast.success(t('instances.disk_moved')); setMoveStorage(''); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const doAdd = () => {
|
||||
if (!addStorage || !addSize) return;
|
||||
const dev = nextFreeDevice(addBus, disks);
|
||||
addDisk.mutate(
|
||||
{ disk: dev, storage: addStorage, size_gb: Number(addSize), ssd: addSsd, discard: addDiscard },
|
||||
{
|
||||
onSuccess: () => { toast.success(t('instances.disk_added', { dev })); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const doDetach = async (dev: string) => {
|
||||
const destroy = await confirm(t('instances.disk_detach_confirm', { dev }));
|
||||
if (!destroy) return;
|
||||
detachDisk.mutate(
|
||||
{ disk: dev, destroy: true },
|
||||
{
|
||||
onSuccess: () => toast.success(t('instances.disk_detached')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />{t('instances.disk_management')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Список дисков */}
|
||||
<div className="space-y-1.5">
|
||||
{disks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t('instances.no_disks')}</p>
|
||||
) : disks.map((d) => (
|
||||
<div key={d} className="flex items-center justify-between rounded-md border px-3 py-1.5 text-sm">
|
||||
<span className="font-mono">{d}</span>
|
||||
<span className="text-xs text-muted-foreground">{parseDiskStorage(config?.[d]) ?? '—'}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('common.delete')} onClick={() => doDetach(d)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Move disk */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Label className="text-xs font-medium">{t('instances.disk_move')}</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Select value={moveDiskDev} onValueChange={(v) => v && setMoveDiskDev(v)}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.disk_device')} /></SelectTrigger>
|
||||
<SelectContent>{disks.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={moveStorage} onValueChange={(v) => v && setMoveStorage(v)}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.migrate_target_storage')} /></SelectTrigger>
|
||||
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={moveDelete} onChange={(e) => setMoveDelete(e.target.checked)} />
|
||||
<span>{t('instances.disk_move_delete')}</span>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={doMove} disabled={!moveStorage || moveDisk.isPending}>
|
||||
{moveDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('instances.disk_move')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add disk */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Label className="text-xs font-medium">{t('instances.disk_add')}</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('instances.disk_bus')}</Label>
|
||||
<Select value={addBus} onValueChange={(v) => v && setAddBus(v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scsi">scsi</SelectItem>
|
||||
<SelectItem value="virtio">virtio</SelectItem>
|
||||
<SelectItem value="sata">sata</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('instances.migrate_target_storage')}</Label>
|
||||
<Select value={addStorage} onValueChange={(v) => v && setAddStorage(v)}>
|
||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('instances.new_size_gb')}</Label>
|
||||
<Input type="number" min={1} value={addSize} onChange={(e) => setAddSize(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={addSsd} onChange={(e) => setAddSsd(e.target.checked)} /><span>SSD</span></label>
|
||||
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={addDiscard} onChange={(e) => setAddDiscard(e.target.checked)} /><span>Discard</span></label>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={doAdd} disabled={!addStorage || !addSize || addDisk.isPending}>
|
||||
{addDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('instances.disk_add')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
@@ -508,6 +1139,16 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{type !== 'lxc' && <CloudInitCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{type !== 'lxc' && <BootOrderCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{type !== 'lxc' && <VmOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{type !== 'lxc' && <CpuOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{type !== 'lxc' && <DiskManagementCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{/* Disk Resize */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck } from 'lucide-react';
|
||||
import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck, Download } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -128,9 +128,18 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
? t('nodeadm.updates_count', 'Доступно обновлений: {{n}}', { n: updates.length })
|
||||
: t('nodeadm.up_to_date', 'Обновлений нет')}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={refreshApt} disabled={aptRefresh.isPending}>
|
||||
<PackageCheck className="mr-1 h-4 w-4" />{t('nodeadm.refresh', 'Проверить (apt update)')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={refreshApt} disabled={aptRefresh.isPending}>
|
||||
<PackageCheck className="mr-1 h-4 w-4" />{t('nodeadm.refresh', 'Проверить (apt update)')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.open(`/console/node/${serverId}/${node}?cmd=upgrade`, '_blank', 'noopener,noreferrer')}
|
||||
disabled={!node}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />{t('nodeadm.do_upgrade', 'Установить обновления')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{updates.length > 0 && (
|
||||
<Table>
|
||||
@@ -150,7 +159,7 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('nodeadm.upgrade_hint', 'Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('nodeadm.upgrade_hint', '«Установить обновления» откроет консоль ноды с apt dist-upgrade (потребуется подтверждение). Не закрывайте вкладку до завершения.')}</p>
|
||||
</TabsContent>
|
||||
|
||||
{/* Repositories */}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useParams, useSearchParams, 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useServer, useNodes, useClusterInfo } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
@@ -26,10 +27,18 @@ export default function NodeDetailPage() {
|
||||
const { data: nodesData } = useNodes(sid);
|
||||
const { data: clusterInfo } = useClusterInfo(sid);
|
||||
const { data: allVMs = [] } = useVirtualMachines();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const nodes = nodesData?.nodes || [];
|
||||
const serverVMs = allVMs.filter(v => v.server_id === sid);
|
||||
|
||||
const activeTab = searchParams.get('tab') || 'overview';
|
||||
const setTab = (tab: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('tab', tab);
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -54,101 +63,115 @@ export default function NodeDetailPage() {
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Node network interfaces */}
|
||||
{nodes.length > 0 && (
|
||||
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* High Availability */}
|
||||
{nodes.length > 0 && (
|
||||
<HAResources serverId={sid} vms={serverVMs} />
|
||||
)}
|
||||
|
||||
{/* Software Defined Networking */}
|
||||
{nodes.length > 0 && (
|
||||
<SDNManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* Datacenter Firewall */}
|
||||
{nodes.length > 0 && (
|
||||
<FirewallManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* Node Firewall */}
|
||||
{nodes.length > 0 && (
|
||||
<NodeFirewallManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* Resource Pools */}
|
||||
{nodes.length > 0 && (
|
||||
<PoolManager
|
||||
serverId={sid}
|
||||
vms={serverVMs.map(v => ({ vmid: v.vmid, name: v.name, type: v.type, node: v.node }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disks & ZFS */}
|
||||
{nodes.length > 0 && (
|
||||
<DiskManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* Node administration: services & APT */}
|
||||
{nodes.length > 0 && (
|
||||
<NodeAdminManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* PVE Access: realms & API tokens */}
|
||||
{nodes.length > 0 && (
|
||||
<AccessManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<Tabs value={activeTab} onValueChange={setTab}>
|
||||
<TabsList className="flex-wrap h-auto">
|
||||
<TabsTrigger value="overview">{t('nodes.tab_overview', 'Обзор')}</TabsTrigger>
|
||||
{nodes.length > 0 && (
|
||||
<>
|
||||
<TabsTrigger value="network">{t('nodes.tab_network', 'Сеть')}</TabsTrigger>
|
||||
<TabsTrigger value="ha">{t('nodes.tab_ha', 'HA')}</TabsTrigger>
|
||||
<TabsTrigger value="firewall">{t('nodes.tab_firewall', 'Firewall')}</TabsTrigger>
|
||||
<TabsTrigger value="pools">{t('nodes.tab_pools', 'Пулы')}</TabsTrigger>
|
||||
<TabsTrigger value="disks">{t('nodes.tab_disks', 'Диски')}</TabsTrigger>
|
||||
<TabsTrigger value="admin">{t('nodes.tab_admin', 'Администрирование')}</TabsTrigger>
|
||||
<TabsTrigger value="access">{t('nodes.tab_access', 'Доступ')}</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview: cluster nodes + instances on server */}
|
||||
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
{nodes.length > 0 && (
|
||||
<>
|
||||
{/* Network: interfaces + SDN */}
|
||||
<TabsContent value="network" className="mt-4 space-y-6">
|
||||
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
<SDNManager serverId={sid} />
|
||||
</TabsContent>
|
||||
|
||||
{/* High Availability */}
|
||||
<TabsContent value="ha" className="mt-4">
|
||||
<HAResources serverId={sid} vms={serverVMs} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Firewall: datacenter + node */}
|
||||
<TabsContent value="firewall" className="mt-4 space-y-6">
|
||||
<FirewallManager serverId={sid} />
|
||||
<NodeFirewallManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Resource Pools */}
|
||||
<TabsContent value="pools" className="mt-4">
|
||||
<PoolManager
|
||||
serverId={sid}
|
||||
vms={serverVMs.map(v => ({ vmid: v.vmid, name: v.name, type: v.type, node: v.node }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Disks & ZFS */}
|
||||
<TabsContent value="disks" className="mt-4">
|
||||
<DiskManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Node administration: services & APT */}
|
||||
<TabsContent value="admin" className="mt-4">
|
||||
<NodeAdminManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* PVE Access: realms & API tokens */}
|
||||
<TabsContent value="access" className="mt-4">
|
||||
<AccessManager serverId={sid} />
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import type { ProxmoxServerCreate } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import ServerUpdatesBadge from './ServerUpdatesBadge';
|
||||
|
||||
interface ServerFormData {
|
||||
name: string;
|
||||
@@ -318,6 +319,8 @@ export default function NodesPage() {
|
||||
{srv.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{srv.description}</p>
|
||||
)}
|
||||
|
||||
<ServerUpdatesBadge serverId={srv.id} online={srv.is_online} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
75
frontend/src/features/nodes/ServerUpdatesBadge.tsx
Normal file
75
frontend/src/features/nodes/ServerUpdatesBadge.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpCircle } from 'lucide-react';
|
||||
import { useNodes } from '@/hooks/use-nodes';
|
||||
import { useAptUpdates } from '@/hooks/use-node-admin';
|
||||
|
||||
/**
|
||||
* Невидимый пробник: тянет список APT-обновлений для одного узла
|
||||
* и сообщает их количество наверх. Рендерит null.
|
||||
*/
|
||||
function NodeUpdatesProbe({
|
||||
serverId,
|
||||
node,
|
||||
onCount,
|
||||
}: {
|
||||
serverId: number;
|
||||
node: string;
|
||||
onCount: (node: string, count: number) => void;
|
||||
}) {
|
||||
const { data } = useAptUpdates(serverId, node);
|
||||
const count = data?.updates?.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
onCount(node, count);
|
||||
}, [node, count, onCount]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Аккуратная плашка «Доступны обновления» для карточки сервера.
|
||||
* Показывается только если хотя бы на одном узле есть APT-обновления.
|
||||
*/
|
||||
export default function ServerUpdatesBadge({
|
||||
serverId,
|
||||
online,
|
||||
}: {
|
||||
serverId: number;
|
||||
online?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: nodesData } = useNodes(online ? serverId : 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const report = useCallback((node: string, count: number) => {
|
||||
setCounts(prev => (prev[node] === count ? prev : { ...prev, [node]: count }));
|
||||
}, []);
|
||||
|
||||
const total = Object.values(counts).reduce((sum, c) => sum + c, 0);
|
||||
|
||||
if (!online) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map(n => (
|
||||
<NodeUpdatesProbe key={n.node} serverId={serverId} node={n.node} onCount={report} />
|
||||
))}
|
||||
{total > 0 && (
|
||||
<Link
|
||||
to={`/nodes/${serverId}?tab=admin`}
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs font-medium text-amber-600 transition-colors hover:bg-amber-500/20 dark:text-amber-400"
|
||||
title={t('nodes.updates_available_count', { count: total })}
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate">{t('nodes.updates_available')}</span>
|
||||
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 font-semibold tabular-nums">
|
||||
{total}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -800,7 +800,7 @@ function RolesTab() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<div>
|
||||
<div>{r.display_name}</div>
|
||||
<div>{t(`perms.role.${r.name}.name`, r.display_name)}</div>
|
||||
<div className="text-xs text-muted-foreground">{r.name}</div>
|
||||
</div>
|
||||
{r.is_system && (
|
||||
@@ -808,7 +808,7 @@ function RolesTab() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{r.description || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{t(`perms.role.${r.name}.desc`, r.description || '—')}</TableCell>
|
||||
<TableCell>{enabledCount}</TableCell>
|
||||
<TableCell>{r.user_count}</TableCell>
|
||||
<TableCell>
|
||||
@@ -934,7 +934,7 @@ function RoleDialog({
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'create' ? t('users.create_role') : `${t('users.edit_role')}: ${role?.display_name}`}
|
||||
{mode === 'create' ? t('users.create_role') : `${t('users.edit_role')}: ${role ? t(`perms.role.${role.name}.name`, role.display_name) : ''}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@@ -974,7 +974,9 @@ function RoleDialog({
|
||||
<div className="border rounded-md max-h-80 overflow-y-auto p-3 space-y-3">
|
||||
{Object.entries(permCategories).map(([cat, items]) => (
|
||||
<div key={cat}>
|
||||
<div className="text-xs font-semibold text-muted-foreground mb-1.5 uppercase">{cat}</div>
|
||||
<div className="text-xs font-semibold text-muted-foreground mb-1.5 uppercase">
|
||||
{t(`perms.cat.${cat}`, cat)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{Object.entries(items).map(([code, label]) => (
|
||||
<label key={code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-accent rounded px-2 py-1">
|
||||
@@ -984,7 +986,9 @@ function RoleDialog({
|
||||
onChange={() => togglePerm(code)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="flex-1 truncate" title={code}>{label}</span>
|
||||
<span className="flex-1 truncate" title={code}>
|
||||
{t(`perms.label.${code.replace(/:/g, '_')}`, label as string)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -560,6 +560,63 @@ export function useResizeDisk(serverId: number, vmid: number, type: string, node
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlockInstance(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/unlock?node=${node}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface CloudInitInput {
|
||||
ciuser?: string;
|
||||
cipassword?: string;
|
||||
sshkeys?: string;
|
||||
ipconfig0?: string;
|
||||
nameserver?: string;
|
||||
searchdomain?: string;
|
||||
}
|
||||
|
||||
export function useUpdateCloudInit(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CloudInitInput) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/cloud-init?node=${node}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMoveDisk(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { disk: string; target_storage: string; delete?: boolean; format?: string }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/move?node=${node}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddDisk(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { disk: string; storage: string; size_gb: number; ssd?: boolean; discard?: boolean; iothread?: boolean }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/add?node=${node}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetachDisk(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { disk: string; destroy?: boolean }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/detach?node=${node}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Config Update ====================
|
||||
export function useUpdateConfig(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { Monitor } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AuthPattern from '@/features/auth/AuthPattern';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import authBgDark from '@/assets/auth/auth-bg-dark.png';
|
||||
import authBgLight from '@/assets/auth/auth-bg-light.png';
|
||||
|
||||
function LanguageSwitcher() {
|
||||
const { i18n } = useTranslation();
|
||||
@@ -12,7 +14,7 @@ function LanguageSwitcher() {
|
||||
value={lang}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label="Language"
|
||||
className="cursor-pointer rounded-md bg-transparent py-1 pr-1 text-sm text-text-secondary outline-none transition-colors hover:text-text-primary focus-visible:text-text-primary"
|
||||
className="cursor-pointer rounded-md bg-transparent py-1 pr-1 text-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground"
|
||||
>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
@@ -22,12 +24,21 @@ function LanguageSwitcher() {
|
||||
|
||||
export default function AuthLayout() {
|
||||
const year = new Date().getFullYear();
|
||||
const theme = useThemeStore((s) => s.theme);
|
||||
const authBg = theme === 'light' ? authBgLight : authBgDark;
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-surface-0 lg:grid-cols-[1.4fr_1fr]">
|
||||
{/* Левая декоративная панель */}
|
||||
<div className="relative hidden overflow-hidden bg-surface-1 text-primary/60 lg:block">
|
||||
<AuthPattern className="absolute inset-0 h-full w-full" />
|
||||
<div className="grid min-h-screen bg-background lg:grid-cols-[1.4fr_1fr]">
|
||||
{/* Левая декоративная панель со сгенерированным фоном */}
|
||||
<div className="relative hidden overflow-hidden bg-muted lg:block">
|
||||
<img
|
||||
src={authBg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
{/* Плавный бесшовный переход к панели формы (в обеих темах) */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-background/70" />
|
||||
</div>
|
||||
|
||||
{/* Правая панель с формой */}
|
||||
@@ -48,7 +59,7 @@ export default function AuthLayout() {
|
||||
</div>
|
||||
|
||||
{/* Футер */}
|
||||
<div className="flex items-center gap-4 text-sm text-text-secondary">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<LanguageSwitcher />
|
||||
<span>PVEmanager © {year}</span>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +231,61 @@
|
||||
"migrate_online": "Online (live) migration",
|
||||
"migrate_online_hint_running": "Instance is running — live migration keeps it online during the move.",
|
||||
"migrate_online_hint_stopped": "Instance is stopped — leave off for a regular offline migration.",
|
||||
"serial_console": "Serial console",
|
||||
"cloud_init": "Cloud-Init",
|
||||
"ci_no_drive": "This VM has no Cloud-Init drive — settings will only apply once one is added.",
|
||||
"ci_saved": "Cloud-Init settings saved",
|
||||
"ci_password_keep": "Leave blank to keep current",
|
||||
"ci_ip_mode": "IP mode",
|
||||
"ci_static": "Static",
|
||||
"ci_ip_none": "Not set",
|
||||
"ci_ip_cidr": "IP/CIDR",
|
||||
"ci_ip_required": "Enter an IP address for static mode",
|
||||
"ci_nameserver": "nameserver",
|
||||
"ci_searchdomain": "Search domain",
|
||||
"ci_reboot_hint": "Cloud-Init applies changes on the next VM reboot.",
|
||||
"boot_order": "Boot order",
|
||||
"boot_order_hint": "Devices are tried top to bottom. Disable a device to exclude it from booting.",
|
||||
"boot_order_saved": "Boot order saved",
|
||||
"boot_none_enabled": "No boot devices enabled — the VM will not boot.",
|
||||
"boot_disabled": "Disabled devices",
|
||||
"boot_enable": "Enable",
|
||||
"boot_disable": "Disable",
|
||||
"boot_network": "Network (PXE)",
|
||||
"vm_options": "VM options",
|
||||
"vm_options_saved": "VM options saved",
|
||||
"opt_agent": "QEMU Guest Agent",
|
||||
"opt_agent_hint": "Enables agent integration (needs qemu-guest-agent inside the VM).",
|
||||
"opt_tablet": "Tablet pointer",
|
||||
"opt_tablet_hint": "USB tablet for absolute mouse; disable to save resources on headless VMs.",
|
||||
"opt_kvm": "KVM hardware virtualization",
|
||||
"opt_kvm_hint": "Disable only for nested virtualization without KVM support.",
|
||||
"opt_hotplug": "Hotplug (network, disk, USB)",
|
||||
"opt_hotplug_hint": "Allow hot-adding devices while the VM is running.",
|
||||
"unlock": "Unlock",
|
||||
"unlocked": "Lock removed",
|
||||
"lock_current": "Current lock: {{lock}}",
|
||||
"no_lock": "No active lock.",
|
||||
"cpu_options": "CPU options",
|
||||
"cpu_saved": "CPU settings saved",
|
||||
"cpu_type": "CPU type",
|
||||
"cpu_type_hint": "\"host\" gives best performance but blocks live migration between different CPUs.",
|
||||
"sockets": "Sockets",
|
||||
"cpulimit": "CPU limit",
|
||||
"cpulimit_hint": "Max host CPU cores the VM may use (0 = unlimited).",
|
||||
"cpuunits": "CPU units (weight)",
|
||||
"cpuunits_hint": "Scheduler weight relative to other guests (default 100).",
|
||||
"numa": "Enable NUMA",
|
||||
"disk_management": "Disk management",
|
||||
"no_disks": "No disks attached",
|
||||
"disk_move": "Move disk",
|
||||
"disk_move_delete": "Delete source after move",
|
||||
"disk_moved": "Disk move started",
|
||||
"disk_add": "Add disk",
|
||||
"disk_added": "Disk {{dev}} added",
|
||||
"disk_bus": "Bus",
|
||||
"disk_detach_confirm": "Detach and delete disk {{dev}}? This destroys its data.",
|
||||
"disk_detached": "Disk detached",
|
||||
"autostart": "Autostart",
|
||||
"autostart_saved": "Autostart settings saved",
|
||||
"start_on_boot": "Start on boot",
|
||||
@@ -382,9 +437,19 @@
|
||||
"disk": "Disk",
|
||||
"uptime": "Uptime",
|
||||
"cluster_nodes": "Cluster Nodes",
|
||||
"tab_overview": "Overview",
|
||||
"tab_network": "Network",
|
||||
"tab_ha": "HA",
|
||||
"tab_firewall": "Firewall",
|
||||
"tab_pools": "Pools",
|
||||
"tab_disks": "Disks",
|
||||
"tab_admin": "Administration",
|
||||
"tab_access": "Access",
|
||||
"vm_list": "Virtual Machines on Server",
|
||||
"no_servers": "No servers configured",
|
||||
"add_server": "Add Server",
|
||||
"updates_available": "Updates available",
|
||||
"updates_available_count": "Updates available: {{count}}",
|
||||
"edit_server": "Edit Server",
|
||||
"test_connection": "Test Connection",
|
||||
"hostname": "Hostname / IP",
|
||||
@@ -667,7 +732,9 @@
|
||||
"apt_refreshed": "Package lists are refreshing",
|
||||
"up_to_date": "No updates",
|
||||
"updates_count": "Updates available: {{n}}",
|
||||
"upgrade_hint": "Installing updates is done in the node console (apt dist-upgrade) — the API does not support unattended upgrade.",
|
||||
"upgrade_hint": "\"Install updates\" opens the node console running apt dist-upgrade (confirmation required). Keep the tab open until it finishes.",
|
||||
"do_upgrade": "Install updates",
|
||||
"upgrade": "Upgrade",
|
||||
"repo_updated": "Repository updated",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
@@ -1218,6 +1285,8 @@
|
||||
"connecting": "Connecting to console...",
|
||||
"reconnect": "Reconnect",
|
||||
"connected": "Connected",
|
||||
"error": "Error"
|
||||
"error": "Error",
|
||||
"terminal_closed": "Terminal connection closed",
|
||||
"terminal_error": "Terminal connection error"
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,61 @@
|
||||
"migrate_online": "Онлайн (live) миграция",
|
||||
"migrate_online_hint_running": "Инстанс запущен — live-миграция переносит его без остановки.",
|
||||
"migrate_online_hint_stopped": "Инстанс остановлен — оставьте выключенным для обычной офлайн-миграции.",
|
||||
"serial_console": "Serial-консоль",
|
||||
"cloud_init": "Cloud-Init",
|
||||
"ci_no_drive": "У этой VM нет диска Cloud-Init — настройки применятся только после его добавления.",
|
||||
"ci_saved": "Настройки Cloud-Init сохранены",
|
||||
"ci_password_keep": "Пусто — не менять",
|
||||
"ci_ip_mode": "Режим IP",
|
||||
"ci_static": "Статический",
|
||||
"ci_ip_none": "Не задан",
|
||||
"ci_ip_cidr": "IP/CIDR",
|
||||
"ci_ip_required": "Укажите IP-адрес для статического режима",
|
||||
"ci_nameserver": "nameserver",
|
||||
"ci_searchdomain": "Домен поиска",
|
||||
"ci_reboot_hint": "Cloud-Init применяет изменения при следующей перезагрузке VM.",
|
||||
"boot_order": "Порядок загрузки",
|
||||
"boot_order_hint": "Устройства пробуются сверху вниз. Отключите устройство, чтобы исключить из загрузки.",
|
||||
"boot_order_saved": "Порядок загрузки сохранён",
|
||||
"boot_none_enabled": "Нет активных устройств загрузки — VM не загрузится.",
|
||||
"boot_disabled": "Отключённые устройства",
|
||||
"boot_enable": "Включить",
|
||||
"boot_disable": "Отключить",
|
||||
"boot_network": "Сеть (PXE)",
|
||||
"vm_options": "Опции VM",
|
||||
"vm_options_saved": "Опции VM сохранены",
|
||||
"opt_agent": "QEMU Guest Agent",
|
||||
"opt_agent_hint": "Включает интеграцию с агентом (нужен qemu-guest-agent внутри VM).",
|
||||
"opt_tablet": "Tablet-указатель",
|
||||
"opt_tablet_hint": "USB-планшет для абсолютной мыши; отключите для экономии ресурсов на headless VM.",
|
||||
"opt_kvm": "Аппаратная виртуализация KVM",
|
||||
"opt_kvm_hint": "Отключайте только для вложенной виртуализации без поддержки KVM.",
|
||||
"opt_hotplug": "Hotplug (сеть, диск, USB)",
|
||||
"opt_hotplug_hint": "Разрешить горячее добавление устройств на работающей VM.",
|
||||
"unlock": "Снять блокировку",
|
||||
"unlocked": "Блокировка снята",
|
||||
"lock_current": "Текущая блокировка: {{lock}}",
|
||||
"no_lock": "Активной блокировки нет.",
|
||||
"cpu_options": "Опции CPU",
|
||||
"cpu_saved": "Настройки CPU сохранены",
|
||||
"cpu_type": "Тип CPU",
|
||||
"cpu_type_hint": "«host» даёт максимум производительности, но мешает live-миграции между разными CPU.",
|
||||
"sockets": "Сокеты",
|
||||
"cpulimit": "Лимит CPU",
|
||||
"cpulimit_hint": "Макс. число ядер хоста для VM (0 — без лимита).",
|
||||
"cpuunits": "Вес CPU (units)",
|
||||
"cpuunits_hint": "Вес планировщика относительно других гостей (по умолчанию 100).",
|
||||
"numa": "Включить NUMA",
|
||||
"disk_management": "Управление дисками",
|
||||
"no_disks": "Диски не подключены",
|
||||
"disk_move": "Переместить диск",
|
||||
"disk_move_delete": "Удалить исходный после перемещения",
|
||||
"disk_moved": "Перемещение диска запущено",
|
||||
"disk_add": "Добавить диск",
|
||||
"disk_added": "Диск {{dev}} добавлен",
|
||||
"disk_bus": "Шина",
|
||||
"disk_detach_confirm": "Отключить и удалить диск {{dev}}? Данные будут уничтожены.",
|
||||
"disk_detached": "Диск отключён",
|
||||
"autostart": "Автозапуск",
|
||||
"autostart_saved": "Настройки автозапуска сохранены",
|
||||
"start_on_boot": "Запускать при загрузке",
|
||||
@@ -382,9 +437,19 @@
|
||||
"disk": "Диск",
|
||||
"uptime": "Аптайм",
|
||||
"cluster_nodes": "Узлы кластера",
|
||||
"tab_overview": "Обзор",
|
||||
"tab_network": "Сеть",
|
||||
"tab_ha": "HA",
|
||||
"tab_firewall": "Firewall",
|
||||
"tab_pools": "Пулы",
|
||||
"tab_disks": "Диски",
|
||||
"tab_admin": "Администрирование",
|
||||
"tab_access": "Доступ",
|
||||
"vm_list": "Виртуальные машины на сервере",
|
||||
"no_servers": "Серверы не настроены",
|
||||
"add_server": "Добавить сервер",
|
||||
"updates_available": "Доступны обновления",
|
||||
"updates_available_count": "Доступно обновлений: {{count}}",
|
||||
"edit_server": "Редактировать сервер",
|
||||
"test_connection": "Проверить соединение",
|
||||
"hostname": "Хост / IP",
|
||||
@@ -667,7 +732,9 @@
|
||||
"apt_refreshed": "Списки пакетов обновляются",
|
||||
"up_to_date": "Обновлений нет",
|
||||
"updates_count": "Доступно обновлений: {{n}}",
|
||||
"upgrade_hint": "Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.",
|
||||
"upgrade_hint": "«Установить обновления» откроет консоль ноды с apt dist-upgrade (потребуется подтверждение). Не закрывайте вкладку до завершения.",
|
||||
"do_upgrade": "Установить обновления",
|
||||
"upgrade": "Upgrade",
|
||||
"repo_updated": "Репозиторий обновлён",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить"
|
||||
@@ -1218,6 +1285,110 @@
|
||||
"connecting": "Подключение к консоли...",
|
||||
"reconnect": "Переподключить",
|
||||
"connected": "Подключено",
|
||||
"error": "Ошибка"
|
||||
"error": "Ошибка",
|
||||
"terminal_closed": "Соединение с терминалом закрыто",
|
||||
"terminal_error": "Ошибка соединения с терминалом"
|
||||
},
|
||||
"perms": {
|
||||
"role": {
|
||||
"admin": { "name": "Администратор", "desc": "Полный доступ ко всем функциям" },
|
||||
"moderator": { "name": "Модератор", "desc": "Управление ВМ и просмотр логов" },
|
||||
"user": { "name": "Пользователь", "desc": "Стандартный пользователь с ограниченным доступом" },
|
||||
"demo": { "name": "Демо-пользователь", "desc": "Доступ только для чтения (демонстрация)" }
|
||||
},
|
||||
"cat": {
|
||||
"Dashboard": "Дашборд",
|
||||
"Proxmox Servers": "Серверы Proxmox",
|
||||
"Virtual Machines": "Виртуальные машины",
|
||||
"Containers": "Контейнеры",
|
||||
"Templates": "Шаблоны",
|
||||
"Storage": "Хранилище",
|
||||
"Backups": "Резервные копии",
|
||||
"IPAM": "IPAM",
|
||||
"User Management": "Управление пользователями",
|
||||
"Role Management": "Управление ролями",
|
||||
"Logs": "Логи",
|
||||
"Settings": "Настройки",
|
||||
"Notifications": "Уведомления",
|
||||
"Firewall": "Firewall",
|
||||
"Networking": "Сеть",
|
||||
"Node Administration": "Администрирование ноды",
|
||||
"Resource Pools": "Пулы ресурсов",
|
||||
"PVE Access": "Доступ PVE"
|
||||
},
|
||||
"label": {
|
||||
"dashboard_view": "Просмотр дашборда",
|
||||
"server_view": "Просмотр серверов",
|
||||
"server_create": "Добавление сервера",
|
||||
"server_update": "Редактирование сервера",
|
||||
"server_delete": "Удаление сервера",
|
||||
"server_manage": "Управление серверами",
|
||||
"cluster_manage": "Управление топологией кластера",
|
||||
"vm_view": "Просмотр ВМ",
|
||||
"vm_create": "Создание ВМ",
|
||||
"vm_update": "Изменение ВМ",
|
||||
"vm_delete": "Удаление ВМ",
|
||||
"vm_start": "Запуск ВМ",
|
||||
"vm_stop": "Остановка ВМ",
|
||||
"vm_restart": "Перезапуск ВМ",
|
||||
"vm_console": "Консоль ВМ",
|
||||
"vm_migrate": "Миграция ВМ",
|
||||
"vm_execute": "Выполнение команд",
|
||||
"vm_manage": "Управление ВМ",
|
||||
"lxc_view": "Просмотр контейнеров",
|
||||
"lxc_create": "Создание контейнеров",
|
||||
"lxc_update": "Изменение контейнеров",
|
||||
"lxc_delete": "Удаление контейнеров",
|
||||
"lxc_start": "Запуск контейнеров",
|
||||
"lxc_stop": "Остановка контейнеров",
|
||||
"lxc_restart": "Перезапуск контейнеров",
|
||||
"lxc_console": "Консоль контейнера",
|
||||
"lxc_migrate": "Миграция контейнеров",
|
||||
"template_view": "Просмотр шаблонов",
|
||||
"template_create": "Создание шаблонов",
|
||||
"template_update": "Изменение шаблонов",
|
||||
"template_delete": "Удаление шаблонов",
|
||||
"template_manage": "Управление шаблонами",
|
||||
"storage_view": "Просмотр хранилища",
|
||||
"storage_manage": "Управление хранилищем",
|
||||
"backup_view": "Просмотр бэкапов",
|
||||
"backup_create": "Создание бэкапов",
|
||||
"backup_restore": "Восстановление бэкапов",
|
||||
"backup_delete": "Удаление бэкапов",
|
||||
"backup_manage": "Управление бэкапами",
|
||||
"ipam_view": "Просмотр IPAM",
|
||||
"ipam_manage": "Управление IPAM",
|
||||
"user_view": "Просмотр пользователей",
|
||||
"user_create": "Создание пользователей",
|
||||
"user_update": "Изменение пользователей",
|
||||
"user_delete": "Удаление пользователей",
|
||||
"user_manage": "Управление пользователями",
|
||||
"quota_view": "Просмотр квот",
|
||||
"quota_manage": "Управление квотами",
|
||||
"role_view": "Просмотр ролей",
|
||||
"role_create": "Создание ролей",
|
||||
"role_update": "Изменение ролей",
|
||||
"role_delete": "Удаление ролей",
|
||||
"role_manage": "Управление ролями",
|
||||
"log_view": "Просмотр логов",
|
||||
"log_export": "Экспорт логов",
|
||||
"log_delete": "Удаление логов",
|
||||
"setting_view": "Просмотр настроек",
|
||||
"setting_update": "Изменение настроек",
|
||||
"setting_manage": "Управление безопасностью",
|
||||
"notification_view": "Просмотр уведомлений",
|
||||
"notification_manage": "Управление уведомлениями",
|
||||
"firewall_view": "Просмотр Firewall",
|
||||
"firewall_manage": "Управление Firewall",
|
||||
"network_view": "Просмотр сети",
|
||||
"network_manage": "Управление сетью",
|
||||
"node_view": "Просмотр администрирования ноды",
|
||||
"node_manage": "Управление администрированием ноды",
|
||||
"node_upgrade": "Обновление пакетов ноды",
|
||||
"pool_view": "Просмотр пулов",
|
||||
"pool_manage": "Управление пулами",
|
||||
"access_view": "Просмотр доступа PVE",
|
||||
"access_manage": "Управление доступом PVE"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import InstancesPage from '@/features/instances/InstancesPage';
|
||||
import InstanceDetailPage from '@/features/instances/InstanceDetailPage';
|
||||
const SnapshotArchivesPage = lazy(() => import('./features/instances/SnapshotArchivesPage.tsx'));
|
||||
import ConsolePage from '@/features/console/ConsolePage';
|
||||
const NodeShellPage = lazy(() => import('./features/console/NodeShellPage.tsx'));
|
||||
|
||||
// Lazy-loaded pages
|
||||
// NOTE: явные относительные пути с расширением .tsx — обход бага rolldown-vite
|
||||
@@ -61,6 +62,11 @@ export const router = createBrowserRouter([
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
// Console — separate layout (no sidebar)
|
||||
{
|
||||
path: '/console/node/:serverId/:node',
|
||||
element: <ConsoleLayout />,
|
||||
children: [{ index: true, element: <SuspenseWrap><NodeShellPage /></SuspenseWrap> }],
|
||||
},
|
||||
{
|
||||
path: '/console/:serverId/:vmid',
|
||||
element: <ConsoleLayout />,
|
||||
|
||||
Reference in New Issue
Block a user