|
|
|
|
@@ -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">
|
|
|
|
|
|