feat: LXC container creation from CT templates

- Add LXC deploy API endpoints: GET /proxmox/api/lxc-templates/{id},
  GET /proxmox/api/lxc-storages/{id}, POST /proxmox/api/lxc/deploy
- Add ProxmoxClient.get_lxc_storage_templates() and get_node_storages()
- Fix duplicate create_lxc_container() — removed dead copy, updated
  canonical with nameserver/searchdomain params
- Fix wait_for_task(): accept WARNINGS:N exitstatus as success
- Add IPAM IP release on deploy failure
- Add frontend hooks: useLXCTemplates, useLXCStorages, useDeployLXC
  with 5-min staleTime caching
- Add LXC wizard steps to CreateInstanceWizard (type, template, config)
- Add LXC i18n keys to en.json and ru.json
- Add LXCTemplate and LXCDeployRequest TypeScript interfaces
This commit is contained in:
markmorado
2026-05-06 19:17:04 +02:00
parent adcc86f30a
commit 1cf099ba1d
8 changed files with 1000 additions and 39 deletions

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor } from 'lucide-react';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -12,12 +12,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useServers } from '@/hooks/use-nodes';
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
import { toast } from 'sonner';
const STEPS = ['server', 'template', 'config', 'confirm'] as const;
type InstanceKind = 'vm' | 'lxc';
const STEPS = ['server', 'type', 'template', 'config', 'confirm'] as const;
type Step = (typeof STEPS)[number];
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
@@ -29,8 +31,11 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const addDeployTask = useDeployTasksStore((s) => s.addTask);
// State
// ── Shared state ──────────────────────────────────────────────────────────
const [kind, setKind] = useState<InstanceKind>('lxc');
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
// ── VM state ──────────────────────────────────────────────────────────────
const [selectedNode, setSelectedNode] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
const [config, setConfig] = useState<{
@@ -63,11 +68,34 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
ssh_keys: '',
});
// ── LXC state ─────────────────────────────────────────────────────────────
const [selectedLXCTemplate, setSelectedLXCTemplate] = useState<LXCTemplate | null>(null);
const [lxcConfig, setLxcConfig] = useState({
name: '',
cores: 1,
memory: 512,
swap: 512,
disk: 8,
storage: 'local-lvm',
bridge: 'vmbr0',
ipam_network_id: null as number | null,
ip_address: '',
gateway: '',
password: '',
ssh_keys: '',
nameserver: '',
unprivileged: true,
start_after_create: true,
onboot: false,
});
// Queries
const { data: servers = [] } = useServers();
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
const { data: networks = [] } = useIPAMNetworks();
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id);
const deploy = useMutation({
mutationFn: (data: VMDeployRequest) =>
@@ -89,6 +117,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
onError: (err: Error) => toast.error(err.message),
});
const deployLXC = useDeployLXC();
const next = () => {
const i = STEPS.indexOf(step);
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
@@ -100,30 +130,75 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const canNext = () => {
if (step === 'server') return !!selectedServer;
if (step === 'template') return !!selectedTemplate;
if (step === 'config') return config.name.length >= 2;
if (step === 'type') return !!kind;
if (step === 'template') return kind === 'vm' ? !!selectedTemplate : !!selectedLXCTemplate;
if (step === 'config') return kind === 'vm' ? config.name.length >= 2 : lxcConfig.name.length >= 2;
return true;
};
const handleDeploy = () => {
if (!selectedTemplate) return;
deploy.mutate({
template_id: selectedTemplate.id,
name: config.name,
target_node: selectedNode || undefined,
cores: config.cores,
memory: config.memory,
disk: config.disk,
target_storage: config.storage,
start_after_create: config.start_after_create,
network_bridge: config.bridge,
ip_address: config.ip_address || undefined,
gateway: config.gateway || undefined,
ipam_network_id: config.ipam_network_id || undefined,
cloud_init_user: config.cloud_init_user || undefined,
cloud_init_password: config.cloud_init_password || undefined,
ssh_keys: config.ssh_keys || undefined,
});
if (kind === 'lxc') {
if (!selectedLXCTemplate || !selectedServer) return;
deployLXC.mutate(
{
server_id: selectedServer.id,
ostemplate: selectedLXCTemplate.volid,
node: selectedLXCTemplate.node || undefined,
name: lxcConfig.name,
cores: lxcConfig.cores,
memory: lxcConfig.memory,
swap: lxcConfig.swap,
disk: lxcConfig.disk,
storage: lxcConfig.storage,
bridge: lxcConfig.bridge,
ip_address: lxcConfig.ip_address || undefined,
gateway: lxcConfig.gateway || undefined,
ipam_network_id: lxcConfig.ipam_network_id || undefined,
password: lxcConfig.password || undefined,
ssh_keys: lxcConfig.ssh_keys || undefined,
nameserver: lxcConfig.nameserver || undefined,
unprivileged: lxcConfig.unprivileged,
start_after_create: lxcConfig.start_after_create,
onboot: lxcConfig.onboot,
},
{
onSuccess: (res) => {
addDeployTask({
id: res.task_id,
name: res.name,
status: 'pending',
step: t('wizard.deploy_starting'),
progress: 0,
vmid: null,
node: null,
error_message: null,
});
toast.success(t('wizard.deploy_queued', { name: res.name }));
navigate('/instances');
},
onError: (err: Error) => toast.error(err.message),
},
);
} else {
if (!selectedTemplate) return;
deploy.mutate({
template_id: selectedTemplate.id,
name: config.name,
target_node: selectedNode || undefined,
cores: config.cores,
memory: config.memory,
disk: config.disk,
target_storage: config.storage,
start_after_create: config.start_after_create,
network_bridge: config.bridge,
ip_address: config.ip_address || undefined,
gateway: config.gateway || undefined,
ipam_network_id: config.ipam_network_id || undefined,
cloud_init_user: config.cloud_init_user || undefined,
cloud_init_password: config.cloud_init_password || undefined,
ssh_keys: config.ssh_keys || undefined,
});
}
};
// Apply template defaults when template selected
@@ -164,7 +239,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
<Card
key={srv.id}
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedServer?.id === srv.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
onClick={() => setSelectedServer(srv)}
onClick={() => { setSelectedServer(srv); setSelectedLXCTemplate(null); setSelectedTemplate(null); }}
>
<CardContent className="flex items-center gap-3 p-4">
<Server className="h-8 w-8 text-muted-foreground" />
@@ -182,8 +257,43 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</div>
)}
{/* Step 2: Template */}
{step === 'template' && (
{/* Step 2: Type selection */}
{step === 'type' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_type')}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<Card
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${kind === 'vm' ? 'border-blue-500 bg-blue-500/5' : ''}`}
onClick={() => setKind('vm')}
>
<CardContent className="flex items-center gap-4 p-5">
<HardDrive className="h-10 w-10 text-blue-400 shrink-0" />
<div>
<p className="font-semibold">{t('wizard.type_vm')}</p>
<p className="text-sm text-muted-foreground">{t('wizard.type_vm_desc')}</p>
</div>
{kind === 'vm' && <CheckCircle className="ml-auto h-5 w-5 text-blue-400 shrink-0" />}
</CardContent>
</Card>
<Card
className={`cursor-pointer transition-colors hover:border-green-500/50 ${kind === 'lxc' ? 'border-green-500 bg-green-500/5' : ''}`}
onClick={() => setKind('lxc')}
>
<CardContent className="flex items-center gap-4 p-5">
<Container className="h-10 w-10 text-green-400 shrink-0" />
<div>
<p className="font-semibold">{t('wizard.type_lxc')}</p>
<p className="text-sm text-muted-foreground">{t('wizard.type_lxc_desc')}</p>
</div>
{kind === 'lxc' && <CheckCircle className="ml-auto h-5 w-5 text-green-400 shrink-0" />}
</CardContent>
</Card>
</div>
</div>
)}
{/* Step 3: Template — VM */}
{step === 'template' && kind === 'vm' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
{groups.map(group => {
@@ -224,8 +334,50 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</div>
)}
{/* Step 3: Configuration */}
{step === 'config' && (
{/* Step 3: Template — LXC */}
{step === 'template' && kind === 'lxc' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_lxc_template')}</h3>
{lxcLoading && (
<div className="flex items-center justify-center py-8 text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t('common.loading')}</span>
</div>
)}
{!lxcLoading && lxcTemplates.length === 0 && (
<p className="text-center text-muted-foreground py-8">{t('wizard.no_lxc_templates')}</p>
)}
{!lxcLoading && lxcTemplates.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{lxcTemplates.map(tpl => (
<Card
key={tpl.volid}
className={`cursor-pointer transition-colors hover:border-green-500/50 ${selectedLXCTemplate?.volid === tpl.volid ? 'border-green-500 bg-green-500/5' : ''}`}
onClick={() => setSelectedLXCTemplate(tpl)}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Container className="h-5 w-5 text-green-400 shrink-0" />
<span className="font-medium text-sm truncate">{tpl.name}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground font-mono">
{tpl.storage} · {tpl.node}
</p>
{tpl.size > 0 && (
<p className="text-xs text-muted-foreground">
{(tpl.size / 1024 / 1024).toFixed(0)} MB
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
{/* Step 4: Configuration — VM */}
{step === 'config' && kind === 'vm' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<div className="grid gap-6 sm:grid-cols-2">
@@ -350,13 +502,155 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</div>
)}
{/* Step 4: Confirm */}
{step === 'confirm' && (
{/* Step 4: Configuration — LXC */}
{step === 'config' && kind === 'lxc' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<div className="grid gap-6 sm:grid-cols-2">
{/* Basic */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.basic')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.hostname')}</Label>
<Input
value={lxcConfig.name}
onChange={e => setLxcConfig(p => ({ ...p, name: e.target.value }))}
placeholder={t('common.placeholder_vm_name')}
/>
</div>
<div>
<Label>{t('wizard.storage')}</Label>
<Select value={lxcConfig.storage} onValueChange={v => setLxcConfig(p => ({ ...p, storage: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{lxcStorages.length > 0
? lxcStorages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)
: <SelectItem value="local-lvm">local-lvm</SelectItem>
}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="lxc_start_after"
checked={lxcConfig.start_after_create}
onChange={e => setLxcConfig(p => ({ ...p, start_after_create: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="lxc_start_after">{t('wizard.start_after_create')}</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="lxc_unprivileged"
checked={lxcConfig.unprivileged}
onChange={e => setLxcConfig(p => ({ ...p, unprivileged: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="lxc_unprivileged">{t('wizard.unprivileged')}</Label>
</div>
</CardContent>
</Card>
{/* Resources */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Cpu className="h-4 w-4" />{t('wizard.resources')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>vCPU</Label>
<Input type="number" min={1} value={lxcConfig.cores} onChange={e => setLxcConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
</div>
<div>
<Label>{t('wizard.memory_mb')}</Label>
<Input type="number" min={64} step={128} value={lxcConfig.memory} onChange={e => setLxcConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
</div>
<div>
<Label>{t('wizard.swap_mb')}</Label>
<Input type="number" min={0} step={128} value={lxcConfig.swap} onChange={e => setLxcConfig(p => ({ ...p, swap: Number(e.target.value) }))} />
</div>
<div>
<Label>{t('wizard.disk_gb')}</Label>
<Input type="number" min={1} value={lxcConfig.disk} onChange={e => setLxcConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
</div>
</CardContent>
</Card>
{/* Network */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.network')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.bridge')}</Label>
<Input value={lxcConfig.bridge} onChange={e => setLxcConfig(p => ({ ...p, bridge: e.target.value }))} />
</div>
<div>
<Label>IPAM {t('wizard.network')}</Label>
<Select value={String(lxcConfig.ipam_network_id || '')} onValueChange={v => setLxcConfig(p => ({ ...p, ipam_network_id: v ? Number(v) : null }))}>
<SelectTrigger><SelectValue placeholder={t('wizard.manual_ip')} /></SelectTrigger>
<SelectContent>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!lxcConfig.ipam_network_id && (
<>
<div>
<Label>IP (CIDR)</Label>
<Input value={lxcConfig.ip_address} onChange={e => setLxcConfig(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.10.10.5/24 или dhcp" />
</div>
<div>
<Label>Gateway</Label>
<Input value={lxcConfig.gateway} onChange={e => setLxcConfig(p => ({ ...p, gateway: e.target.value }))} placeholder="10.10.10.1" />
</div>
</>
)}
<div>
<Label>DNS</Label>
<Input value={lxcConfig.nameserver} onChange={e => setLxcConfig(p => ({ ...p, nameserver: e.target.value }))} placeholder="8.8.8.8" />
</div>
</CardContent>
</Card>
{/* Auth */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.lxc_password')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.lxc_password')}</Label>
<Input
type="password"
value={lxcConfig.password}
onChange={e => setLxcConfig(p => ({ ...p, password: e.target.value }))}
placeholder={t('wizard.lxc_password_placeholder')}
/>
</div>
<div>
<Label>SSH Keys</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={lxcConfig.ssh_keys}
onChange={e => setLxcConfig(p => ({ ...p, ssh_keys: e.target.value }))}
placeholder={t('common.placeholder_ssh_key')}
/>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Step 5: Confirm — VM */}
{step === 'confirm' && kind === 'vm' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
<Card>
<CardContent className="p-6 space-y-3">
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
<Row label={t('common.type')} value="VM (QEMU)" />
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
<Row label={t('common.name')} value={config.name} />
<Row label={t('common.vcpu')} value={String(config.cores)} />
@@ -371,14 +665,38 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</div>
)}
{/* Step 5: Confirm — LXC */}
{step === 'confirm' && kind === 'lxc' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
<Card>
<CardContent className="p-6 space-y-3">
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
<Row label={t('common.type')} value="LXC Container" />
<Row label={t('wizard.lxc_template_label')} value={selectedLXCTemplate?.name || ''} />
<Row label={t('wizard.hostname')} value={lxcConfig.name} />
<Row label={t('common.vcpu')} value={String(lxcConfig.cores)} />
<Row label={t('wizard.memory_mb')} value={`${lxcConfig.memory} MB`} />
<Row label={t('wizard.swap_mb')} value={`${lxcConfig.swap} MB`} />
<Row label={t('wizard.disk_gb')} value={`${lxcConfig.disk} GB`} />
<Row label={t('wizard.storage')} value={lxcConfig.storage} />
<Row label={t('wizard.bridge')} value={lxcConfig.bridge} />
{lxcConfig.ipam_network_id && <Row label={t('common.ipam')} value={networks.find(n => n.id === lxcConfig.ipam_network_id)?.name || 'Auto'} />}
{lxcConfig.ip_address && <Row label={t('common.primary_ip')} value={lxcConfig.ip_address} />}
<Row label={t('wizard.unprivileged')} value={lxcConfig.unprivileged ? t('common.yes') : t('common.no')} />
</CardContent>
</Card>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between border-t pt-4">
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
</Button>
{step === 'confirm' ? (
<Button onClick={handleDeploy} disabled={deploy.isPending}>
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Button onClick={handleDeploy} disabled={deploy.isPending || deployLXC.isPending}>
{(deploy.isPending || deployLXC.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('wizard.deploy')}
</Button>
) : (
@@ -390,7 +708,6 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">