feat(ipam): привязка сетей к серверу/ноде и автовыбор сети в визардах
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:
markmorado
2026-07-08 20:09:17 +05:00
parent 23d3f95f54
commit a6eeae3dd7
6 changed files with 206 additions and 11 deletions

View File

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

View File

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

View File

@@ -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 }))} />

View File

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

View 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>
</>
);
}

View 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;
}