i18n(dashboard,instances): translate dashboard, action dialogs and resource cards
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s

This commit is contained in:
2026-08-13 17:19:26 +05:00
parent 43db0f79ca
commit d6ea83c8aa
5 changed files with 336 additions and 153 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, ChevronDown } from 'lucide-react' import { ExternalLink, ChevronDown } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -85,10 +86,11 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
serverList: Rec[]; nodeStats: Rec[]; allVMs: Rec[] serverList: Rec[]; nodeStats: Rec[]; allVMs: Rec[]
memPercent: number; diskPercent: number; availableIPs: number memPercent: number; diskPercent: number; availableIPs: number
}) { }) {
const { t } = useTranslation()
const clusterName = const clusterName =
(serverList[0]?.cluster_name as string) || (serverList[0]?.cluster_name as string) ||
(serverList[0]?.server_name as string) || (serverList[0]?.server_name as string) ||
'Кластер' t('dashboard.cluster')
const totalVMs = allVMs.filter(v => v.type === 'qemu').length const totalVMs = allVMs.filter(v => v.type === 'qemu').length
const totalCTs = allVMs.filter(v => v.type === 'lxc').length const totalCTs = allVMs.filter(v => v.type === 'lxc').length
const totalNodeMem = nodeStats.reduce((s, n) => s + (Number(n.maxmem) || 0), 0) const totalNodeMem = nodeStats.reduce((s, n) => s + (Number(n.maxmem) || 0), 0)
@@ -111,12 +113,12 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
<div className={`grid ${cols} gap-4`}> <div className={`grid ${cols} gap-4`}>
{availableIPs > 0 && ( {availableIPs > 0 && (
<div> <div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">IPv4 доступно</p> <p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('dashboard.ipv4_available')}</p>
<p className="font-mono text-3xl font-bold tabular-nums text-success">{availableIPs.toLocaleString()}</p> <p className="font-mono text-3xl font-bold tabular-nums text-success">{availableIPs.toLocaleString()}</p>
</div> </div>
)} )}
<div> <div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Узлов</p> <p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('dashboard.nodes_count')}</p>
<p className="font-mono text-3xl font-bold tabular-nums">{nodeStats.length}</p> <p className="font-mono text-3xl font-bold tabular-nums">{nodeStats.length}</p>
</div> </div>
<div> <div>
@@ -132,7 +134,7 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
<div className="flex items-center justify-between text-xs pt-1"> <div className="flex items-center justify-between text-xs pt-1">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Оверселлинг RAM = <span className="font-mono text-foreground font-medium">{overselling}</span> {t('dashboard.ram_oversell')} <span className="font-mono text-foreground font-medium">{overselling}</span>
</span> </span>
<span className="flex items-center gap-1 text-muted-foreground"> <span className="flex items-center gap-1 text-muted-foreground">
<ExternalLink className="h-3 w-3" /> Grafana <ExternalLink className="h-3 w-3" /> Grafana
@@ -145,6 +147,7 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
// ==================== Tasks Today Card ==================== // ==================== Tasks Today Card ====================
function TasksTodayCard() { function TasksTodayCard() {
const { t } = useTranslation()
const { data: tasksData } = useAllTasks({ limit: 500 }) const { data: tasksData } = useAllTasks({ limit: 500 })
const { data: activeData } = useActiveTaskCount() const { data: activeData } = useActiveTaskCount()
@@ -169,16 +172,16 @@ function TasksTodayCard() {
).length ).length
const rows = [ const rows = [
{ label: 'Выполнено:', value: completed, red: false }, { label: t('dashboard.tasks_done'), value: completed, red: false },
{ label: 'Выполняются:', value: running, red: false }, { label: t('dashboard.tasks_running'), value: running, red: false },
{ label: 'В очереди:', value: queued, red: false }, { label: t('dashboard.tasks_queued'), value: queued, red: false },
{ label: 'Ошибки:', value: errors, red: errors > 0 }, { label: t('dashboard.tasks_errors'), value: errors, red: errors > 0 },
] ]
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader className="pb-2 pt-5 px-5"> <CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-semibold">Задачи за сутки</CardTitle> <CardTitle className="text-sm font-semibold">{t('dashboard.tasks_today')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-5 pb-5 space-y-2.5"> <CardContent className="px-5 pb-5 space-y-2.5">
{rows.map(({ label, value, red }) => ( {rows.map(({ label, value, red }) => (
@@ -188,7 +191,7 @@ function TasksTodayCard() {
</div> </div>
))} ))}
<div className="border-t border-border pt-2.5 flex items-center justify-between"> <div className="border-t border-border pt-2.5 flex items-center justify-between">
<span className="text-sm font-medium">Всего</span> <span className="text-sm font-medium">{t('dashboard.tasks_total')}</span>
<span className="font-mono text-sm font-bold tabular-nums">{today.length}</span> <span className="font-mono text-sm font-bold tabular-nums">{today.length}</span>
</div> </div>
</CardContent> </CardContent>
@@ -200,11 +203,12 @@ function TasksTodayCard() {
function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: { function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
cpuPercent: number; memPercent: number; serverName: string; serverCount: number cpuPercent: number; memPercent: number; serverName: string; serverCount: number
}) { }) {
const { t } = useTranslation()
return ( return (
<Card className="h-full gap-0 py-0 overflow-hidden"> <Card className="h-full gap-0 py-0 overflow-hidden">
<div className={`h-[3px] w-full ${healthColor(cpuPercent, memPercent)}`} /> <div className={`h-[3px] w-full ${healthColor(cpuPercent, memPercent)}`} />
<CardHeader className="pb-2 pt-4 px-5"> <CardHeader className="pb-2 pt-4 px-5">
<CardTitle className="text-sm font-semibold truncate">{serverName || 'Сервер'}</CardTitle> <CardTitle className="text-sm font-semibold truncate">{serverName || t('dashboard.server')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-5 pb-5 space-y-4"> <CardContent className="px-5 pb-5 space-y-4">
<div className="flex justify-around"> <div className="flex justify-around">
@@ -213,11 +217,11 @@ function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
</div> </div>
<div className="space-y-1.5 text-xs"> <div className="space-y-1.5 text-xs">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Серверов:</span> <span className="text-muted-foreground">{t('dashboard.servers_count')}:</span>
<span className="font-mono font-medium">{serverCount}</span> <span className="font-mono font-medium">{serverCount}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Платформа:</span> <span className="text-muted-foreground">{t('dashboard.platform')}:</span>
<span className="font-medium">Proxmox VE</span> <span className="font-medium">Proxmox VE</span>
</div> </div>
</div> </div>
@@ -228,6 +232,7 @@ function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
// ==================== Cluster Nodes Section ==================== // ==================== Cluster Nodes Section ====================
function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) { function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) {
const { t } = useTranslation()
const [tab, setTab] = useState<'now' | 'day'>('now') const [tab, setTab] = useState<'now' | 'day'>('now')
// Top-10 nodes by disk usage % — one donut slice per node. // Top-10 nodes by disk usage % — one donut slice per node.
@@ -256,19 +261,19 @@ function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) {
<Card> <Card>
<CardHeader className="pb-3 pt-4 px-5"> <CardHeader className="pb-3 pt-4 px-5">
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<CardTitle className="text-sm font-semibold">Узлы кластера</CardTitle> <CardTitle className="text-sm font-semibold">{t('dashboard.cluster_nodes')}</CardTitle>
<div className="flex gap-1"> <div className="flex gap-1">
{(['now', 'day'] as const).map(t => ( {(['now', 'day'] as const).map(tab_key => (
<button <button
key={t} key={tab_key}
onClick={() => setTab(t)} onClick={() => setTab(tab_key)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card ${ className={`rounded-full px-3 py-1 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card ${
tab === t tab === tab_key
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t === 'now' ? 'Топ-10 сейчас' : 'Топ-10 за сутки'} {tab_key === 'now' ? t('dashboard.top10_now') : t('dashboard.top10_day')}
</button> </button>
))} ))}
</div> </div>
@@ -276,13 +281,13 @@ function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) {
</CardHeader> </CardHeader>
<CardContent className="px-5 pb-4"> <CardContent className="px-5 pb-4">
<div className="grid grid-cols-[1fr_56px_56px_56px] gap-2 px-2 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border mb-1"> <div className="grid grid-cols-[1fr_56px_56px_56px] gap-2 px-2 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border mb-1">
<span>Название узлов</span> <span>{t('dashboard.node_name')}</span>
<span className="text-right">CPU, %</span> <span className="text-right">CPU, %</span>
<span className="text-right">RAM</span> <span className="text-right">RAM</span>
<span className="text-right">Disk</span> <span className="text-right">Disk</span>
</div> </div>
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет данных</p> <p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
) : ( ) : (
sorted.map((n, i) => { sorted.map((n, i) => {
const cpu = Number(n.cpu_pct) || 0 const cpu = Number(n.cpu_pct) || 0
@@ -320,12 +325,12 @@ function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) {
{/* Disk stats — donut, one slice per node by disk usage % */} {/* Disk stats — donut, one slice per node by disk usage % */}
<Card> <Card>
<CardHeader className="pb-2 pt-4 px-5"> <CardHeader className="pb-2 pt-4 px-5">
<CardTitle className="text-sm font-semibold">ТОП статистика по диску</CardTitle> <CardTitle className="text-sm font-semibold">{t('dashboard.disk_top')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-3 pb-4"> <CardContent className="px-3 pb-4">
{pieData.length === 0 ? ( {pieData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-sm text-muted-foreground"> <div className="h-52 flex items-center justify-center text-sm text-muted-foreground">
Нет данных {t('common.no_data')}
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>

View File

@@ -92,6 +92,7 @@ export default function InstanceActionDialogs(props: Props) {
const CLONE_AUTO = '__auto__'; const CLONE_AUTO = '__auto__';
function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) { function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [full, setFull] = useState(true); const [full, setFull] = useState(true);
const [targetNode, setTargetNode] = useState(''); const [targetNode, setTargetNode] = useState('');
@@ -109,7 +110,6 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
const effectiveNode = targetNode || node; const effectiveNode = targetNode || node;
const { data: storages = [] } = useLXCStorages(serverId, effectiveNode); const { data: storages = [] } = useLXCStorages(serverId, effectiveNode);
// Reset form whenever the dialog is opened for a fresh instance.
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setNewName(''); setNewName('');
@@ -138,7 +138,7 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
id: data.task_id, id: data.task_id,
name: data.name, name: data.name,
status: 'pending', status: 'pending',
step: 'В очереди...', step: t('common.queued'),
progress: 0, progress: 0,
vmid: null, vmid: null,
node: targetNode || node, node: targetNode || node,
@@ -146,7 +146,7 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
kind: 'clone', kind: 'clone',
server_id: serverId, server_id: serverId,
}); });
toast.success('Клонирование запущено в фоне'); toast.success(t('instances.clone_started'));
onClose(); onClose();
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
@@ -158,22 +158,22 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><Copy className="h-4 w-4" /> Клонировать</DialogTitle> <DialogTitle className="flex items-center gap-2"><Copy className="h-4 w-4" /> {t('instances.clone_title')}</DialogTitle>
<DialogDescription>Создать копию <strong>{name ?? `#${vmid}`}</strong></DialogDescription> <DialogDescription>{t('instances.clone_desc', { name: name ?? `#${vmid}` })}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="clone-name">Имя новой инстанции</Label> <Label htmlFor="clone-name">{t('instances.clone_new_name')}</Label>
<Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} /> <Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Целевой узел</Label> <Label>{t('instances.clone_target_node')}</Label>
<Select value={targetNode || CLONE_AUTO} onValueChange={(v) => { setTargetNode(v && v !== CLONE_AUTO ? v : ''); setTargetStorage(''); }}> <Select value={targetNode || CLONE_AUTO} onValueChange={(v) => { setTargetNode(v && v !== CLONE_AUTO ? v : ''); setTargetStorage(''); }}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={CLONE_AUTO}>Тот же узел ({node})</SelectItem> <SelectItem value={CLONE_AUTO}>{t('instances.clone_same_node', { node })}</SelectItem>
{nodes.filter((n) => n.node !== node).map((n) => ( {nodes.filter((n) => n.node !== node).map((n) => (
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem> <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
))} ))}
@@ -182,11 +182,11 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Хранилище</Label> <Label>{t('instances.clone_storage')}</Label>
<Select value={targetStorage || CLONE_AUTO} onValueChange={(v) => setTargetStorage(v && v !== CLONE_AUTO ? v : '')}> <Select value={targetStorage || CLONE_AUTO} onValueChange={(v) => setTargetStorage(v && v !== CLONE_AUTO ? v : '')}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={CLONE_AUTO}>Как у источника</SelectItem> <SelectItem value={CLONE_AUTO}>{t('instances.clone_same_storage')}</SelectItem>
{storages.map((s) => ( {storages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem> <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
))} ))}
@@ -197,11 +197,11 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
{isAdmin && ( {isAdmin && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Владелец</Label> <Label>{t('instances.clone_owner')}</Label>
<Select value={ownerId ? String(ownerId) : CLONE_AUTO} onValueChange={(v) => setOwnerId(v === CLONE_AUTO ? null : Number(v))}> <Select value={ownerId ? String(ownerId) : CLONE_AUTO} onValueChange={(v) => setOwnerId(v === CLONE_AUTO ? null : Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={CLONE_AUTO}>Я ({profile?.username})</SelectItem> <SelectItem value={CLONE_AUTO}>{t('instances.clone_owner_me', { username: profile?.username })}</SelectItem>
{allUsers.map((u) => ( {allUsers.map((u) => (
<SelectItem key={u.id} value={String(u.id)}> <SelectItem key={u.id} value={String(u.id)}>
{u.username}{u.full_name ? ` (${u.full_name})` : ''} {u.username}{u.full_name ? ` (${u.full_name})` : ''}
@@ -213,20 +213,20 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="clone-desc">Описание <span className="text-muted-foreground">(необязательно)</span></Label> <Label htmlFor="clone-desc">{t('instances.clone_desc_label')} <span className="text-muted-foreground">({t('common.optional')})</span></Label>
<Input id="clone-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Заметка для новой инстанции" /> <Input id="clone-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('instances.clone_desc_placeholder')} />
</div> </div>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox checked={full} onChange={(e) => setFull(e.target.checked)} /> <Checkbox checked={full} onChange={(e) => setFull(e.target.checked)} />
<span>Полный клон (full clone)</span> <span>{t('instances.clone_full')}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!newName.trim() || clone.isPending}> <Button onClick={submit} disabled={!newName.trim() || clone.isPending}>
{clone.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {clone.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Клонировать {t('instances.clone_btn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -252,7 +252,6 @@ function MigrateDialog({ open, onClose, serverId, vmid, type, node, name }: Omit
const isRunning = status?.status === 'running'; const isRunning = status?.status === 'running';
const { data: storages = [] } = useLXCStorages(serverId, targetNode || node); const { data: storages = [] } = useLXCStorages(serverId, targetNode || node);
// Сброс формы при открытии; online по умолчанию = инстанс запущен.
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setTargetNode(''); setTargetNode('');
@@ -275,7 +274,7 @@ function MigrateDialog({ open, onClose, serverId, vmid, type, node, name }: Omit
id: data.task_id, id: data.task_id,
name: data.name, name: data.name,
status: 'pending', status: 'pending',
step: 'В очереди...', step: t('common.queued'),
progress: 0, progress: 0,
vmid, vmid,
node: targetNode, node: targetNode,
@@ -363,7 +362,6 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
const addDeployTask = useDeployTasksStore((s) => s.addTask); const addDeployTask = useDeployTasksStore((s) => s.addTask);
const { data: allServers = [] } = useServers(); const { data: allServers = [] } = useServers();
// Только другие кластеры, настроенные через API-токен — remote_migrate не принимает пароль.
const targetServers = allServers.filter((s) => s.id !== serverId && !s.use_password); const targetServers = allServers.filter((s) => s.id !== serverId && !s.use_password);
const { data: nodesResp } = useNodes(targetServerId ?? 0); const { data: nodesResp } = useNodes(targetServerId ?? 0);
@@ -402,7 +400,7 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
id: data.task_id, id: data.task_id,
name: data.name, name: data.name,
status: 'pending', status: 'pending',
step: 'В очереди...', step: t('common.queued'),
progress: 0, progress: 0,
vmid, vmid,
node: targetNode, node: targetNode,
@@ -410,7 +408,7 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
kind: 'remote_migrate', kind: 'remote_migrate',
server_id: serverId, server_id: serverId,
}); });
toast.success(t('instances.remote_migrate_started', 'Миграция на другой кластер запущена в фоне')); toast.success(t('instances.remote_migrate_started'));
onClose(); onClose();
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
@@ -423,20 +421,20 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4" /> {t('instances.remote_migrate', 'Миграция на другой кластер')} <ArrowRightLeft className="h-4 w-4" /> {t('instances.remote_migrate')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t('instances.remote_migrate_desc', 'Перенести на независимый Proxmox-кластер (не ноду текущего)')} <strong>{name ?? `#${vmid}`}</strong> {t('instances.remote_migrate_desc')} <strong>{name ?? `#${vmid}`}</strong>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>{t('instances.remote_migrate_target_server', 'Целевой кластер')}</Label> <Label>{t('instances.remote_migrate_target_server')}</Label>
<Select <Select
value={targetServerId ? String(targetServerId) : ''} value={targetServerId ? String(targetServerId) : ''}
onValueChange={(v) => { if (v) { setTargetServerId(Number(v)); setTargetNode(''); setTargetStorage(''); setTargetBridge(''); } }} onValueChange={(v) => { if (v) { setTargetServerId(Number(v)); setTargetNode(''); setTargetStorage(''); setTargetBridge(''); } }}
> >
<SelectTrigger><SelectValue placeholder={t('instances.remote_migrate_select_server', 'Выберите сервер')} /></SelectTrigger> <SelectTrigger><SelectValue placeholder={t('instances.remote_migrate_select_server')} /></SelectTrigger>
<SelectContent> <SelectContent>
{targetServers.map((s) => ( {targetServers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem> <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
@@ -445,7 +443,7 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
</Select> </Select>
{targetServers.length === 0 && ( {targetServers.length === 0 && (
<p className="text-xs text-warning"> <p className="text-xs text-warning">
{t('instances.remote_migrate_no_targets', 'Нет других серверов с API-токеном. Remote-миграция требует, чтобы у целевого сервера была настроена авторизация по API-токену (не по паролю).')} {t('instances.remote_migrate_no_targets')}
</p> </p>
)} )}
</div> </div>
@@ -476,11 +474,11 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>{t('instances.remote_migrate_target_bridge', 'Сетевой мост')}</Label> <Label>{t('instances.remote_migrate_target_bridge')}</Label>
<Select value={targetBridge || REMOTE_MIGRATE_SAME} onValueChange={(v) => setTargetBridge(!v || v === REMOTE_MIGRATE_SAME ? '' : v)}> <Select value={targetBridge || REMOTE_MIGRATE_SAME} onValueChange={(v) => setTargetBridge(!v || v === REMOTE_MIGRATE_SAME ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={REMOTE_MIGRATE_SAME}>{t('instances.remote_migrate_same_bridge', 'Как на источнике')}</SelectItem> <SelectItem value={REMOTE_MIGRATE_SAME}>{t('instances.remote_migrate_same_bridge')}</SelectItem>
{bridges.map((b) => ( {bridges.map((b) => (
<SelectItem key={b.iface} value={b.iface}>{b.iface}</SelectItem> <SelectItem key={b.iface} value={b.iface}>{b.iface}</SelectItem>
))} ))}
@@ -499,14 +497,14 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
<label className="flex items-center gap-2.5 cursor-pointer"> <label className="flex items-center gap-2.5 cursor-pointer">
<Checkbox checked={deleteSource} onChange={(e) => setDeleteSource(e.target.checked)} /> <Checkbox checked={deleteSource} onChange={(e) => setDeleteSource(e.target.checked)} />
<span className="text-sm">{t('instances.remote_migrate_delete_source', 'Удалить с исходного кластера после успешного переноса')}</span> <span className="text-sm">{t('instances.remote_migrate_delete_source')}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!targetServerId || !targetNode || migrate.isPending}> <Button onClick={submit} disabled={!targetServerId || !targetNode || migrate.isPending}>
{migrate.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {migrate.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.remote_migrate', 'Миграция на другой кластер')} {t('instances.remote_migrate')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -517,12 +515,12 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
// ==================== Reinstall ==================== // ==================== Reinstall ====================
function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) { function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [confirm, setConfirm] = useState(''); const [confirm, setConfirm] = useState('');
const reinstall = useReinstallInstance(serverId, vmid, node); const reinstall = useReinstallInstance(serverId, vmid, node);
const addDeployTask = useDeployTasksStore((s) => s.addTask); const addDeployTask = useDeployTasksStore((s) => s.addTask);
const expected = name || String(vmid); const expected = name || String(vmid);
// Reinstall via this endpoint currently supports only saved-template instances.
const submit = () => { const submit = () => {
if (confirm !== expected) return; if (confirm !== expected) return;
reinstall.mutate(undefined, { reinstall.mutate(undefined, {
@@ -531,7 +529,7 @@ function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Om
id: data.task_id, id: data.task_id,
name: data.name || expected, name: data.name || expected,
status: 'pending', status: 'pending',
step: 'В очереди...', step: t('common.queued'),
progress: 0, progress: 0,
vmid, vmid,
node, node,
@@ -539,7 +537,7 @@ function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Om
kind: 'reinstall', kind: 'reinstall',
server_id: serverId, server_id: serverId,
}); });
toast.success('Переустановка запущена в фоне'); toast.success(t('instances.reinstall_started'));
setConfirm(''); setConfirm('');
onClose(); onClose();
}, },
@@ -551,25 +549,25 @@ function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Om
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-4 w-4" /> Переустановить</DialogTitle> <DialogTitle className="flex items-center gap-2"><Sparkles className="h-4 w-4" /> {t('instances.reinstall_title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Это пересоздаст инстанс <strong>{expected}</strong> из исходного шаблона. Все данные будут утеряны. {t('instances.reinstall_desc', { name: expected })}
{type === 'lxc' && ( {type === 'lxc' && (
<span className="mt-2 block text-warning">Поддержка LXC — экспериментальная.</span> <span className="mt-2 block text-warning">{t('instances.reinstall_lxc_warn')}</span>
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label>Введите <strong className="text-foreground">{expected}</strong> для подтверждения</Label> <Label>{t('instances.reinstall_confirm_label')} <strong className="text-foreground">{expected}</strong></Label>
<Input value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={expected} /> <Input value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={expected} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="destructive" onClick={submit} disabled={confirm !== expected || reinstall.isPending}> <Button variant="destructive" onClick={submit} disabled={confirm !== expected || reinstall.isPending}>
{reinstall.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {reinstall.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Переустановить {t('instances.reinstall_btn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -600,7 +598,7 @@ function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node, name
id: data.task_id, id: data.task_id,
name: data.name || (name || `#${vmid}`), name: data.name || (name || `#${vmid}`),
status: 'pending', status: 'pending',
step: 'В очереди...', step: t('common.queued'),
progress: 0, progress: 0,
vmid, vmid,
node, node,
@@ -608,7 +606,7 @@ function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node, name
kind: 'change_password', kind: 'change_password',
server_id: serverId, server_id: serverId,
}); });
toast.success('Смена пароля запущена в фоне'); toast.success(t('instances.password_started'));
setPassword(''); setPassword('');
onClose(); onClose();
}, },
@@ -621,20 +619,20 @@ function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node, name
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><KeyRound className="h-4 w-4" /> Изменить пароль</DialogTitle> <DialogTitle className="flex items-center gap-2"><KeyRound className="h-4 w-4" /> {t('instances.change_password_title')}</DialogTitle>
<DialogDescription> <DialogDescription>
{type === 'qemu' {type === 'qemu'
? 'Требуется установленный qemu-guest-agent внутри VM.' ? t('instances.change_password_qemu_hint')
: 'Контейнер должен быть запущен.'} : t('instances.change_password_lxc_hint')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label>Пользователь</Label> <Label>{t('instances.change_password_user')}</Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('common.placeholder_root')} /> <Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('common.placeholder_root')} />
</div> </div>
<div> <div>
<Label>Новый пароль</Label> <Label>{t('instances.change_password_new')}</Label>
<div className="relative"> <div className="relative">
<Input <Input
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
@@ -654,10 +652,10 @@ function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node, name
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!password || mut.isPending}> <Button onClick={submit} disabled={!password || mut.isPending}>
{mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Изменить {t('instances.change_password_btn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -668,12 +666,12 @@ function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node, name
// ==================== Notes ==================== // ==================== Notes ====================
function NotesDialog({ open, onClose, serverId, vmid, type, node, description }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) { function NotesDialog({ open, onClose, serverId, vmid, type, node, description }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const config = useVMConfig(serverId, vmid, type, node, open); const config = useVMConfig(serverId, vmid, type, node, open);
const remote = (config.data?.description as string | undefined) ?? description ?? ''; const remote = (config.data?.description as string | undefined) ?? description ?? '';
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
// Sync remote → local when dialog opens or config loads, unless user typed
useEffect(() => { useEffect(() => {
if (open && !touched) setValue(remote); if (open && !touched) setValue(remote);
if (!open) setTouched(false); if (!open) setTouched(false);
@@ -699,21 +697,21 @@ function NotesDialog({ open, onClose, serverId, vmid, type, node, description }:
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><FileText className="h-4 w-4" /> Примечание</DialogTitle> <DialogTitle className="flex items-center gap-2"><FileText className="h-4 w-4" /> {t('instances.notes_title')}</DialogTitle>
<DialogDescription>Свободный текст / markdown, видимый в Proxmox UI.</DialogDescription> <DialogDescription>{t('instances.notes_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<textarea <textarea
value={value} value={value}
onChange={(e) => { setTouched(true); setValue(e.target.value); }} onChange={(e) => { setTouched(true); setValue(e.target.value); }}
rows={8} rows={8}
className="min-h-32 w-full rounded-md border bg-background p-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring" className="min-h-32 w-full rounded-md border bg-background p-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
placeholder="Описание / заметки об инстансе..." placeholder={t('instances.notes_placeholder')}
/> />
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={mut.isPending}> <Button onClick={submit} disabled={mut.isPending}>
{mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Сохранить {t('common.save')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -740,8 +738,8 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Запуск команд недоступен</DialogTitle> <DialogTitle>{t('instances.exec_unavailable_title')}</DialogTitle>
<DialogDescription>Поддержка через qemu-guest-agent — только для KVM (qemu) инстансов.</DialogDescription> <DialogDescription>{t('instances.exec_unavailable_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button onClick={onClose}>OK</Button> <Button onClick={onClose}>OK</Button>
@@ -755,13 +753,13 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><TerminalIcon className="h-4 w-4" /> Запуск команды (qemu-agent)</DialogTitle> <DialogTitle className="flex items-center gap-2"><TerminalIcon className="h-4 w-4" /> {t('instances.exec_title')}</DialogTitle>
<DialogDescription>Команда выполняется внутри VM через guest-agent.</DialogDescription> <DialogDescription>{t('instances.exec_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[1fr_120px]"> <div className="grid gap-3 sm:grid-cols-[1fr_120px]">
<div> <div>
<Label>Команда</Label> <Label>{t('instances.exec_command')}</Label>
<Input <Input
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
@@ -770,7 +768,7 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
/> />
</div> </div>
<div> <div>
<Label>Таймаут (с)</Label> <Label>{t('instances.exec_timeout')}</Label>
<Input <Input
type="number" type="number"
min={1} min={1}
@@ -802,10 +800,10 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Закрыть</Button> <Button variant="outline" onClick={onClose}>{t('common.close')}</Button>
<Button onClick={submit} disabled={!command.trim() || exec.isPending}> <Button onClick={submit} disabled={!command.trim() || exec.isPending}>
{exec.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {exec.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Выполнить {t('instances.exec_run')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -816,6 +814,7 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
// ==================== ISO mount/unmount ==================== // ==================== ISO mount/unmount ====================
function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) { function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [device, setDevice] = useState('ide2'); const [device, setDevice] = useState('ide2');
const [volid, setVolid] = useState<string>(''); const [volid, setVolid] = useState<string>('');
const [bootFromDisk, setBootFromDisk] = useState(true); const [bootFromDisk, setBootFromDisk] = useState(true);
@@ -826,14 +825,12 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
const attach = useAttachIso(serverId, vmid, node); const attach = useAttachIso(serverId, vmid, node);
const detach = useDetachIso(serverId, vmid, node); const detach = useDetachIso(serverId, vmid, node);
// volid текущего подключённого образа (без ',media=cdrom,size=...'); null — привод пуст.
const currentVolid = useMemo(() => { const currentVolid = useMemo(() => {
const v = config.data?.[device]; const v = config.data?.[device];
if (typeof v !== 'string' || v.startsWith('none')) return null; if (typeof v !== 'string' || v.startsWith('none')) return null;
return v.split(',')[0] || null; return v.split(',')[0] || null;
}, [config.data, device]); }, [config.data, device]);
// Отразить текущий образ в выпадающем списке при открытии/смене устройства.
useEffect(() => { useEffect(() => {
if (open) setVolid(currentVolid || ''); if (open) setVolid(currentVolid || '');
}, [open, device, currentVolid]); }, [open, device, currentVolid]);
@@ -843,8 +840,8 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>ISO недоступно для LXC</DialogTitle> <DialogTitle>{t('instances.iso_lxc_title')}</DialogTitle>
<DialogDescription>Подключение ISO-образов поддерживается только для KVM (qemu) виртуальных машин.</DialogDescription> <DialogDescription>{t('instances.iso_lxc_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button onClick={onClose}>OK</Button> <Button onClick={onClose}>OK</Button>
@@ -858,12 +855,12 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><Disc className="h-4 w-4" /> ISO образ</DialogTitle> <DialogTitle className="flex items-center gap-2"><Disc className="h-4 w-4" /> {t('instances.iso_title')}</DialogTitle>
<DialogDescription>Подключите ISO к виртуальному CD-ROM устройству.</DialogDescription> <DialogDescription>{t('instances.iso_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label>Устройство</Label> <Label>{t('instances.iso_device')}</Label>
<Select value={device} onValueChange={(v) => v && setDevice(v)}> <Select value={device} onValueChange={(v) => v && setDevice(v)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -875,21 +872,21 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
</Select> </Select>
</div> </div>
<div> <div>
<Label>Образ</Label> <Label>{t('instances.iso_image')}</Label>
<Select value={volid} onValueChange={(v) => v && setVolid(v)}> <Select value={volid} onValueChange={(v) => v && setVolid(v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={isos.isLoading ? 'Загрузка...' : 'Выберите ISO'} /> <SelectValue placeholder={isos.isLoading ? t('common.loading') : t('instances.iso_select')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(isos.data?.isos || []).map((iso) => ( {(isos.data?.isos || []).map((iso) => (
<SelectItem key={iso.volid} value={iso.volid}> <SelectItem key={iso.volid} value={iso.volid}>
{iso.name || iso.volid}{iso.volid === currentVolid ? ' · текущий' : ''} {iso.name || iso.volid}{iso.volid === currentVolid ? ` · ${t('instances.iso_current')}` : ''}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{isos.data?.isos?.length === 0 && ( {isos.data?.isos?.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">На ноде нет загруженных ISO образов.</p> <p className="mt-1 text-xs text-muted-foreground">{t('instances.iso_no_isos')}</p>
)} )}
</div> </div>
{currentVolid && ( {currentVolid && (
@@ -900,10 +897,9 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
onChange={(e) => setBootFromDisk(e.target.checked)} onChange={(e) => setBootFromDisk(e.target.checked)}
/> />
<span> <span>
Загружаться с диска после извлечения {t('instances.iso_boot_disk')}
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">
Ставит диск первым в порядке загрузки и перезапускает ВМ, если она запущена, — {t('instances.iso_boot_disk_hint')}
чтобы сразу стартовать с установленной ОС, а не с ISO.
</span> </span>
</span> </span>
</label> </label>
@@ -916,10 +912,9 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
onChange={(e) => setBootFromIso(e.target.checked)} onChange={(e) => setBootFromIso(e.target.checked)}
/> />
<span> <span>
Загрузиться с этого ISO {t('instances.iso_boot_iso')}
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">
Ставит CD-ROM первым в порядке загрузки и перезапускает ВМ, если она запущена, — {t('instances.iso_boot_iso_hint')}
для установки/восстановления с образа. Без галочки ISO просто вставится в привод.
</span> </span>
</span> </span>
</label> </label>
@@ -931,9 +926,9 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
onClick={() => detach.mutate({ device, boot_from_disk: bootFromDisk, reboot_after: bootFromDisk }, { onClick={() => detach.mutate({ device, boot_from_disk: bootFromDisk, reboot_after: bootFromDisk }, {
onSuccess: (r) => { onSuccess: (r) => {
toast.success( toast.success(
r.rebooted ? 'ISO извлечён, ВМ перезапускается с диска' r.rebooted ? t('instances.iso_ejected_rebooting')
: r.boot_from_disk ? 'ISO извлечён, загрузка с диска (применится при старте)' : r.boot_from_disk ? t('instances.iso_ejected_disk')
: 'ISO отключён', : t('instances.iso_ejected'),
); );
onClose(); onClose();
}, },
@@ -942,15 +937,15 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
disabled={detach.isPending || !currentVolid} disabled={detach.isPending || !currentVolid}
> >
{detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Извлечь {t('instances.iso_eject')}
</Button> </Button>
<Button <Button
onClick={() => attach.mutate({ volid, device, boot_from_iso: bootFromIso, reboot_after: bootFromIso }, { onClick={() => attach.mutate({ volid, device, boot_from_iso: bootFromIso, reboot_after: bootFromIso }, {
onSuccess: (r) => { onSuccess: (r) => {
toast.success( toast.success(
r.rebooted ? 'ISO подключён, ВМ перезапускается с образа' r.rebooted ? t('instances.iso_attached_rebooting')
: r.boot_from_iso ? 'ISO подключён, загрузка с образа (применится при старте)' : r.boot_from_iso ? t('instances.iso_attached_booting')
: 'ISO подключён', : t('instances.iso_attached'),
); );
onClose(); onClose();
}, },
@@ -959,7 +954,7 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
disabled={!volid || attach.isPending} disabled={!volid || attach.isPending}
> >
{attach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {attach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Подключить {t('instances.iso_attach')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -976,8 +971,6 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
const [mode, setMode] = useState('snapshot'); const [mode, setMode] = useState('snapshot');
const [compress, setCompress] = useState('zstd'); const [compress, setCompress] = useState('zstd');
// Only storages that accept backups and are visible from this VM's node
// (`nodes` empty/absent => storage is available on all nodes).
const storages = useMemo(() => { const storages = useMemo(() => {
const list = (storagesData?.storages || []) as { storage: string; type: string; content?: string; nodes?: string }[]; const list = (storagesData?.storages || []) as { storage: string; type: string; content?: string; nodes?: string }[];
return list return list
@@ -997,7 +990,7 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
{ server_id: serverId, node, vmid, storage, mode, compress }, { server_id: serverId, node, vmid, storage, mode, compress },
{ {
onSuccess: () => { onSuccess: () => {
toast.success(t('instances.backup_started', 'Бэкап запущен в фоне')); toast.success(t('instances.backup_started'));
onClose(); onClose();
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
@@ -1009,17 +1002,17 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('instances.backup_now', 'Создать резервную копию')}</DialogTitle> <DialogTitle>{t('instances.backup_now')}</DialogTitle>
<DialogDescription> <DialogDescription>
{name || `${type === 'qemu' ? 'VM' : 'CT'} ${vmid}`} · {node} {name || `${type === 'qemu' ? 'VM' : 'CT'} ${vmid}`} · {node}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('backups.storage', 'Хранилище')}</Label> <Label>{t('backups.storage')}</Label>
<Select value={storage} onValueChange={setStorage}> <Select value={storage} onValueChange={setStorage}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t('backups.select_storage', 'Выберите хранилище')} /> <SelectValue placeholder={t('backups.select_storage')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{storages.map((s) => ( {storages.map((s) => (
@@ -1031,13 +1024,13 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
</Select> </Select>
{storages.length === 0 && ( {storages.length === 0 && (
<p className="text-xs text-warning"> <p className="text-xs text-warning">
{t('instances.backup_no_storages', 'Нет хранилищ с поддержкой бэкапов на этой ноде')} {t('instances.backup_no_storages')}
</p> </p>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('backups.mode', 'Режим')}</Label> <Label>{t('backups.mode')}</Label>
<Select value={mode} onValueChange={setMode}> <Select value={mode} onValueChange={setMode}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -1048,24 +1041,24 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('backups.compression', 'Сжатие')}</Label> <Label>{t('backups.compression')}</Label>
<Select value={compress} onValueChange={setCompress}> <Select value={compress} onValueChange={setCompress}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="zstd">ZSTD</SelectItem> <SelectItem value="zstd">ZSTD</SelectItem>
<SelectItem value="gzip">GZIP</SelectItem> <SelectItem value="gzip">GZIP</SelectItem>
<SelectItem value="lzo">LZO</SelectItem> <SelectItem value="lzo">LZO</SelectItem>
<SelectItem value="0">{t('backups.no_compression', 'Без сжатия')}</SelectItem> <SelectItem value="0">{t('backups.no_compression')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button> <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!storage || createBackup.isPending}> <Button onClick={submit} disabled={!storage || createBackup.isPending}>
{createBackup.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {createBackup.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.backup_create', 'Создать бэкап')} {t('instances.backup_create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1077,9 +1070,9 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
type ActionConfig = { type ActionConfig = {
icon: React.ReactNode; icon: React.ReactNode;
title: string; titleKey: string;
description: string; descKey: string;
btnLabel: string; btnKey: string;
btnVariant: 'default' | 'outline' | 'destructive'; btnVariant: 'default' | 'outline' | 'destructive';
btnClass: string; btnClass: string;
}; };
@@ -1087,33 +1080,33 @@ type ActionConfig = {
const POWER_ACTION_CONFIG: Record<PowerAction, ActionConfig> = { const POWER_ACTION_CONFIG: Record<PowerAction, ActionConfig> = {
start: { start: {
icon: <Play className="h-4 w-4 text-success" />, icon: <Play className="h-4 w-4 text-success" />,
title: 'Запустить', titleKey: 'instances.power_start',
description: 'Запустить инстанс', descKey: 'instances.power_start_desc',
btnLabel: 'Запустить', btnKey: 'instances.power_start',
btnVariant: 'default', btnVariant: 'default',
btnClass: 'bg-success text-success-foreground hover:bg-success/90 border-0', btnClass: 'bg-success text-success-foreground hover:bg-success/90 border-0',
}, },
restart: { restart: {
icon: <RotateCcw className="h-4 w-4 text-warning" />, icon: <RotateCcw className="h-4 w-4 text-warning" />,
title: 'Перезапустить', titleKey: 'instances.power_restart',
description: 'Перезапустить инстанс', descKey: 'instances.power_restart_desc',
btnLabel: 'Перезапустить', btnKey: 'instances.power_restart',
btnVariant: 'outline', btnVariant: 'outline',
btnClass: 'border-warning text-warning hover:bg-warning/10', btnClass: 'border-warning text-warning hover:bg-warning/10',
}, },
shutdown: { shutdown: {
icon: <Power className="h-4 w-4 text-orange-500" />, icon: <Power className="h-4 w-4 text-orange-500" />,
title: 'Выключить', titleKey: 'instances.power_shutdown',
description: 'Мягко выключить инстанс (ACPI shutdown)', descKey: 'instances.power_shutdown_desc',
btnLabel: 'Выключить', btnKey: 'instances.power_shutdown',
btnVariant: 'outline', btnVariant: 'outline',
btnClass: 'border-orange-500 text-orange-500 hover:bg-orange-500/10', btnClass: 'border-orange-500 text-orange-500 hover:bg-orange-500/10',
}, },
stop: { stop: {
icon: <Square className="h-4 w-4" />, icon: <Square className="h-4 w-4" />,
title: 'Остановить', titleKey: 'instances.power_stop',
description: 'Принудительно остановить инстанс. Несохранённые данные могут быть утеряны', descKey: 'instances.power_stop_desc',
btnLabel: 'Остановить', btnKey: 'instances.power_stop',
btnVariant: 'destructive', btnVariant: 'destructive',
btnClass: '', btnClass: '',
}, },
@@ -1134,9 +1127,10 @@ export function PowerConfirmDialog({
vmName?: string; vmName?: string;
isPending?: boolean; isPending?: boolean;
}) { }) {
const { t } = useTranslation();
if (!action) return null; if (!action) return null;
const cfg = POWER_ACTION_CONFIG[action]; const cfg = POWER_ACTION_CONFIG[action];
const displayName = vmName ? `«${vmName}»` : 'выбранный инстанс'; const displayName = vmName ? `«${vmName}»` : t('instances.power_unnamed');
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@@ -1144,21 +1138,21 @@ export function PowerConfirmDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{cfg.icon} {cfg.icon}
{cfg.title} {t(cfg.titleKey)}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{cfg.description} {displayName}? {t(cfg.descKey)} {displayName}?
{action === 'stop' && ( {action === 'stop' && (
<span className="mt-2 flex items-center gap-1.5 text-destructive"> <span className="mt-2 flex items-center gap-1.5 text-destructive">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" /> <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Это аналог «выдернуть кабель из розетки». {t('instances.power_stop_warn')}
</span> </span>
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}> <Button variant="outline" onClick={onClose} disabled={isPending}>
Отмена {t('common.cancel')}
</Button> </Button>
<Button <Button
variant={cfg.btnVariant} variant={cfg.btnVariant}
@@ -1167,7 +1161,7 @@ export function PowerConfirmDialog({
disabled={isPending} disabled={isPending}
> >
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{cfg.btnLabel} {t(cfg.btnKey)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -166,7 +166,7 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
if (!diskSize || !diskDevice) return; if (!diskSize || !diskDevice) return;
if (currentDiskGb != null && Number(diskSize) <= currentDiskGb) { if (currentDiskGb != null && Number(diskSize) <= currentDiskGb) {
toast.error( toast.error(
`Новый размер должен быть больше текущего (${currentDiskGb.toFixed(1)} ГБ). Уменьшение диска Proxmox не поддерживает.` t('instances.disk_resize_too_small', { size: currentDiskGb.toFixed(1) })
); );
return; return;
} }
@@ -178,7 +178,7 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
const grown = !!res?.filesystem_grown; const grown = !!res?.filesystem_grown;
setGrowResult({ grown, output: res?.grow_output ?? '' }); setGrowResult({ grown, output: res?.grow_output ?? '' });
if (grown) toast.success(res?.message ?? `Disk resized to ${diskSize}G`); if (grown) toast.success(res?.message ?? `Disk resized to ${diskSize}G`);
else toast.warning(res?.message ?? `Диск увеличен до ${diskSize}G, но ФС не расширена`); else toast.warning(res?.message ?? t('instances.disk_no_fs', { size: diskSize }));
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
} }
@@ -229,7 +229,7 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
/> />
{currentDiskGb != null && ( {currentDiskGb != null && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Текущий размер: {currentDiskGb.toFixed(1)} ГБ {t('instances.current_disk_size', { size: currentDiskGb.toFixed(1) })}
</p> </p>
)} )}
</div> </div>
@@ -250,8 +250,8 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className={`text-xs font-medium ${growResult.grown ? 'text-success' : 'text-warning'}`}> <p className={`text-xs font-medium ${growResult.grown ? 'text-success' : 'text-warning'}`}>
{growResult.grown {growResult.grown
? 'Файловая система расширена внутри ОС' ? t('instances.fs_grown')
: 'Файловая система не расширена — вывод growpart ниже'} : t('instances.fs_not_grown')}
</p> </p>
{growResult.output && ( {growResult.output && (
<pre className="max-h-48 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap"> <pre className="max-h-48 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap">

View File

@@ -263,7 +263,25 @@
"memory": "Memory", "memory": "Memory",
"storage": "Storage", "storage": "Storage",
"instances": "Instances", "instances": "Instances",
"recent_activity": "Recent Activity" "recent_activity": "Recent Activity",
"cluster": "Cluster",
"ipv4_available": "IPv4 available",
"nodes_count": "Nodes",
"ram_oversell": "RAM oversell =",
"tasks_today": "Tasks today",
"tasks_done": "Done:",
"tasks_running": "Running:",
"tasks_queued": "Queued:",
"tasks_errors": "Errors:",
"tasks_total": "Total",
"server": "Server",
"servers_count": "Servers",
"platform": "Platform",
"cluster_nodes": "Cluster nodes",
"top10_now": "Top 10 now",
"top10_day": "Top 10 today",
"node_name": "Node name",
"disk_top": "Top disk usage"
}, },
"common": { "common": {
"access_denied": "Access denied", "access_denied": "Access denied",
@@ -290,6 +308,7 @@
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"queued": "Queued...", "queued": "Queued...",
"no_data": "No data",
"confirm_delete": "Are you sure you want to delete this?", "confirm_delete": "Are you sure you want to delete this?",
"no_data": "No data available", "no_data": "No data available",
"all": "All", "all": "All",
@@ -559,7 +578,80 @@
"destroy_button": "Permanently Destroy {{type}}", "destroy_button": "Permanently Destroy {{type}}",
"destroyed": "{{name}} has been destroyed", "destroyed": "{{name}} has been destroyed",
"status_reinstalling": "Reinstalling", "status_reinstalling": "Reinstalling",
"status_changing_password": "Changing password" "status_changing_password": "Changing password",
"clone_started": "Cloning started in background",
"clone_title": "Clone",
"clone_desc": "Create a copy of {{name}}",
"clone_new_name": "New instance name",
"clone_target_node": "Target node",
"clone_same_node": "Same node ({{node}})",
"clone_storage": "Storage",
"clone_same_storage": "Same as source",
"clone_owner": "Owner",
"clone_owner_me": "Me ({{username}})",
"clone_desc_label": "Description",
"clone_desc_placeholder": "Note for the new instance",
"clone_full": "Full clone",
"clone_btn": "Clone",
"reinstall_started": "Reinstall started in background",
"reinstall_title": "Reinstall",
"reinstall_desc": "This will recreate instance {{name}} from its original template. All data will be lost.",
"reinstall_lxc_warn": "LXC support is experimental.",
"reinstall_confirm_label": "Type",
"reinstall_btn": "Reinstall",
"password_started": "Password change started in background",
"change_password_title": "Change password",
"change_password_qemu_hint": "Requires qemu-guest-agent installed inside the VM.",
"change_password_lxc_hint": "Container must be running.",
"change_password_user": "User",
"change_password_new": "New password",
"change_password_btn": "Change",
"notes_title": "Notes",
"notes_desc": "Free text / markdown, visible in Proxmox UI.",
"notes_placeholder": "Description / notes about the instance...",
"exec_unavailable_title": "Command execution unavailable",
"exec_unavailable_desc": "qemu-guest-agent support — KVM (qemu) instances only.",
"exec_title": "Run command (qemu-agent)",
"exec_desc": "Command runs inside the VM via guest-agent.",
"exec_command": "Command",
"exec_timeout": "Timeout (s)",
"exec_run": "Execute",
"iso_lxc_title": "ISO not available for LXC",
"iso_lxc_desc": "ISO image mounting is supported only for KVM (qemu) virtual machines.",
"iso_title": "ISO image",
"iso_desc": "Attach an ISO to the virtual CD-ROM device.",
"iso_device": "Device",
"iso_image": "Image",
"iso_select": "Select ISO",
"iso_current": "current",
"iso_no_isos": "No ISO images found on this node.",
"iso_boot_disk": "Boot from disk after ejection",
"iso_boot_disk_hint": "Sets the disk first in boot order and reboots the VM if running — to immediately boot the installed OS instead of the ISO.",
"iso_boot_iso": "Boot from this ISO",
"iso_boot_iso_hint": "Sets the CD-ROM first in boot order and reboots the VM if running — for installation/recovery from the image. Without this option the ISO is simply inserted.",
"iso_ejected_rebooting": "ISO ejected, VM rebooting from disk",
"iso_ejected_disk": "ISO ejected, boot from disk (takes effect on next start)",
"iso_ejected": "ISO detached",
"iso_attached_rebooting": "ISO attached, VM rebooting from image",
"iso_attached_booting": "ISO attached, boot from image (takes effect on next start)",
"iso_attached": "ISO attached",
"iso_eject": "Eject",
"iso_attach": "Attach",
"power_start": "Start",
"power_start_desc": "Start instance",
"power_restart": "Restart",
"power_restart_desc": "Restart instance",
"power_shutdown": "Shutdown",
"power_shutdown_desc": "Gracefully shut down instance (ACPI shutdown)",
"power_stop": "Stop",
"power_stop_desc": "Force stop instance. Unsaved data may be lost",
"power_unnamed": "selected instance",
"power_stop_warn": "This is the equivalent of pulling the power cable.",
"disk_resize_too_small": "New size must be larger than current ({{size}} GB). Proxmox does not support shrinking disks.",
"disk_no_fs": "Disk resized to {{size}}G, but filesystem was not expanded",
"current_disk_size": "Current size: {{size}} GB",
"fs_grown": "Filesystem expanded inside the OS",
"fs_not_grown": "Filesystem not expanded — growpart output below"
}, },
"wizard": { "wizard": {
"title": "Create Instance", "title": "Create Instance",

View File

@@ -263,7 +263,25 @@
"memory": "Память", "memory": "Память",
"storage": "Хранилище", "storage": "Хранилище",
"instances": "Инстансы", "instances": "Инстансы",
"recent_activity": "Последняя активность" "recent_activity": "Последняя активность",
"cluster": "Кластер",
"ipv4_available": "IPv4 доступно",
"nodes_count": "Узлов",
"ram_oversell": "Оверселлинг RAM =",
"tasks_today": "Задачи за сутки",
"tasks_done": "Выполнено:",
"tasks_running": "Выполняются:",
"tasks_queued": "В очереди:",
"tasks_errors": "Ошибки:",
"tasks_total": "Всего",
"server": "Сервер",
"servers_count": "Серверов",
"platform": "Платформа",
"cluster_nodes": "Узлы кластера",
"top10_now": "Топ-10 сейчас",
"top10_day": "Топ-10 за сутки",
"node_name": "Название узлов",
"disk_top": "ТОП статистика по диску"
}, },
"common": { "common": {
"access_denied": "Доступ запрещён", "access_denied": "Доступ запрещён",
@@ -290,6 +308,7 @@
"online": "Онлайн", "online": "Онлайн",
"offline": "Офлайн", "offline": "Офлайн",
"queued": "В очереди...", "queued": "В очереди...",
"no_data": "Нет данных",
"confirm_delete": "Вы уверены, что хотите удалить?", "confirm_delete": "Вы уверены, что хотите удалить?",
"no_data": "Нет данных", "no_data": "Нет данных",
"all": "Все", "all": "Все",
@@ -559,7 +578,80 @@
"destroy_button": "Безвозвратно уничтожить {{type}}", "destroy_button": "Безвозвратно уничтожить {{type}}",
"destroyed": "{{name}} уничтожен", "destroyed": "{{name}} уничтожен",
"status_reinstalling": "Переустановка", "status_reinstalling": "Переустановка",
"status_changing_password": "Смена пароля" "status_changing_password": "Смена пароля",
"clone_started": "Клонирование запущено в фоне",
"clone_title": "Клонировать",
"clone_desc": "Создать копию {{name}}",
"clone_new_name": "Имя новой инстанции",
"clone_target_node": "Целевой узел",
"clone_same_node": "Тот же узел ({{node}})",
"clone_storage": "Хранилище",
"clone_same_storage": "Как у источника",
"clone_owner": "Владелец",
"clone_owner_me": "Я ({{username}})",
"clone_desc_label": "Описание",
"clone_desc_placeholder": "Заметка для новой инстанции",
"clone_full": "Полный клон (full clone)",
"clone_btn": "Клонировать",
"reinstall_started": "Переустановка запущена в фоне",
"reinstall_title": "Переустановить",
"reinstall_desc": "Это пересоздаст инстанс {{name}} из исходного шаблона. Все данные будут утеряны.",
"reinstall_lxc_warn": "Поддержка LXC — экспериментальная.",
"reinstall_confirm_label": "Введите",
"reinstall_btn": "Переустановить",
"password_started": "Смена пароля запущена в фоне",
"change_password_title": "Изменить пароль",
"change_password_qemu_hint": "Требуется установленный qemu-guest-agent внутри VM.",
"change_password_lxc_hint": "Контейнер должен быть запущен.",
"change_password_user": "Пользователь",
"change_password_new": "Новый пароль",
"change_password_btn": "Изменить",
"notes_title": "Примечание",
"notes_desc": "Свободный текст / markdown, видимый в Proxmox UI.",
"notes_placeholder": "Описание / заметки об инстансе...",
"exec_unavailable_title": "Запуск команд недоступен",
"exec_unavailable_desc": "Поддержка через qemu-guest-agent — только для KVM (qemu) инстансов.",
"exec_title": "Запуск команды (qemu-agent)",
"exec_desc": "Команда выполняется внутри VM через guest-agent.",
"exec_command": "Команда",
"exec_timeout": "Таймаут (с)",
"exec_run": "Выполнить",
"iso_lxc_title": "ISO недоступно для LXC",
"iso_lxc_desc": "Подключение ISO-образов поддерживается только для KVM (qemu) виртуальных машин.",
"iso_title": "ISO образ",
"iso_desc": "Подключите ISO к виртуальному CD-ROM устройству.",
"iso_device": "Устройство",
"iso_image": "Образ",
"iso_select": "Выберите ISO",
"iso_current": "текущий",
"iso_no_isos": "На ноде нет загруженных ISO образов.",
"iso_boot_disk": "Загружаться с диска после извлечения",
"iso_boot_disk_hint": "Ставит диск первым в порядке загрузки и перезапускает ВМ, если она запущена, — чтобы сразу стартовать с установленной ОС, а не с ISO.",
"iso_boot_iso": "Загрузиться с этого ISO",
"iso_boot_iso_hint": "Ставит CD-ROM первым в порядке загрузки и перезапускает ВМ, если она запущена, — для установки/восстановления с образа. Без галочки ISO просто вставится в привод.",
"iso_ejected_rebooting": "ISO извлечён, ВМ перезапускается с диска",
"iso_ejected_disk": "ISO извлечён, загрузка с диска (применится при старте)",
"iso_ejected": "ISO отключён",
"iso_attached_rebooting": "ISO подключён, ВМ перезапускается с образа",
"iso_attached_booting": "ISO подключён, загрузка с образа (применится при старте)",
"iso_attached": "ISO подключён",
"iso_eject": "Извлечь",
"iso_attach": "Подключить",
"power_start": "Запустить",
"power_start_desc": "Запустить инстанс",
"power_restart": "Перезапустить",
"power_restart_desc": "Перезапустить инстанс",
"power_shutdown": "Выключить",
"power_shutdown_desc": "Мягко выключить инстанс (ACPI shutdown)",
"power_stop": "Остановить",
"power_stop_desc": "Принудительно остановить инстанс. Несохранённые данные могут быть утеряны",
"power_unnamed": "выбранный инстанс",
"power_stop_warn": "Это аналог «выдернуть кабель из розетки».",
"disk_resize_too_small": "Новый размер должен быть больше текущего ({{size}} ГБ). Уменьшение диска Proxmox не поддерживает.",
"disk_no_fs": "Диск увеличен до {{size}}G, но ФС не расширена",
"current_disk_size": "Текущий размер: {{size}} ГБ",
"fs_grown": "Файловая система расширена внутри ОС",
"fs_not_grown": "Файловая система не расширена — вывод growpart ниже"
}, },
"wizard": { "wizard": {
"title": "Создание инстанса", "title": "Создание инстанса",