feat: Proxmox management suite — hardware, firewall, pools, disks, storage, access, node admin
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 54s
Deploy to Production / Trigger production update (push) Successful in 7s

- 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:
markmorado
2026-07-04 05:40:52 +05:00
parent 5c8baefb3f
commit 00e36e1cc6
31 changed files with 2576 additions and 357 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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>
);
}

View File

@@ -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();
}}
>

View 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>
);
}

View File

@@ -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 образ')}

View File

@@ -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">

View File

@@ -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 */}

View File

@@ -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>
);
}

View File

@@ -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>
);

View 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>
)}
</>
);
}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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"
}
}
}

View File

@@ -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 />,