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:
177
frontend/src/features/templates/TemplateDialogs.tsx
Normal file
177
frontend/src/features/templates/TemplateDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Все серверы",
|
||||
|
||||
Reference in New Issue
Block a user