feat(ipam): привязка сетей к серверу/ноде и автовыбор сети в визардах
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
- network-scope: scopedNetworks/autoPickNetworkId — фильтрация IPAM-сетей по выбранным серверу/ноде и детерминированный автовыбор сети для выдачи IP - ServerNodeSelect: пара селектов «Сервер + Нода» для привязки сети - Мастер установки App Store и CreateInstanceWizard: список сетей ограничен релевантными, при смене сервера/ноды сеть подбирается автоматически - IPAM: страницы сетей учитывают привязку к ноде - Исправлены типы обработчиков Select (onValueChange отдаёт string|null) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
import { useLXCStorages } from '@/hooks/use-lxc-templates';
|
||||
import { useIPAMNetworks, useIPAMPools } from '@/hooks/use-ipam';
|
||||
import { scopedNetworks, autoPickNetworkId } from '@/features/ipam/network-scope';
|
||||
import { useInstallApp, useInstalledApp, useOperationProgress, useAppOperations } from '@/hooks/use-appstore';
|
||||
import type { AppOperation, CatalogApp } from '@/types/appstore';
|
||||
|
||||
@@ -49,6 +50,32 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
const { data: ipamNetworks = [] } = useIPAMNetworks();
|
||||
const { data: ipamPools = [] } = useIPAMPools(ipamNetworkId ? Number(ipamNetworkId) : undefined);
|
||||
|
||||
const serverIdNum = serverId ? Number(serverId) : undefined;
|
||||
const { data: nodesData } = useNodes(serverIdNum ?? 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
// Сети, релевантные выбранным серверу/ноде (для выпадающего списка).
|
||||
const availableNetworks = useMemo(
|
||||
() => scopedNetworks(ipamNetworks, serverIdNum, node || undefined),
|
||||
[ipamNetworks, serverIdNum, node],
|
||||
);
|
||||
|
||||
// Смена сервера: сбрасываем ноду и выбираем сеть, привязанную к серверу (если есть).
|
||||
const handleServerChange = (v: string) => {
|
||||
setServerId(v ?? '');
|
||||
setNode('');
|
||||
const nid = autoPickNetworkId(ipamNetworks, v ? Number(v) : undefined, undefined);
|
||||
setIpamNetworkId(nid ? String(nid) : '');
|
||||
setIpamPoolId('');
|
||||
};
|
||||
|
||||
// Смена ноды: автоматически выбираем сеть, из пула которой выдаётся IP.
|
||||
const handleNodeChange = (v: string) => {
|
||||
setNode(v);
|
||||
const nid = autoPickNetworkId(ipamNetworks, serverIdNum, v || undefined);
|
||||
setIpamNetworkId(nid ? String(nid) : '');
|
||||
setIpamPoolId('');
|
||||
};
|
||||
|
||||
// Хранилища ноды под rootfs LXC (для выбора вместо ручного ввода).
|
||||
const { data: storages = [] } = useLXCStorages(
|
||||
serverId ? Number(serverId) : undefined,
|
||||
@@ -160,7 +187,7 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.server')}</Label>
|
||||
<Select value={serverId} onValueChange={(v) => setServerId(v ?? '')}>
|
||||
<Select value={serverId} onValueChange={(v) => handleServerChange(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder={t('appstore.select_server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((s) => (
|
||||
@@ -218,7 +245,15 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
<NumField label={t('appstore.disk')} value={disk} onChange={setDisk} />
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.node')}</Label>
|
||||
<Input value={node} onChange={(e) => setNode(e.target.value)} />
|
||||
<Select value={node || '__auto__'} onValueChange={(v) => handleNodeChange(v && v !== '__auto__' ? v : '')} disabled={!serverId}>
|
||||
<SelectTrigger><SelectValue placeholder={t('wizard.auto_select', 'Авто')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">{t('wizard.auto_select', 'Авто')}</SelectItem>
|
||||
{nodes.map((n) => (
|
||||
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.storage')}</Label>
|
||||
@@ -249,7 +284,7 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dhcp">{t('appstore.ipam_dhcp')}</SelectItem>
|
||||
{ipamNetworks.map((n) => (
|
||||
{availableNetworks.map((n) => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
{n.name} ({n.network})
|
||||
</SelectItem>
|
||||
|
||||
@@ -9,9 +9,10 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
import { autoPickNetworkId } from '@/features/ipam/network-scope';
|
||||
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
|
||||
import { useProfile, useMyQuota } from '@/hooks/use-settings';
|
||||
@@ -100,6 +101,15 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
|
||||
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id, undefined, 'rootdir');
|
||||
const { data: vmStorages = [] } = useLXCStorages(selectedServer?.id, undefined, 'images');
|
||||
const { data: nodesData } = useNodes(selectedServer?.id ?? 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
|
||||
// Смена ноды (VM): выбираем ноду и авто-подбираем IPAM-сеть, привязанную к ней.
|
||||
const handleVMNodeChange = (node: string) => {
|
||||
setSelectedNode(node);
|
||||
const nid = autoPickNetworkId(networks, selectedServer?.id, node || undefined);
|
||||
setConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||
};
|
||||
|
||||
// ── SSH Keys & ownership ──────────────────────────────────────────────────
|
||||
const { data: profile } = useProfile();
|
||||
@@ -277,7 +287,12 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<Card
|
||||
key={srv.id}
|
||||
className={`cursor-pointer transition-colors hover:border-primary/50 ${selectedServer?.id === srv.id ? 'border-primary bg-primary/5' : ''}`}
|
||||
onClick={() => { setSelectedServer(srv); setSelectedLXCTemplate(null); setSelectedTemplate(null); }}
|
||||
onClick={() => {
|
||||
setSelectedServer(srv); setSelectedLXCTemplate(null); setSelectedTemplate(null);
|
||||
setSelectedNode('');
|
||||
setConfig(p => ({ ...p, ipam_network_id: null }));
|
||||
setLxcConfig(p => ({ ...p, ipam_network_id: null }));
|
||||
}}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
@@ -393,7 +408,11 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<Card
|
||||
key={tpl.volid}
|
||||
className={`cursor-pointer transition-colors hover:border-success/50 ${selectedLXCTemplate?.volid === tpl.volid ? 'border-success bg-success/5' : ''}`}
|
||||
onClick={() => setSelectedLXCTemplate(tpl)}
|
||||
onClick={() => {
|
||||
setSelectedLXCTemplate(tpl);
|
||||
const nid = autoPickNetworkId(networks, selectedServer?.id, tpl.node || undefined);
|
||||
setLxcConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -432,7 +451,15 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.target_node')}</Label>
|
||||
<Input value={selectedNode} onChange={e => setSelectedNode(e.target.value)} placeholder={t('wizard.auto_select')} />
|
||||
<Select value={selectedNode || '__auto__'} onValueChange={v => handleVMNodeChange(v && v !== '__auto__' ? v : '')}>
|
||||
<SelectTrigger><SelectValue placeholder={t('wizard.auto_select')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">{t('wizard.auto_select')}</SelectItem>
|
||||
{nodes.map(n => (
|
||||
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useUpdateNetwork, useDeleteNetwork, useCreatePool, useUpdatePool, useDeletePool, useIPAMIPMap,
|
||||
} from '@/hooks/use-ipam';
|
||||
import type { IPAMNetwork, IPAMPool } from '@/types';
|
||||
import { ServerNodeSelect } from './ServerNodeSelect';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
@@ -73,6 +74,7 @@ export default function IPAMNetworkDetailPage() {
|
||||
<InfoRow label={t('common.vlan')} value={network.vlan_id ? String(network.vlan_id) : '—'} />
|
||||
<InfoRow label={t('common.dns')} value={network.dns_primary || '—'} />
|
||||
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
|
||||
<InfoRow label={t('appstore.node')} value={network.proxmox_node || '—'} />
|
||||
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
|
||||
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}
|
||||
</CardContent>
|
||||
@@ -138,6 +140,8 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
|
||||
dns_secondary: network.dns_secondary ?? '',
|
||||
dns_domain: network.dns_domain ?? '',
|
||||
proxmox_bridge: network.proxmox_bridge ?? '',
|
||||
proxmox_server_id: network.proxmox_server_id as number | undefined,
|
||||
proxmox_node: network.proxmox_node as string | undefined,
|
||||
is_active: network.is_active,
|
||||
description: network.description ?? '',
|
||||
});
|
||||
@@ -153,6 +157,9 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
|
||||
dns_secondary: form.dns_secondary || undefined,
|
||||
dns_domain: form.dns_domain || undefined,
|
||||
proxmox_bridge: form.proxmox_bridge || undefined,
|
||||
// null явно очищает привязку на бэкенде (undefined был бы исключён из update)
|
||||
proxmox_server_id: (form.proxmox_server_id ?? null) as number | undefined,
|
||||
proxmox_node: (form.proxmox_node ?? null) as string | undefined,
|
||||
is_active: form.is_active,
|
||||
description: form.description || undefined,
|
||||
},
|
||||
@@ -190,6 +197,11 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
|
||||
<Field label={`${t('common.dns')} 2`}><Input value={form.dns_secondary} onChange={e => setForm(p => ({ ...p, dns_secondary: e.target.value }))} /></Field>
|
||||
<Field label={t('ipam.dns_domain')}><Input value={form.dns_domain} onChange={e => setForm(p => ({ ...p, dns_domain: e.target.value }))} /></Field>
|
||||
<Field label={t('ipam.bridge')}><Input value={form.proxmox_bridge} onChange={e => setForm(p => ({ ...p, proxmox_bridge: e.target.value }))} placeholder="vmbr0" /></Field>
|
||||
<ServerNodeSelect
|
||||
serverId={form.proxmox_server_id}
|
||||
node={form.proxmox_node}
|
||||
onChange={({ serverId, node }) => setForm(p => ({ ...p, proxmox_server_id: serverId, proxmox_node: node }))}
|
||||
/>
|
||||
<Field label={t('ipam.description')} full><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></Field>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" checked={form.is_active} onChange={e => setForm(p => ({ ...p, is_active: e.target.checked }))} />
|
||||
|
||||
@@ -9,13 +9,19 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useIPAMNetworks, useCreateNetwork } from '@/hooks/use-ipam';
|
||||
import { ServerNodeSelect } from './ServerNodeSelect';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const emptyForm = {
|
||||
name: '', network: '', gateway: '', description: '', proxmox_bridge: '',
|
||||
proxmox_server_id: undefined as number | undefined, proxmox_node: undefined as string | undefined,
|
||||
};
|
||||
|
||||
export default function IPAMNetworksPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', network: '', gateway: '', description: '' });
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const { data: networks = [], isLoading } = useIPAMNetworks();
|
||||
const createNet = useCreateNetwork();
|
||||
@@ -26,8 +32,16 @@ export default function IPAMNetworksPage() {
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
createNet.mutate(form, {
|
||||
onSuccess: () => { toast.success(t('ipam.network_created')); setDialogOpen(false); setForm({ name: '', network: '', gateway: '', description: '' }); },
|
||||
createNet.mutate({
|
||||
name: form.name,
|
||||
network: form.network,
|
||||
gateway: form.gateway || undefined,
|
||||
description: form.description || undefined,
|
||||
proxmox_bridge: form.proxmox_bridge || undefined,
|
||||
proxmox_server_id: form.proxmox_server_id,
|
||||
proxmox_node: form.proxmox_node,
|
||||
}, {
|
||||
onSuccess: () => { toast.success(t('ipam.network_created')); setDialogOpen(false); setForm(emptyForm); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
@@ -85,6 +99,14 @@ export default function IPAMNetworksPage() {
|
||||
<div><Label>{t('common.name')}</Label><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
|
||||
<div><Label>CIDR</Label><Input value={form.network} onChange={e => setForm(p => ({ ...p, network: e.target.value }))} placeholder="10.0.0.0/24" /></div>
|
||||
<div><Label>Gateway</Label><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} placeholder="10.0.0.1" /></div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ServerNodeSelect
|
||||
serverId={form.proxmox_server_id}
|
||||
node={form.proxmox_node}
|
||||
onChange={({ serverId, node }) => setForm(p => ({ ...p, proxmox_server_id: serverId, proxmox_node: node }))}
|
||||
/>
|
||||
</div>
|
||||
<div><Label>{t('ipam.bridge')}</Label><Input value={form.proxmox_bridge} onChange={e => setForm(p => ({ ...p, proxmox_bridge: e.target.value }))} placeholder="vmbr0" /></div>
|
||||
<div><Label>{t('ipam.description')}</Label><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></div>
|
||||
<Button onClick={handleCreate} disabled={!form.name || !form.network || createNet.isPending} className="w-full">{t('common.create')}</Button>
|
||||
</div>
|
||||
|
||||
55
frontend/src/features/ipam/ServerNodeSelect.tsx
Normal file
55
frontend/src/features/ipam/ServerNodeSelect.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
interface Props {
|
||||
serverId?: number;
|
||||
node?: string;
|
||||
onChange: (v: { serverId?: number; node?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пара селектов «Сервер Proxmox» + «Нода» для привязки IPAM-сети к конкретной ноде.
|
||||
* Смена сервера сбрасывает выбранную ноду. Ноды подтягиваются для выбранного сервера.
|
||||
*/
|
||||
export function ServerNodeSelect({ serverId, node, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: servers = [] } = useServers();
|
||||
const { data: nodesData } = useNodes(serverId ?? 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1 block">{t('ipam.server')}</Label>
|
||||
<Select
|
||||
value={serverId ? String(serverId) : NONE}
|
||||
onValueChange={(v) => onChange({ serverId: v === NONE ? undefined : Number(v), node: undefined })}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder={t('ipam.any_server', '— не привязано —')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t('ipam.any_server', '— не привязано —')}</SelectItem>
|
||||
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block">{t('appstore.node')}</Label>
|
||||
<Select
|
||||
value={node || NONE}
|
||||
onValueChange={(v) => onChange({ serverId, node: v && v !== NONE ? v : undefined })}
|
||||
disabled={!serverId}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder={t('ipam.any_node', '— любая нода —')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t('ipam.any_node', '— любая нода —')}</SelectItem>
|
||||
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
frontend/src/features/ipam/network-scope.ts
Normal file
44
frontend/src/features/ipam/network-scope.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { IPAMNetwork } from '@/types';
|
||||
|
||||
/**
|
||||
* Сети, релевантные выбранным серверу/ноде (для выпадающих списков в визардах).
|
||||
* Показываем активные сети, которые:
|
||||
* - привязаны к выбранному серверу ИЛИ глобальные (без привязки к серверу);
|
||||
* - привязаны к выбранной ноде ИЛИ без привязки к конкретной ноде.
|
||||
*/
|
||||
export function scopedNetworks(
|
||||
networks: IPAMNetwork[],
|
||||
serverId?: number | null,
|
||||
node?: string | null,
|
||||
): IPAMNetwork[] {
|
||||
return networks.filter((n) =>
|
||||
n.is_active &&
|
||||
(!n.proxmox_server_id || !serverId || n.proxmox_server_id === serverId) &&
|
||||
(!n.proxmox_node || !node || n.proxmox_node === node),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Автовыбор сети исходя из выбранной ноды: возвращает id IPAM-сети, из пула
|
||||
* которой нужно выдать IP, либо null (тогда fallback на DHCP/ручной ввод).
|
||||
*
|
||||
* Правило (детерминированное):
|
||||
* 1. сеть, привязанная именно к этой ноде выбранного сервера;
|
||||
* 2. иначе — сеть, привязанная к серверу без привязки к ноде;
|
||||
* 3. иначе — null (глобальные сети автоматически НЕ выбираем, чтобы не выдать
|
||||
* адрес из чужой подсети).
|
||||
*/
|
||||
export function autoPickNetworkId(
|
||||
networks: IPAMNetwork[],
|
||||
serverId?: number | null,
|
||||
node?: string | null,
|
||||
): number | null {
|
||||
if (!serverId) return null;
|
||||
const active = networks.filter((n) => n.is_active && n.proxmox_server_id === serverId);
|
||||
if (node) {
|
||||
const nodeBound = active.find((n) => n.proxmox_node === node);
|
||||
if (nodeBound) return nodeBound.id;
|
||||
}
|
||||
const serverOnly = active.find((n) => !n.proxmox_node);
|
||||
return serverOnly ? serverOnly.id : null;
|
||||
}
|
||||
Reference in New Issue
Block a user