feat(images): Phase 2 UI — make-template toggle in download dialog
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:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user