Files
pvemanager/frontend/src/hooks/use-ipam.ts
Dilshod Rasulov 6796903712
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m46s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 3m7s
feat(ipam): several IP addresses per instance
A guest was implicitly limited to a single allocation: lookups were
"(server, vmid) -> one row", release_ip_by_vmid freed one address, and
the link tool skipped guests that already had one. An instance can now
hold a primary address plus any number of aliases, managed from the
Network tab of the instance and visible on the IPAM pages.

Schema and service:
- ipam_allocations gains is_primary, assignment_kind, target_interface
  and apply_* columns; migration 43 marks existing rows as primary
- find_allocations_by_resource/set_primary; release_ip_by_vmid now frees
  every address of a deleted guest instead of just the first one
- next-available search may scan the whole subnet when a network has no
  auto-assign pool, skipping node and cached guest addresses so it never
  hands out an address that is already live

Applying to the guest (services/guest_ip_service.py):
- reuses script_engine, so LXC goes through pct exec and VMs through the
  guest agent; the shell script synchronises the whole alias list of an
  interface, which makes add, remove and retry the same code path
- persists via ifupdown, netplan, NetworkManager or sysconfig and never
  restarts guest networking; an unknown stack degrades to runtime_only
  and an unreachable guest keeps the allocation as pending

API and UI:
- /ipam/api/guests/{server_id}/{vmid}/addresses: list, add, apply,
  primary, delete; allocations list accepts guest filters
- instance list exposes ips[] alongside the primary ip
- addresses card on the Network tab, "+N" badge in the IP column,
  primary badge in the allocations table

Fixes found along the way:
- link-allocations registers every address of a guest, carries the MAC
  over, refuses to take an address away from another live guest and no
  longer matches allocations by resource name (which leaked an address
  between same-named guests on different servers)
- IPAM history no longer fails to serialise datetime values, which used
  to abort re-linking with a 500
2026-08-21 23:48:20 +05:00

