i18n(images): translate Images page, dialogs and mirrors manager
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s

This commit is contained in:
2026-08-13 16:59:01 +05:00
parent 477faef44a
commit 43db0f79ca
6 changed files with 296 additions and 99 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Download } from 'lucide-react';
import {
Dialog,
@@ -46,12 +47,13 @@ function fmtSize(bytes?: number): string {
}
export default function DownloadImageDialog({ open, onClose, serverId, node, image }: Props) {
const { t } = useTranslation();
const isQcow2 = image?.kind === 'qcow2';
const [storage, setStorage] = useState(''); // download storage (import / vztmpl)
const [toTemplate, setToTemplate] = useState(true);
const [diskStorage, setDiskStorage] = useState(''); // VM disk storage (images)
const [vmid, setVmid] = useState<string>(''); // желаемый VMID шаблона (пусто = авто)
const [vmid, setVmid] = useState<string>('');
const [cores, setCores] = useState(2);
const [memory, setMemory] = useState(2048);
const [bridge, setBridge] = useState('vmbr0');
@@ -117,7 +119,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
id: data.task_id,
name: data.name,
status: 'pending',
step: 'В очереди...',
step: t('common.queued'),
progress: 0,
vmid: null,
node,
@@ -125,7 +127,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
kind: makeTemplate ? 'image_template' : 'image_download',
server_id: serverId,
});
toast.success(makeTemplate ? 'Создание шаблона запущено в фоне' : 'Загрузка образа запущена в фоне');
toast.success(makeTemplate ? t('images.template_started') : t('images.download_started'));
onClose();
},
onError: (e: Error) => toast.error(e.message),
@@ -137,26 +139,26 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{makeTemplate ? 'Создать шаблон из образа' : 'Скачать образ'}</DialogTitle>
<DialogTitle>{makeTemplate ? t('images.create_template_title') : t('images.download_image_title')}</DialogTitle>
<DialogDescription>{image.name}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Нода: <span className="font-medium text-foreground">{node}</span>
{image.arch ? <> · Архитектура: <span className="font-medium text-foreground">{image.arch}</span></> : null}
{t('images.node_label')}: <span className="font-medium text-foreground">{node}</span>
{image.arch ? <> · {t('images.arch_label')}: <span className="font-medium text-foreground">{image.arch}</span></> : null}
</div>
<div>
<Label>{isQcow2 ? 'Хранилище загрузки (import)' : 'Хранилище (vztmpl)'}</Label>
<Label>{isQcow2 ? t('images.storage_import') : t('images.storage_vztmpl')}</Label>
<Select value={storage || '__none__'} onValueChange={(v) => { if (v !== null) setStorage(v === '__none__' ? '' : v); }}>
<SelectTrigger>
<SelectValue placeholder={storagesLoading ? 'Загрузка...' : 'Выберите хранилище'} />
<SelectValue placeholder={storagesLoading ? t('common.loading') : t('images.select_storage')} />
</SelectTrigger>
<SelectContent>
{storages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} свободно` : ''}
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} ${t('images.free')}` : ''}
</SelectItem>
))}
</SelectContent>
@@ -164,8 +166,8 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
{!storagesLoading && storages.length === 0 && (
<p className="mt-2 text-sm text-warning">
{content === 'import'
? 'Нет хранилища с поддержкой импорта (content=import). Нужен dir/NFS storage с типом контента «Import» (PVE 8.2+).'
: 'Нет хранилища с поддержкой шаблонов контейнеров (vztmpl).'}
? t('images.no_import_storage')
: t('images.no_vztmpl_storage')}
</p>
)}
</div>
@@ -173,56 +175,56 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
{isQcow2 && (
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={toTemplate} onChange={(e) => setToTemplate(e.target.checked)} />
<span>Сделать VM-шаблоном после загрузки (импорт диска + cloud-init)</span>
<span>{t('images.make_template_label')}</span>
</label>
)}
{makeTemplate && (
<div className="space-y-4 rounded-md border p-3">
<div>
<Label>Хранилище диска ВМ (images)</Label>
<Label>{t('images.disk_storage')}</Label>
<Select value={diskStorage || '__none__'} onValueChange={(v) => { if (v !== null) setDiskStorage(v === '__none__' ? '' : v); }}>
<SelectTrigger><SelectValue placeholder="Выберите хранилище" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={t('images.select_storage')} /></SelectTrigger>
<SelectContent>
{diskStorages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} свободно` : ''}
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} ${t('images.free')}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{diskStorages.length === 0 && (
<p className="mt-2 text-sm text-warning">
Нет хранилища для дисков ВМ (content=images).
{t('images.no_disk_storage')}
</p>
)}
</div>
<div>
<Label htmlFor="img-vmid">VMID шаблона (пусто = авто)</Label>
<Input id="img-vmid" type="number" min={100} placeholder="напр. 9001"
<Label htmlFor="img-vmid">{t('images.vmid_label')}</Label>
<Input id="img-vmid" type="number" min={100} placeholder={t('images.vmid_placeholder')}
value={vmid} onChange={(e) => setVmid(e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor="img-cores">Ядра</Label>
<Label htmlFor="img-cores">{t('images.cores')}</Label>
<Input id="img-cores" type="number" min={1} value={cores} onChange={(e) => setCores(Number(e.target.value) || 1)} />
</div>
<div>
<Label htmlFor="img-mem">Память (MB)</Label>
<Label htmlFor="img-mem">{t('images.memory_mb')}</Label>
<Input id="img-mem" type="number" min={256} step={256} value={memory} onChange={(e) => setMemory(Number(e.target.value) || 512)} />
</div>
<div>
<Label htmlFor="img-bridge">Мост</Label>
<Label htmlFor="img-bridge">{t('images.bridge')}</Label>
<Input id="img-bridge" value={bridge} onChange={(e) => setBridge(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="img-ciuser">cloud-init пользователь</Label>
<Input id="img-ciuser" placeholder="напр. ubuntu" value={ciuser} onChange={(e) => setCiuser(e.target.value)} />
<Label htmlFor="img-ciuser">{t('images.ci_user')}</Label>
<Input id="img-ciuser" placeholder={t('images.ci_user_placeholder')} value={ciuser} onChange={(e) => setCiuser(e.target.value)} />
</div>
<div>
<Label htmlFor="img-cipass">cloud-init пароль</Label>
<Label htmlFor="img-cipass">{t('images.ci_password')}</Label>
<Input id="img-cipass" type="password" value={cipassword} onChange={(e) => setCipassword(e.target.value)} />
</div>
</div>
@@ -231,10 +233,10 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={submitDisabled}>
{download.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{makeTemplate ? 'Создать шаблон' : 'Скачать'}
{makeTemplate ? t('images.create_template_btn') : t('images.download_btn')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Download, Upload, Wand2, FileUp, X } from 'lucide-react';
import {
Dialog,
@@ -65,6 +66,7 @@ function filenameFromUrl(url: string): string {
type Mode = 'url' | 'local';
export default function DownloadIsoDialog({ open, onClose, serverId, node, source }: Props) {
const { t } = useTranslation();
const fromMirror = !!source?.source_id;
const [mode, setMode] = useState<Mode>('url');
@@ -116,7 +118,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
resolveFilename.mutate(trimmed, {
onSuccess: (data) => {
if (data.filename) setFilename(data.filename);
else toast.error('Не удалось определить имя файла');
else toast.error(t('images.detect_filename_failed'));
},
onError: (e: Error) => toast.error(e.message),
});
@@ -132,7 +134,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
id: data.task_id,
name: data.name,
status: 'pending',
step: 'В очереди...',
step: t('common.queued'),
progress: 0,
vmid: null,
node,
@@ -159,7 +161,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
checksum_algorithm: checksum.trim() ? checksumAlgo : undefined,
},
{
onSuccess: (data) => onSuccess(data, 'Загрузка ISO на ноду запущена в фоне'),
onSuccess: (data) => onSuccess(data, t('images.iso_upload_started')),
onError: (e: Error) => toast.error(e.message),
},
);
@@ -178,7 +180,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
checksum_algorithm: checksum.trim() ? checksumAlgo : undefined,
},
{
onSuccess: (data) => onSuccess(data, 'Загрузка ISO запущена в фоне'),
onSuccess: (data) => onSuccess(data, t('images.iso_download_started')),
onError: (e: Error) => toast.error(e.message),
},
);
@@ -188,35 +190,35 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Загрузить ISO</DialogTitle>
<DialogTitle>{t('images.download_iso_title')}</DialogTitle>
<DialogDescription>
{fromMirror
? source?.name
: mode === 'local'
? 'Файл будет передан на ноду через панель'
: 'Proxmox скачает образ по ссылке прямо на ноду'}
? t('images.iso_local_desc')
: t('images.iso_url_desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Нода: <span className="font-medium text-foreground">{node}</span>
{t('images.node_label')}: <span className="font-medium text-foreground">{node}</span>
</div>
{!fromMirror && (
<Tabs value={mode} onValueChange={(v) => v && setMode(v as Mode)}>
<TabsList className="w-full">
<TabsTrigger value="url" className="flex-1">
<Download className="mr-1.5 h-4 w-4" />По ссылке (URL)
<Download className="mr-1.5 h-4 w-4" />{t('images.tab_url')}
</TabsTrigger>
<TabsTrigger value="local" className="flex-1">
<FileUp className="mr-1.5 h-4 w-4" />Локальный файл
<FileUp className="mr-1.5 h-4 w-4" />{t('images.tab_local')}
</TabsTrigger>
</TabsList>
<TabsContent value="url" className="mt-4 space-y-4">
<div>
<Label htmlFor="iso-url">URL образа</Label>
<Label htmlFor="iso-url">{t('images.url_label')}</Label>
<div className="flex gap-2">
<Input
id="iso-url"
@@ -230,7 +232,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
variant="outline"
onClick={handleDetectFilename}
disabled={!url.trim() || resolveFilename.isPending}
title="Определить имя файла по ссылке"
title={t('images.detect_filename_title')}
>
{resolveFilename.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
@@ -239,7 +241,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
</div>
</div>
<div>
<Label htmlFor="iso-filename">Имя файла в хранилище</Label>
<Label htmlFor="iso-filename">{t('images.filename_label')}</Label>
<Input
id="iso-filename"
placeholder={filenameFromUrl(url) || 'linux-24.04-amd64.iso'}
@@ -247,9 +249,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
onChange={(e) => setFilename(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">
Proxmox принимает только .iso и .img — иначе расширение будет добавлено автоматически.
Кнопка со звёздочкой рядом с URL определяет настоящее имя файла (по Content-Disposition
или итоговой ссылке после редиректов), если оно не видно из самого URL.
{t('images.filename_hint')}
</p>
</div>
</TabsContent>
@@ -278,8 +278,8 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
)}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">Перетащите ISO-файл сюда</p>
<p className="text-xs text-muted-foreground">или нажмите, чтобы выбрать (.iso, .img)</p>
<p className="text-sm font-medium">{t('images.drop_iso')}</p>
<p className="text-xs text-muted-foreground">{t('images.drop_iso_hint')}</p>
</div>
) : (
<div className="flex items-center justify-between rounded-lg border p-3">
@@ -292,43 +292,43 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
variant="ghost"
size="icon"
onClick={() => setSelectedFile(null)}
title="Убрать файл"
title={t('images.remove_file')}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
<p className="text-xs text-muted-foreground">
Имя файла в хранилище назначается автоматически — из имени выбранного файла.
{t('images.local_filename_hint')}
</p>
</TabsContent>
</Tabs>
)}
<div>
<Label>Хранилище (iso)</Label>
<Label>{t('images.storage_iso')}</Label>
<Select value={storage || '__none__'} onValueChange={(v) => { if (v !== null) setStorage(v === '__none__' ? '' : v); }}>
<SelectTrigger>
<SelectValue placeholder={storagesLoading ? 'Загрузка...' : 'Выберите хранилище'} />
<SelectValue placeholder={storagesLoading ? t('common.loading') : t('images.select_storage')} />
</SelectTrigger>
<SelectContent>
{storages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} свободно` : ''}
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} ${t('images.free')}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{!storagesLoading && storages.length === 0 && (
<p className="mt-2 text-sm text-warning">
Нет хранилища с типом контента ISO. Включите ISO в настройках хранилища.
{t('images.no_iso_storage')}
</p>
)}
</div>
<div className="grid grid-cols-[1fr_140px] gap-3">
<div>
<Label htmlFor="iso-checksum">Контрольная сумма (необязательно)</Label>
<Label htmlFor="iso-checksum">{t('images.checksum_optional')}</Label>
<Input
id="iso-checksum"
placeholder="e3b0c44298fc1c14..."
@@ -337,7 +337,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
/>
</div>
<div>
<Label>Алгоритм</Label>
<Label>{t('images.algo')}</Label>
<Select value={checksumAlgo} onValueChange={(v) => { if (v) setChecksumAlgo(v); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -352,14 +352,14 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={submitDisabled}>
{isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: mode === 'local' && !fromMirror
? <Upload className="mr-2 h-4 w-4" />
: <Download className="mr-2 h-4 w-4" />}
{mode === 'local' && !fromMirror ? 'Загрузить' : 'Скачать'}
{mode === 'local' && !fromMirror ? t('images.upload_btn') : t('images.download_btn')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Download, HardDriveDownload, Package, ChevronDown, Disc } from 'lucide-react';
import { OsLogo } from '@/features/templates/OsLogo';
import { Card, CardContent } from '@/components/ui/card';
@@ -22,6 +23,7 @@ import MirrorsManager from './MirrorsManager';
import type { CatalogImage } from '@/types';
export default function ImagesPage() {
const { t } = useTranslation();
const [serverId, setServerId] = useState<number | null>(null);
const [node, setNode] = useState<string>('');
const [search, setSearch] = useState('');
@@ -110,33 +112,33 @@ export default function ImagesPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDriveDownload className="h-6 w-6" />
<h1 className="text-2xl font-bold">Образы</h1>
<h1 className="text-2xl font-bold">{t('images.title')}</h1>
</div>
</div>
{/* Контекст: сервер + нода */}
<div className="flex flex-wrap gap-3">
<Select value={serverId != null ? String(serverId) : ''} onValueChange={(v) => { if (v !== null) { setServerId(Number(v)); setNode(''); } }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Сервер" /></SelectTrigger>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('images.server')} /></SelectTrigger>
<SelectContent>
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder="Нода" /></SelectTrigger>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('images.node')} /></SelectTrigger>
<SelectContent>
{nodes.length === 0 && <SelectItem value="__empty__" disabled>Нет доступных нод</SelectItem>}
{nodes.length === 0 && <SelectItem value="__empty__" disabled>{t('images.no_nodes')}</SelectItem>}
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder="Поиск образа..." value={search} onChange={(e) => setSearch(e.target.value)} />
<Input className="pl-9" placeholder={t('images.search')} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Select value={archFilter || '__all__'} onValueChange={(v) => { if (v !== null) setArchFilter(v === '__all__' ? '' : v); }}>
<SelectTrigger className="w-[150px]"><SelectValue placeholder="Архитектура" /></SelectTrigger>
<SelectTrigger className="w-[150px]"><SelectValue placeholder={t('images.arch')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Все архитектуры</SelectItem>
<SelectItem value="__all__">{t('images.arch_all')}</SelectItem>
<SelectItem value="amd64">amd64</SelectItem>
<SelectItem value="arm64">arm64</SelectItem>
</SelectContent>
@@ -148,7 +150,7 @@ export default function ImagesPage() {
<button type="button" onClick={toggleCloud}
className="flex w-full items-center gap-2 text-lg font-semibold">
<ChevronDown className={`h-5 w-5 transition-transform ${cloudOpen ? '' : '-rotate-90'}`} />
<Package className="h-5 w-5" /> Cloud-образы ВМ
<Package className="h-5 w-5" /> {t('images.cloud_section')}
</button>
{cloudOpen && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -160,7 +162,7 @@ export default function ImagesPage() {
<div className="font-medium truncate">{img.name}</div>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant="secondary">{img.arch}</Badge>
{img.source === 'mirror' && <Badge variant="outline">зеркало</Badge>}
{img.source === 'mirror' && <Badge variant="outline">{t('images.mirror_badge')}</Badge>}
</div>
</div>
<Button size="sm" disabled={!canDownload}
@@ -171,7 +173,7 @@ export default function ImagesPage() {
</Card>
))}
{cloudImages.length === 0 && (
<div className="text-sm text-muted-foreground col-span-full">Нет образов по фильтру.</div>
<div className="text-sm text-muted-foreground col-span-full">{t('images.no_images_filter')}</div>
)}
</div>
)}
@@ -183,22 +185,22 @@ export default function ImagesPage() {
<button type="button" onClick={toggleIso}
className="flex items-center gap-2 text-lg font-semibold">
<ChevronDown className={`h-5 w-5 transition-transform ${isoOpen ? '' : '-rotate-90'}`} />
<Disc className="h-5 w-5" /> ISO-образы
<Disc className="h-5 w-5" /> {t('images.iso_section')}
</button>
<Button size="sm" variant="outline" disabled={!canDownload}
onClick={() => openIsoDownload(null)}>
<Download className="mr-2 h-4 w-4" /> Загрузить по URL
<Download className="mr-2 h-4 w-4" /> {t('images.download_by_url')}
</Button>
</div>
{isoOpen && (<>
{!canDownload && (
<div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы загрузить образ.</div>
<div className="text-sm text-muted-foreground">{t('images.select_server_node')}</div>
)}
{/* Реальные ISO на хранилище ноды (загруженные ранее, в т.ч. до подключения к панели) */}
{canDownload && nodeIsos.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">На хранилище ноды</div>
<div className="text-sm font-medium text-muted-foreground">{t('images.node_storage')}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{nodeIsos.map((iso) => (
<Card key={iso.volid}>
@@ -238,7 +240,7 @@ export default function ImagesPage() {
</div>
{isoImages.length === 0 && (
<div className="text-sm text-muted-foreground">
Сохранённых ISO-ссылок нет. Загрузите образ по URL или добавьте зеркало с типом «iso» ниже.
{t('images.no_iso_links')}
</div>
)}
</>)}
@@ -249,11 +251,11 @@ export default function ImagesPage() {
<button type="button" onClick={toggleLxc}
className="flex w-full items-center gap-2 text-lg font-semibold">
<ChevronDown className={`h-5 w-5 transition-transform ${lxcOpen ? '' : '-rotate-90'}`} />
<Package className="h-5 w-5" /> LXC-шаблоны (репозиторий Proxmox)
<Package className="h-5 w-5" /> {t('images.lxc_section')}
</button>
{lxcOpen && (<>
{!canDownload && (
<div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы увидеть список.</div>
<div className="text-sm text-muted-foreground">{t('images.select_server_node_list')}</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{lxcTemplates.map((t) => (

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -42,6 +43,7 @@ function MirrorFormDialog({
onClose: () => void;
editing: ImageMirrorItem | null;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<ImageMirrorInput>(EMPTY);
const create = useCreateMirror();
const update = useUpdateMirror();
@@ -66,11 +68,11 @@ function MirrorFormDialog({
const submit = () => {
if (!form.name.trim()) return;
if ((form.kind === 'qcow2' || form.kind === 'iso') && !form.url?.trim()) {
toast.error(`Для ${form.kind} нужен URL`);
toast.error(t('mirrors.url_required', { kind: form.kind }));
return;
}
if (!form.url?.trim() && !form.template?.trim()) {
toast.error('Нужен URL или имя шаблона');
toast.error(t('mirrors.url_or_template_required'));
return;
}
const payload: ImageMirrorInput = {
@@ -84,7 +86,7 @@ function MirrorFormDialog({
description: form.description?.trim() || undefined,
};
const opts = {
onSuccess: () => { toast.success('Сохранено'); onClose(); },
onSuccess: () => { toast.success(t('mirrors.saved')); onClose(); },
onError: (e: Error) => toast.error(e.message),
};
if (editing) update.mutate({ id: editing.mirror_id, data: payload }, opts);
@@ -95,27 +97,27 @@ function MirrorFormDialog({
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Редактировать зеркало' : 'Добавить зеркало'}</DialogTitle>
<DialogTitle>{editing ? t('mirrors.edit_title') : t('mirrors.add_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div>
<Label htmlFor="m-name">Название</Label>
<Label htmlFor="m-name">{t('mirrors.name')}</Label>
<Input id="m-name" value={form.name} onChange={(e) => set('name', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Тип</Label>
<Label>{t('mirrors.kind')}</Label>
<Select value={form.kind} onValueChange={(v) => { if (v !== null) set('kind', v); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="qcow2">qcow2 (ВМ)</SelectItem>
<SelectItem value="vztmpl">vztmpl (LXC)</SelectItem>
<SelectItem value="iso">iso (установочный)</SelectItem>
<SelectItem value="qcow2">{t('mirrors.kind_qcow2')}</SelectItem>
<SelectItem value="vztmpl">{t('mirrors.kind_vztmpl')}</SelectItem>
<SelectItem value="iso">{t('mirrors.kind_iso')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Архитектура</Label>
<Label>{t('mirrors.arch')}</Label>
<Select value={form.arch} onValueChange={(v) => { if (v !== null) set('arch', v); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -125,16 +127,18 @@ function MirrorFormDialog({
</Select>
</div>
<div>
<Label htmlFor="m-os">ОС</Label>
<Label htmlFor="m-os">{t('mirrors.os')}</Label>
<Input id="m-os" placeholder="ubuntu" value={form.os} onChange={(e) => set('os', e.target.value)} />
</div>
</div>
<div>
<Label htmlFor="m-ver">Версия</Label>
<Label htmlFor="m-ver">{t('mirrors.version')}</Label>
<Input id="m-ver" placeholder="24.04" value={form.version} onChange={(e) => set('version', e.target.value)} />
</div>
<div>
<Label htmlFor="m-url">URL {form.kind === 'vztmpl' ? '(или имя шаблона ниже)' : '(обязательно)'}</Label>
<Label htmlFor="m-url">
URL {form.kind === 'vztmpl' ? t('mirrors.url_or_template_hint') : t('mirrors.url_required_hint')}
</Label>
<Input
id="m-url"
placeholder={form.kind === 'iso' ? 'https://.../installer-amd64.iso' : 'https://.../image.qcow2'}
@@ -144,17 +148,17 @@ function MirrorFormDialog({
</div>
{form.kind === 'vztmpl' && (
<div>
<Label htmlFor="m-tpl">Имя шаблона (vztmpl)</Label>
<Label htmlFor="m-tpl">{t('mirrors.template_name')}</Label>
<Input id="m-tpl" placeholder="debian-12-standard_..._amd64.tar.zst" value={form.template} onChange={(e) => set('template', e.target.value)} />
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="m-sum">Контрольная сумма</Label>
<Label htmlFor="m-sum">{t('mirrors.checksum')}</Label>
<Input id="m-sum" value={form.checksum} onChange={(e) => set('checksum', e.target.value)} />
</div>
<div>
<Label>Алгоритм</Label>
<Label>{t('mirrors.algo')}</Label>
<Select value={form.checksum_algorithm || '__none__'} onValueChange={(v) => { if (v !== null) set('checksum_algorithm', v === '__none__' ? '' : v); }}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
@@ -168,19 +172,19 @@ function MirrorFormDialog({
</div>
</div>
<div>
<Label htmlFor="m-desc">Описание</Label>
<Label htmlFor="m-desc">{t('mirrors.description')}</Label>
<Input id="m-desc" value={form.description} onChange={(e) => set('description', e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
<span>Включено (показывать в каталоге)</span>
<span>{t('mirrors.enabled_label')}</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!form.name.trim() || pending}>
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Сохранить
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -189,6 +193,7 @@ function MirrorFormDialog({
}
export default function MirrorsManager() {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: mirrors = [], isLoading } = useImageMirrors();
const del = useDeleteMirror();
@@ -200,13 +205,13 @@ export default function MirrorsManager() {
const handleDelete = async (m: ImageMirrorItem) => {
const ok = await confirm(m.name, {
title: 'Удалить зеркало?',
confirmLabel: 'Удалить',
title: t('mirrors.delete_title'),
confirmLabel: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
del.mutate(m.mirror_id, {
onSuccess: () => toast.success('Зеркало удалено'),
onSuccess: () => toast.success(t('mirrors.deleted')),
onError: (e: Error) => toast.error(e.message),
});
};
@@ -214,16 +219,16 @@ export default function MirrorsManager() {
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Кастомные зеркала</h2>
<h2 className="text-lg font-semibold">{t('mirrors.title')}</h2>
<Button size="sm" onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" /> Добавить
<Plus className="mr-2 h-4 w-4" /> {t('mirrors.add')}
</Button>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Загрузка...</div>
<div className="text-sm text-muted-foreground">{t('common.loading')}</div>
) : mirrors.length === 0 ? (
<div className="text-sm text-muted-foreground">Кастомных зеркал пока нет.</div>
<div className="text-sm text-muted-foreground">{t('mirrors.empty')}</div>
) : (
<div className="space-y-2">
{mirrors.map((m) => (
@@ -235,7 +240,7 @@ export default function MirrorsManager() {
</div>
<Badge variant="secondary">{m.kind}</Badge>
<Badge variant="outline">{m.arch}</Badge>
{!m.enabled && <Badge variant="destructive">выкл</Badge>}
{!m.enabled && <Badge variant="destructive">{t('mirrors.disabled_badge')}</Badge>}
<Button variant="ghost" size="icon" onClick={() => openEdit(m)}>
<Pencil className="h-4 w-4" />
</Button>

View File

@@ -289,6 +289,7 @@
"stopped": "Stopped",
"online": "Online",
"offline": "Offline",
"queued": "Queued...",
"confirm_delete": "Are you sure you want to delete this?",
"no_data": "No data available",
"all": "All",
@@ -1602,6 +1603,99 @@
"terminal_closed": "Terminal connection closed",
"terminal_error": "Terminal connection error"
},
"images": {
"title": "Images",
"server": "Server",
"node": "Node",
"no_nodes": "No nodes available",
"search": "Search image...",
"arch": "Architecture",
"arch_all": "All architectures",
"cloud_section": "Cloud VM images",
"iso_section": "ISO images",
"lxc_section": "LXC templates (Proxmox repository)",
"mirror_badge": "mirror",
"no_images_filter": "No images match the filter.",
"download_by_url": "Download by URL",
"select_server_node": "Select a server and node to download an image.",
"select_server_node_list": "Select a server and node to view the list.",
"node_storage": "On node storage",
"no_iso_links": "No saved ISO links. Download an image by URL or add a mirror with type «iso» below.",
"download_iso_title": "Download ISO",
"iso_local_desc": "The file will be uploaded to the node through the panel",
"iso_url_desc": "Proxmox will download the image from the URL directly to the node",
"node_label": "Node",
"arch_label": "Architecture",
"tab_url": "By URL",
"tab_local": "Local file",
"url_label": "Image URL",
"detect_filename_title": "Detect filename from URL",
"detect_filename_failed": "Could not determine filename",
"filename_label": "Filename in storage",
"filename_hint": "Proxmox only accepts .iso and .img — otherwise the extension will be added automatically. The wand button next to the URL detects the real filename (via Content-Disposition or the final URL after redirects) when it is not visible from the URL itself.",
"drop_iso": "Drop ISO file here",
"drop_iso_hint": "or click to select (.iso, .img)",
"remove_file": "Remove file",
"local_filename_hint": "The filename in storage is assigned automatically from the selected file's name.",
"storage_iso": "Storage (iso)",
"storage_import": "Download storage (import)",
"storage_vztmpl": "Storage (vztmpl)",
"select_storage": "Select storage",
"free": "free",
"no_iso_storage": "No storage with ISO content type. Enable ISO in storage settings.",
"no_import_storage": "No storage with import support (content=import). A dir/NFS storage with content type «Import» is required (PVE 8.2+).",
"no_vztmpl_storage": "No storage with container template support (vztmpl).",
"checksum_optional": "Checksum (optional)",
"algo": "Algorithm",
"iso_upload_started": "ISO upload to node started in background",
"iso_download_started": "ISO download started in background",
"upload_btn": "Upload",
"download_btn": "Download",
"create_template_title": "Create template from image",
"download_image_title": "Download image",
"make_template_label": "Convert to VM template after download (disk import + cloud-init)",
"disk_storage": "VM disk storage (images)",
"no_disk_storage": "No storage for VM disks (content=images).",
"vmid_label": "Template VMID (blank = auto)",
"vmid_placeholder": "e.g. 9001",
"cores": "Cores",
"memory_mb": "Memory (MB)",
"bridge": "Bridge",
"ci_user": "cloud-init user",
"ci_user_placeholder": "e.g. ubuntu",
"ci_password": "cloud-init password",
"template_started": "Template creation started in background",
"download_started": "Image download started in background",
"create_template_btn": "Create template"
},
"mirrors": {
"title": "Custom mirrors",
"add": "Add",
"empty": "No custom mirrors yet.",
"disabled_badge": "off",
"delete_title": "Delete mirror?",
"deleted": "Mirror deleted",
"saved": "Saved",
"add_title": "Add mirror",
"edit_title": "Edit mirror",
"name": "Name",
"kind": "Type",
"kind_qcow2": "qcow2 (VM)",
"kind_vztmpl": "vztmpl (LXC)",
"kind_iso": "iso (installation)",
"arch": "Architecture",
"os": "OS",
"version": "Version",
"url_or_template_hint": "(or template name below)",
"url_required_hint": "(required)",
"template_name": "Template name (vztmpl)",
"checksum": "Checksum",
"algo": "Algorithm",
"description": "Description",
"enabled_label": "Enabled (show in catalog)",
"url_required": "URL is required for {{kind}}",
"url_or_template_required": "URL or template name is required"
},
"scripts": {
"title": "Script catalog",
"subtitle": "Bash scripts for execution on Proxmox nodes and guests (VM/LXC)",

View File

@@ -289,6 +289,7 @@
"stopped": "Остановлен",
"online": "Онлайн",
"offline": "Офлайн",
"queued": "В очереди...",
"confirm_delete": "Вы уверены, что хотите удалить?",
"no_data": "Нет данных",
"all": "Все",
@@ -1727,6 +1728,99 @@
"access_manage": "Управление доступом PVE"
}
},
"images": {
"title": "Образы",
"server": "Сервер",
"node": "Нода",
"no_nodes": "Нет доступных нод",
"search": "Поиск образа...",
"arch": "Архитектура",
"arch_all": "Все архитектуры",
"cloud_section": "Cloud-образы ВМ",
"iso_section": "ISO-образы",
"lxc_section": "LXC-шаблоны (репозиторий Proxmox)",
"mirror_badge": "зеркало",
"no_images_filter": "Нет образов по фильтру.",
"download_by_url": "Загрузить по URL",
"select_server_node": "Выберите сервер и ноду, чтобы загрузить образ.",
"select_server_node_list": "Выберите сервер и ноду, чтобы увидеть список.",
"node_storage": "На хранилище ноды",
"no_iso_links": "Сохранённых ISO-ссылок нет. Загрузите образ по URL или добавьте зеркало с типом «iso» ниже.",
"download_iso_title": "Загрузить ISO",
"iso_local_desc": "Файл будет передан на ноду через панель",
"iso_url_desc": "Proxmox скачает образ по ссылке прямо на ноду",
"node_label": "Нода",
"arch_label": "Архитектура",
"tab_url": "По ссылке (URL)",
"tab_local": "Локальный файл",
"url_label": "URL образа",
"detect_filename_title": "Определить имя файла по ссылке",
"detect_filename_failed": "Не удалось определить имя файла",
"filename_label": "Имя файла в хранилище",
"filename_hint": "Proxmox принимает только .iso и .img — иначе расширение будет добавлено автоматически. Кнопка со звёздочкой рядом с URL определяет настоящее имя файла (по Content-Disposition или итоговой ссылке после редиректов), если оно не видно из самого URL.",
"drop_iso": "Перетащите ISO-файл сюда",
"drop_iso_hint": "или нажмите, чтобы выбрать (.iso, .img)",
"remove_file": "Убрать файл",
"local_filename_hint": "Имя файла в хранилище назначается автоматически — из имени выбранного файла.",
"storage_iso": "Хранилище (iso)",
"storage_import": "Хранилище загрузки (import)",
"storage_vztmpl": "Хранилище (vztmpl)",
"select_storage": "Выберите хранилище",
"free": "свободно",
"no_iso_storage": "Нет хранилища с типом контента ISO. Включите ISO в настройках хранилища.",
"no_import_storage": "Нет хранилища с поддержкой импорта (content=import). Нужен dir/NFS storage с типом контента «Import» (PVE 8.2+).",
"no_vztmpl_storage": "Нет хранилища с поддержкой шаблонов контейнеров (vztmpl).",
"checksum_optional": "Контрольная сумма (необязательно)",
"algo": "Алгоритм",
"iso_upload_started": "Загрузка ISO на ноду запущена в фоне",
"iso_download_started": "Загрузка ISO запущена в фоне",
"upload_btn": "Загрузить",
"download_btn": "Скачать",
"create_template_title": "Создать шаблон из образа",
"download_image_title": "Скачать образ",
"make_template_label": "Сделать VM-шаблоном после загрузки (импорт диска + cloud-init)",
"disk_storage": "Хранилище диска ВМ (images)",
"no_disk_storage": "Нет хранилища для дисков ВМ (content=images).",
"vmid_label": "VMID шаблона (пусто = авто)",
"vmid_placeholder": "напр. 9001",
"cores": "Ядра",
"memory_mb": "Память (MB)",
"bridge": "Мост",
"ci_user": "cloud-init пользователь",
"ci_user_placeholder": "напр. ubuntu",
"ci_password": "cloud-init пароль",
"template_started": "Создание шаблона запущено в фоне",
"download_started": "Загрузка образа запущена в фоне",
"create_template_btn": "Создать шаблон"
},
"mirrors": {
"title": "Кастомные зеркала",
"add": "Добавить",
"empty": "Кастомных зеркал пока нет.",
"disabled_badge": "выкл",
"delete_title": "Удалить зеркало?",
"deleted": "Зеркало удалено",
"saved": "Сохранено",
"add_title": "Добавить зеркало",
"edit_title": "Редактировать зеркало",
"name": "Название",
"kind": "Тип",
"kind_qcow2": "qcow2 (ВМ)",
"kind_vztmpl": "vztmpl (LXC)",
"kind_iso": "iso (установочный)",
"arch": "Архитектура",
"os": "ОС",
"version": "Версия",
"url_or_template_hint": "(или имя шаблона ниже)",
"url_required_hint": "(обязательно)",
"template_name": "Имя шаблона (vztmpl)",
"checksum": "Контрольная сумма",
"algo": "Алгоритм",
"description": "Описание",
"enabled_label": "Включено (показывать в каталоге)",
"url_required": "Для {{kind}} нужен URL",
"url_or_template_required": "Нужен URL или имя шаблона"
},
"scripts": {
"title": "Каталог скриптов",
"subtitle": "Bash-скрипты для выполнения на нодах Proxmox и гостях (VM/LXC)",