feat(templates): add quick-deploy and CT template download

Per-template Deploy button opens a quick-deploy dialog (prefilled from the
quick-deploy endpoint defaults) that creates a VM via /templates/api/deploy.
New 'Download CT template' dialog browses the node's appliance repository
(available-lxc-templates) and pulls a template to a vztmpl storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 00:29:34 +05:00
parent 508be2423a
commit 5d6acd619e
6 changed files with 296 additions and 5 deletions

View File

@@ -0,0 +1,177 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers, useNodes, useStorages } from '@/hooks/use-nodes';
import { useQuickDeployInfo, useDeployVM } from '@/hooks/use-templates';
import { useAvailableLXCTemplates, useDownloadLXCTemplate } from '@/hooks/use-lxc-templates';
import type { OSTemplate } from '@/types';
import { toast } from 'sonner';
// ==================== Quick deploy (VM from template) ====================
export function DeployDialog({ template, onClose }: { template: OSTemplate | null; onClose: () => void }) {
const { t } = useTranslation();
const open = !!template;
const { data: info } = useQuickDeployInfo(template?.id ?? null);
const deploy = useDeployVM();
const [name, setName] = useState('');
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const [disk, setDisk] = useState('');
const [start, setStart] = useState(true);
// Prefill from the quick-deploy info (authoritative defaults from backend).
useEffect(() => {
if (info?.template) {
setCores(String(info.template.default_cores));
setMemory(String(info.template.default_memory));
setDisk(String(info.template.default_disk));
}
}, [info?.template]);
useEffect(() => { if (template) setName(''); }, [template]);
const submit = () => {
if (!template) return;
if (!name.trim()) { toast.error(t('templates.name_required')); return; }
deploy.mutate(
{
template_id: template.id,
name: name.trim(),
cores: Number(cores) || undefined,
memory: Number(memory) || undefined,
disk: Number(disk) || undefined,
start_after_create: start,
},
{
onSuccess: () => { toast.success(t('templates.deploy_started')); onClose(); },
onError: (e: Error) => toast.error(e.message),
},
);
};
const min = info?.template;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{t('templates.deploy')}: {template?.name}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('templates.vm_name')}</Label><Input value={name} onChange={e => setName(e.target.value)} className="mt-1" placeholder="web-01" /></div>
<div className="grid grid-cols-3 gap-3">
<div><Label>vCPU</Label><Input type="number" min={min?.min_cores ?? 1} value={cores} onChange={e => setCores(e.target.value)} className="mt-1" /></div>
<div><Label>{t('nodes.memory')} (MB)</Label><Input type="number" min={min?.min_memory ?? 128} value={memory} onChange={e => setMemory(e.target.value)} className="mt-1" /></div>
<div><Label>{t('nodes.disk')} (GB)</Label><Input type="number" min={min?.min_disk ?? 1} value={disk} onChange={e => setDisk(e.target.value)} className="mt-1" /></div>
</div>
{min && <p className="text-xs text-muted-foreground">{t('templates.minimums')}: {min.min_cores} vCPU / {min.min_memory} MB / {min.min_disk} GB</p>}
<div className="flex items-center gap-2">
<input id="deploy_start" type="checkbox" checked={start} onChange={e => setStart(e.target.checked)} className="h-4 w-4" />
<Label htmlFor="deploy_start" className="cursor-pointer">{t('templates.start_after')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={deploy.isPending || !name.trim()}>
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('templates.deploy')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Download CT template ====================
export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { data: servers = [] } = useServers();
const [serverId, setServerId] = useState(0);
const [node, setNode] = useState('');
const [storage, setStorage] = useState('');
const [template, setTemplate] = useState('');
const { data: nodesData } = useNodes(serverId);
const nodes = nodesData?.nodes ?? [];
const { data: available = [], isFetching } = useAvailableLXCTemplates(serverId || undefined, node || undefined);
const { data: storagesRaw = [] } = useStorages(serverId, node);
const download = useDownloadLXCTemplate(serverId);
// Storages that accept CT templates (vztmpl content).
const storages = useMemo(
() => (storagesRaw as { storage: string; content?: string }[]).filter(s => (s.content || '').split(',').includes('vztmpl')),
[storagesRaw],
);
// Default node to the first one.
useEffect(() => { if (!node && nodes.length) setNode(nodes[0].node); }, [nodes, node]);
const submit = () => {
if (!node || !storage || !template) { toast.error(t('templates.download_fields_required')); return; }
download.mutate({ node, storage, template }, {
onSuccess: () => { toast.success(t('templates.download_started')); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{t('templates.download_ct')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>{t('templates.server')}</Label>
<Select value={serverId ? String(serverId) : ''} onValueChange={v => { if (v) { setServerId(Number(v)); setNode(''); setStorage(''); setTemplate(''); } }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
<SelectContent>{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}</SelectContent>
</Select>
</div>
{serverId > 0 && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('nodes.title')}</Label>
<Select value={node} onValueChange={v => { if (v) { setNode(v); setTemplate(''); setStorage(''); } }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>{t('storages.title')}</Label>
<Select value={storage} onValueChange={v => { if (v) setStorage(v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={storages.length ? '' : t('templates.no_vztmpl_storage')} /></SelectTrigger>
<SelectContent>{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t('templates.template')} {isFetching && <Loader2 className="inline h-3 w-3 animate-spin" />}</Label>
<Select value={template} onValueChange={v => { if (v) setTemplate(v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('templates.select_template')} /></SelectTrigger>
<SelectContent className="max-h-72">
{available.map(a => (
<SelectItem key={a.template} value={a.template}>{a.template}{a.headline ? ` — ${a.headline}` : ''}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={download.isPending || !node || !storage || !template}>
{download.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('templates.download_btn')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Monitor, Search, Download, Trash2, Loader2 } from 'lucide-react';
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus } from 'lucide-react';
import { OsLogo } from './OsLogo';
import { DeployDialog, DownloadCTDialog } from './TemplateDialogs';
import type { OSTemplate } from '@/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -20,6 +22,8 @@ export default function TemplatesPage() {
const [typeFilter, setTypeFilter] = useState<string>('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>('');
const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null);
const [downloadOpen, setDownloadOpen] = useState(false);
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [], isLoading } = useTemplates(
@@ -50,9 +54,14 @@ export default function TemplatesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
<Button onClick={() => setImportDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDownloadOpen(true)}>
<PackagePlus className="mr-2 h-4 w-4" />{t('templates.download_ct')}
</Button>
<Button onClick={() => setImportDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
</Button>
</div>
</div>
{/* Filters */}
@@ -133,12 +142,20 @@ export default function TemplatesPage() {
<span>{tpl.server_name}</span>
<span>{tpl.vmid != null ? `VMID: ${tpl.vmid}` : t('templates.file_template')}</span>
</div>
{tpl.vm_type === 'qemu' && tpl.vmid != null && (
<Button size="sm" className="w-full" onClick={() => setDeployTpl(tpl)}>
<Rocket className="mr-1.5 h-4 w-4" />{t('templates.deploy')}
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
<DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} />
<DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} />
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="sm:max-w-md">

View File

@@ -1,4 +1,4 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { LXCTemplate, LXCDeployRequest } from '@/types';
@@ -42,3 +42,37 @@ export function useDeployLXC() {
apiClient.post<{ task_id: number; status: string; name: string }>('/proxmox/api/lxc/deploy', data),
});
}
export interface AvailableLXCTemplate {
template: string;
type?: string;
os?: string;
version?: string;
headline?: string;
section?: string;
package?: string;
[k: string]: unknown;
}
// Templates available to download from the PVE appliance repository for a node.
export function useAvailableLXCTemplates(serverId?: number, node?: string) {
return useQuery({
queryKey: ['available-lxc-templates', serverId, node],
queryFn: () =>
apiClient.get<AvailableLXCTemplate[]>(`/proxmox/api/${serverId}/available-lxc-templates?node=${encodeURIComponent(node!)}`),
enabled: !!serverId && !!node,
staleTime: 5 * 60 * 1000,
});
}
export function useDownloadLXCTemplate(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { node: string; storage: string; template: string }) =>
apiClient.post<{ success?: boolean; upid?: string; task_id?: number }>(`/proxmox/api/${serverId}/download-lxc-template`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['all-tasks'] });
qc.invalidateQueries({ queryKey: ['lxc-templates', serverId] });
},
});
}

View File

@@ -79,3 +79,38 @@ export function useDeleteTemplate() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}
export interface QuickDeployInfo {
template: { id: number; name: string; vmid?: number; node?: string; description?: string;
default_cores: number; default_memory: number; default_disk: number;
min_cores: number; min_memory: number; min_disk: number };
group: { name?: string; icon?: string };
server: { id?: number; name?: string };
}
export function useQuickDeployInfo(templateId: number | null) {
return useQuery({
queryKey: ['quick-deploy', templateId],
queryFn: () => apiClient.get<QuickDeployInfo>(`/templates/api/quick-deploy/${templateId}`),
enabled: !!templateId,
});
}
export interface DeployVMArgs {
template_id: number; name: string;
cores?: number; memory?: number; disk?: number;
target_node?: string; target_storage?: string;
start_after_create?: boolean; onboot?: boolean;
}
export function useDeployVM() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: DeployVMArgs) =>
apiClient.post<{ task_id: number; status: string; name?: string }>('/templates/api/deploy', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['all-tasks'] });
qc.invalidateQueries({ queryKey: ['virtual-machines'] });
},
});
}

View File

@@ -396,6 +396,20 @@
},
"templates": {
"title": "OS Templates",
"deploy": "Deploy",
"vm_name": "VM name",
"minimums": "Minimums",
"start_after": "Start after creation",
"deploy_started": "Deployment started",
"name_required": "Enter a VM name",
"download_ct": "Download CT template",
"download_started": "Download started",
"download_fields_required": "Select node, storage and template",
"download_btn": "Download",
"no_vztmpl_storage": "No vztmpl storage",
"template": "Template",
"select_template": "Select template",
"server": "Server",
"search": "Search templates...",
"all_groups": "All groups",
"all_servers": "All servers",

View File

@@ -396,6 +396,20 @@
},
"templates": {
"title": "Шаблоны ОС",
"deploy": "Развернуть",
"vm_name": "Имя VM",
"minimums": "Минимум",
"start_after": "Запустить после создания",
"deploy_started": "Развёртывание запущено",
"name_required": "Укажите имя VM",
"download_ct": "Скачать CT-шаблон",
"download_started": "Загрузка запущена",
"download_fields_required": "Выберите узел, хранилище и шаблон",
"download_btn": "Скачать",
"no_vztmpl_storage": "Нет хранилища vztmpl",
"template": "Шаблон",
"select_template": "Выберите шаблон",
"server": "Сервер",
"search": "Поиск шаблонов...",
"all_groups": "Все группы",
"all_servers": "Все серверы",