390 lines
13 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type {
IPAMNetwork,
IPAMPool,
IPAMAllocation,
GuestAddress,
GuestAddressApplyStatus,
} from '@/types';
export const ipamKeys = {
networks: ['ipam-networks'] as const,
network: (id: number) => ['ipam-network', id] as const,
networkStats: (id: number) => ['ipam-network-stats', id] as const,
ipMap: (id: number) => ['ipam-ip-map', id] as const,
pools: (networkId?: number) => ['ipam-pools', networkId] as const,
allocations: (params?: Record<string, unknown>) => ['ipam-allocations', params] as const,
history: (params?: Record<string, unknown>) => ['ipam-history', params] as const,
summary: ['ipam-summary'] as const,
conflicts: ['ipam-conflicts'] as const,
};
export function useIPAMNetworks() {
return useQuery({
queryKey: ipamKeys.networks,
queryFn: () => apiClient.get<IPAMNetwork[]>('/ipam/api/networks'),
});
}
export function useIPAMNetwork(id: number) {
return useQuery({
queryKey: ipamKeys.network(id),
queryFn: () => apiClient.get<IPAMNetwork>(`/ipam/api/networks/${id}`),
enabled: id > 0,
});
}
export function useIPAMNetworkStats(id: number) {
return useQuery({
queryKey: ipamKeys.networkStats(id),
queryFn: () => apiClient.get<{ total_ips: number; allocated_ips: number; reserved_ips: number; available_ips: number; utilization_percent: number }>(`/ipam/api/networks/${id}/stats`),
enabled: id > 0,
});
}
export interface IPAMIPMapEntry {
ip_address: string;
status: 'gateway' | 'allocated' | 'reserved' | 'available' | string;
resource_name?: string | null;
resource_type?: string | null;
hostname?: string | null;
proxmox_vmid?: number | null;
is_gateway?: boolean;
allocation_id?: number | null;
last_seen?: string | null;
}
export function useIPAMIPMap(id: number) {
return useQuery({
queryKey: ipamKeys.ipMap(id),
queryFn: () => apiClient.get<{ network_id: number; ip_map: IPAMIPMapEntry[] }>(`/ipam/api/networks/${id}/ip-map`),
enabled: id > 0,
});
}
export function useIPAMPools(networkId?: number) {
const qs = networkId ? `?network_id=${networkId}` : '';
return useQuery({
queryKey: ipamKeys.pools(networkId),
queryFn: () => apiClient.get<IPAMPool[]>(`/ipam/api/pools${qs}`),
});
}
export function useIPAMAllocations(params?: { network_id?: number; pool_id?: number; status?: string; search?: string; limit?: number; offset?: number }) {
const qs = new URLSearchParams();
if (params?.network_id) qs.set('network_id', String(params.network_id));
if (params?.pool_id) qs.set('pool_id', String(params.pool_id));
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.offset) qs.set('offset', String(params.offset));
return useQuery({
queryKey: ipamKeys.allocations(params),
queryFn: () => apiClient.get<IPAMAllocation[]>(`/ipam/api/allocations?${qs}`),
});
}
export function useIPAMHistory(params?: { network_id?: number; ip_address?: string; action?: string; limit?: number }) {
const qs = new URLSearchParams();
if (params?.network_id) qs.set('network_id', String(params.network_id));
if (params?.ip_address) qs.set('ip_address', params.ip_address);
if (params?.action) qs.set('action', params.action);
if (params?.limit) qs.set('limit', String(params.limit));
return useQuery({
queryKey: ipamKeys.history(params),
queryFn: () => apiClient.get<any[]>(`/ipam/api/history?${qs}`),
});
}
export function useIPAMSummary() {
return useQuery({
queryKey: ipamKeys.summary,
queryFn: () => apiClient.get<Record<string, unknown>>('/ipam/api/summary'),
});
}
export function useCreateNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMNetwork>) => apiClient.post<IPAMNetwork>('/ipam/api/networks', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
});
}
export function useUpdateNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<IPAMNetwork> & { id: number }) =>
apiClient.put<IPAMNetwork>(`/ipam/api/networks/${id}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-network'] });
qc.invalidateQueries({ queryKey: ipamKeys.networks });
},
});
}
export function useDeleteNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
apiClient.delete(`/ipam/api/networks/${id}${force ? '?force=true' : ''}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
});
}
// ==================== Pools ====================
export function useCreatePool() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMPool>) => apiClient.post<IPAMPool>('/ipam/api/pools', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-pools'] });
qc.invalidateQueries({ queryKey: ['ipam-network-stats'] });
qc.invalidateQueries({ queryKey: ipamKeys.summary });
},
});
}
export function useUpdatePool() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<IPAMPool> & { id: number }) =>
apiClient.put<IPAMPool>(`/ipam/api/pools/${id}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-pools'] });
qc.invalidateQueries({ queryKey: ['ipam-network-stats'] });
},
});
}
export function useDeletePool() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/pools/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-pools'] });
qc.invalidateQueries({ queryKey: ['ipam-network-stats'] });
},
});
}
export function useCreateAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMAllocation>) => apiClient.post<IPAMAllocation>('/ipam/api/allocations', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-allocations'] });
qc.invalidateQueries({ queryKey: ipamKeys.summary });
qc.invalidateQueries({ queryKey: ['ipam-network-stats'] });
},
});
}
export function useUpdateAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<IPAMAllocation> & { id: number }) =>
apiClient.put<IPAMAllocation>(`/ipam/api/allocations/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
});
}
export function useDeleteAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/allocations/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-allocations'] });
qc.invalidateQueries({ queryKey: ipamKeys.summary });
qc.invalidateQueries({ queryKey: ['ipam-network-stats'] });
},
});
}
// ==================== Tools / maintenance ====================
export interface IPAMConflict { ip_address: string; [k: string]: unknown }
export interface IPAMOrphan {
id: number; ip_address: string; resource_name?: string; resource_type?: string;
proxmox_vmid?: number; proxmox_server_id?: number; reason: string;
}
export interface IPAMUnlinked {
id: number | null; ip_address: string; resource_name?: string; resource_type?: string;
can_link: boolean; suggested_vmid?: number; suggested_server_id?: number; suggested_server_name?: string | null;
}
export function useIPAMConflicts() {
return useQuery({
queryKey: ipamKeys.conflicts,
queryFn: () => apiClient.get<{ conflicts: IPAMConflict[]; count: number }>('/ipam/api/conflicts'),
});
}
export function useIPAMOrphans() {
return useQuery({
queryKey: ['ipam-orphans'],
queryFn: () => apiClient.get<{ orphans: IPAMOrphan[]; count: number }>('/ipam/api/orphans'),
});
}
export function useIPAMUnlinked() {
return useQuery({
queryKey: ['ipam-unlinked'],
queryFn: () => apiClient.get<{ unlinked: IPAMUnlinked[]; count: number; linkable_count: number }>('/ipam/api/unlinked'),
});
}
// Invalidate everything that orphan/link/auto operations can affect.
function useInvalidateIPAM() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['ipam-allocations'] });
qc.invalidateQueries({ queryKey: ['ipam-orphans'] });
qc.invalidateQueries({ queryKey: ['ipam-unlinked'] });
qc.invalidateQueries({ queryKey: ipamKeys.summary });
qc.invalidateQueries({ queryKey: ipamKeys.conflicts });
qc.invalidateQueries({ queryKey: ipamKeys.networks });
};
}
export function useCleanupOrphans() {
const invalidate = useInvalidateIPAM();
return useMutation({
mutationFn: () => apiClient.post<{ released: string[]; released_count: number; errors: unknown[]; error_count: number }>('/ipam/api/cleanup-orphans', {}),
onSuccess: () => invalidate(),
});
}
export interface IPAMLinkSkipped {
ip_address: string;
resource_name: string;
reason: string;
}
export function useLinkAllocations() {
const invalidate = useInvalidateIPAM();
return useMutation({
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
apiClient.post<{ linked: unknown[]; not_found: IPAMLinkSkipped[] }>('/ipam/api/link-allocations', body ?? {}),
onSuccess: () => invalidate(),
});
}
export function useAutoAllocate() {
const invalidate = useInvalidateIPAM();
return useMutation({
mutationFn: (data: { network_id: number; pool_id?: number; resource_type?: string; resource_name?: string; hostname?: string; notes?: string }) =>
apiClient.post<IPAMAllocation>('/ipam/api/allocations/auto', data),
onSuccess: () => invalidate(),
});
}
export function useNextAvailableIP() {
return useMutation({
mutationFn: ({ networkId, poolId }: { networkId: number; poolId?: number }) => {
const qs = poolId ? `?pool_id=${poolId}` : '';
return apiClient.get<{ next_available_ip: string }>(`/ipam/api/allocations/next-available/${networkId}${qs}`);
},
});
}
export function useIPHistoryLookup() {
return useMutation({
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
});
}
// ==================== Адреса инстанса (несколько IP на гостя) ====================
export interface GuestAddressApplyResult {
address: GuestAddress;
applied: boolean;
apply_status: GuestAddressApplyStatus;
persist?: string | null;
error?: string | null;
}
export const guestAddressKey = (serverId: number, vmid: number) =>
['guest-addresses', serverId, vmid] as const;
export function useGuestAddresses(serverId: number, vmid: number, enabled = true) {
return useQuery({
queryKey: guestAddressKey(serverId, vmid),
queryFn: () =>
apiClient.get<{ addresses: GuestAddress[]; count: number }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
),
enabled: enabled && serverId > 0 && vmid > 0,
});
}
/** Инвалидация адресов гостя + всего, что показывает IP инстанса. */
function useInvalidateGuestAddresses(serverId: number, vmid: number) {
const qc = useQueryClient();
const invalidateIPAM = useInvalidateIPAM();
return () => {
qc.invalidateQueries({ queryKey: guestAddressKey(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['instances'] });
qc.invalidateQueries({ queryKey: ['vms'] });
invalidateIPAM();
};
}
export function useAddGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (body: {
network_id: number;
pool_id?: number;
ip_address?: string;
target_interface?: string;
make_primary?: boolean;
hostname?: string;
notes?: string;
}) =>
apiClient.post<GuestAddressApplyResult>(
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
body,
),
onSuccess: () => invalidate(),
});
}
export function useApplyGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.post<GuestAddressApplyResult>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/apply`,
{},
),
onSuccess: () => invalidate(),
});
}
export function useSetPrimaryGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.post<{ address: GuestAddress }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/primary`,
{},
),
onSuccess: () => invalidate(),
});
}
export function useRemoveGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.delete<{ released: string; removed_from_guest: boolean; error?: string | null }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}`,
),
onSuccess: () => invalidate(),
});
}