feat(images): Phase 2 UI — make-template toggle in download dialog
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Successful in 51s
Deploy to Production / Trigger production update (push) Failing after 11s

qcow2: 'make VM template' toggle (default on) with disk-storage picker,
cores/memory/bridge and cloud-init user/password fields; routes to
image_template task kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:11:20 +05:00
parent c7edf72d19
commit ec8f4904a1
2 changed files with 104 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Select, Select,
@@ -44,30 +45,53 @@ function fmtSize(bytes?: number): string {
} }
export default function DownloadImageDialog({ open, onClose, serverId, node, image }: Props) { export default function DownloadImageDialog({ open, onClose, serverId, node, image }: Props) {
const [storage, setStorage] = useState(''); 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 [cores, setCores] = useState(2);
const [memory, setMemory] = useState(2048);
const [bridge, setBridge] = useState('vmbr0');
const [ciuser, setCiuser] = useState('');
const [cipassword, setCipassword] = useState('');
// qcow2 кладём как образ для импорта (PVE 8.2+ content=import), vztmpl — как шаблон CT // qcow2 кладём как образ для импорта (PVE 8.2+ content=import), vztmpl — как шаблон CT
const content = image?.kind === 'vztmpl' ? 'vztmpl' : 'import'; const content = isQcow2 ? 'import' : 'vztmpl';
const { data: storages = [], isLoading: storagesLoading } = useImageStorages( const { data: storages = [], isLoading: storagesLoading } = useImageStorages(
open ? serverId : undefined, open ? serverId : undefined,
open ? node : undefined, open ? node : undefined,
open ? content : undefined, open ? content : undefined,
); );
// Хранилища для диска ВМ (только при конвертации в шаблон)
const { data: diskStorages = [] } = useImageStorages(
open && isQcow2 && toTemplate ? serverId : undefined,
open && isQcow2 && toTemplate ? node : undefined,
open && isQcow2 && toTemplate ? 'images' : undefined,
);
const download = useDownloadImage(serverId); const download = useDownloadImage(serverId);
const addDeployTask = useDeployTasksStore((s) => s.addTask); const addDeployTask = useDeployTasksStore((s) => s.addTask);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
// Авто-выбор единственного хранилища
setStorage(storages.length === 1 ? storages[0].storage : ''); setStorage(storages.length === 1 ? storages[0].storage : '');
setToTemplate(true);
} }
}, [open, storages]); }, [open, storages]);
useEffect(() => {
if (open) setDiskStorage(diskStorages.length === 1 ? diskStorages[0].storage : '');
}, [open, diskStorages]);
if (!image) return null; if (!image) return null;
const makeTemplate = isQcow2 && toTemplate;
const submitDisabled =
!storage || download.isPending || (makeTemplate && !diskStorage);
const submit = () => { const submit = () => {
if (!storage) return; if (submitDisabled) return;
download.mutate( download.mutate(
{ {
node, node,
@@ -75,6 +99,13 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
source_id: image.source_id, source_id: image.source_id,
kind: image.kind, kind: image.kind,
template: image.source_id ? undefined : image.template || undefined, template: image.source_id ? undefined : image.template || undefined,
to_template: makeTemplate,
disk_storage: makeTemplate ? diskStorage : undefined,
cores: makeTemplate ? cores : undefined,
memory: makeTemplate ? memory : undefined,
bridge: makeTemplate ? bridge : undefined,
ciuser: makeTemplate && ciuser ? ciuser : undefined,
cipassword: makeTemplate && cipassword ? cipassword : undefined,
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
@@ -87,10 +118,10 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
vmid: null, vmid: null,
node, node,
error_message: null, error_message: null,
kind: 'image_download', kind: makeTemplate ? 'image_template' : 'image_download',
server_id: serverId, server_id: serverId,
}); });
toast.success('Загрузка образа запущена в фоне'); toast.success(makeTemplate ? 'Создание шаблона запущено в фоне' : 'Загрузка образа запущена в фоне');
onClose(); onClose();
}, },
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
@@ -102,7 +133,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Скачать образ</DialogTitle> <DialogTitle>{makeTemplate ? 'Создать шаблон из образа' : 'Скачать образ'}</DialogTitle>
<DialogDescription>{image.name}</DialogDescription> <DialogDescription>{image.name}</DialogDescription>
</DialogHeader> </DialogHeader>
@@ -113,7 +144,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
</div> </div>
<div> <div>
<Label>Хранилище ({content})</Label> <Label>{isQcow2 ? 'Хранилище загрузки (import)' : 'Хранилище (vztmpl)'}</Label>
<Select value={storage || '__none__'} onValueChange={(v) => { if (v !== null) setStorage(v === '__none__' ? '' : v); }}> <Select value={storage || '__none__'} onValueChange={(v) => { if (v !== null) setStorage(v === '__none__' ? '' : v); }}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={storagesLoading ? 'Загрузка...' : 'Выберите хранилище'} /> <SelectValue placeholder={storagesLoading ? 'Загрузка...' : 'Выберите хранилище'} />
@@ -134,13 +165,67 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
</p> </p>
)} )}
</div> </div>
{isQcow2 && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={toTemplate} onChange={(e) => setToTemplate(e.target.checked)} />
<span>Сделать VM-шаблоном после загрузки (импорт диска + cloud-init)</span>
</label>
)}
{makeTemplate && (
<div className="space-y-4 rounded-md border p-3">
<div>
<Label>Хранилище диска ВМ (images)</Label>
<Select value={diskStorage || '__none__'} onValueChange={(v) => { if (v !== null) setDiskStorage(v === '__none__' ? '' : v); }}>
<SelectTrigger><SelectValue placeholder="Выберите хранилище" /></SelectTrigger>
<SelectContent>
{diskStorages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} свободно` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{diskStorages.length === 0 && (
<p className="mt-2 text-sm text-amber-600 dark:text-amber-500">
Нет хранилища для дисков ВМ (content=images).
</p>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor="img-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>
<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>
<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)} />
</div>
<div>
<Label htmlFor="img-cipass">cloud-init пароль</Label>
<Input id="img-cipass" type="password" value={cipassword} onChange={(e) => setCipassword(e.target.value)} />
</div>
</div>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button> <Button variant="outline" onClick={onClose}>Отмена</Button>
<Button onClick={submit} disabled={!storage || download.isPending}> <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" />} {download.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
Скачать {makeTemplate ? 'Создать шаблон' : 'Скачать'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -58,6 +58,15 @@ export interface ImageDownloadRequest {
filename?: string; filename?: string;
checksum?: string; checksum?: string;
checksum_algorithm?: string; checksum_algorithm?: string;
// Авто-конвертация qcow2 → VM-шаблон
to_template?: boolean;
disk_storage?: string;
cores?: number;
memory?: number;
bridge?: string;
ciuser?: string;
cipassword?: string;
ssh_keys?: string;
} }
export function useDownloadImage(serverId: number) { export function useDownloadImage(serverId: number) {