feat(ipam): add IPAM tools page

New /ipam/tools page surfacing the existing maintenance endpoints: summary
with recent activity, conflict detection, orphan allocations (with cleanup),
unlinked VMs (scan & link), auto-allocation + next-available lookup, and
per-IP history. Linked from the IPAM dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 22:35:27 +05:00
parent fb1267c533
commit fdd3e03c7d
6 changed files with 451 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { Network, Globe, AlertTriangle, Activity } from 'lucide-react';
import { Network, Globe, AlertTriangle, Activity, Wrench } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useIPAMSummary, useIPAMNetworks } from '@/hooks/use-ipam';
@@ -13,7 +13,12 @@ export default function IPAMDashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
<Link to="/ipam/tools" className="flex items-center gap-1 text-sm text-blue-400 hover:underline">
<Wrench className="h-4 w-4" />{t('ipam.tools')}
</Link>
</div>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">

View File

@@ -0,0 +1,315 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, AlertTriangle, Ghost, Unlink, Wand2, Search, Link2, Trash2, History } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
useIPAMNetworks, useIPAMSummary,
useIPAMConflicts, useIPAMOrphans, useIPAMUnlinked,
useCleanupOrphans, useLinkAllocations, useAutoAllocate, useNextAvailableIP, useIPHistoryLookup,
} from '@/hooks/use-ipam';
import { toast } from 'sonner';
export default function IPAMToolsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button render={<Link to="/ipam" />} variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
<h1 className="text-2xl font-bold">{t('ipam.tools')}</h1>
</div>
<SummaryCard />
<AllocateCard />
<ConflictsCard />
<OrphansCard />
<UnlinkedCard />
<IPHistoryCard />
</div>
);
}
function SummaryCard() {
const { t } = useTranslation();
const { data } = useIPAMSummary();
const s = data as Record<string, unknown> | undefined;
const recent = (s?.recent_allocations as Array<{ ip: string; resource?: string; by?: string; at?: string }>) || [];
const stat = (k: string) => Number(s?.[k] ?? 0);
return (
<Card>
<CardHeader><CardTitle className="text-lg">{t('ipam.summary')}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{([
['networks_count', t('ipam.networks_count')],
['pools_count', t('ipam.pools')],
['total_allocated', t('ipam.total_allocated')],
['total_reserved', t('ipam.total_reserved')],
['vm_count', 'VM'],
['lxc_count', 'LXC'],
] as const).map(([k, label]) => (
<div key={k} className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{stat(k)}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
))}
</div>
{recent.length > 0 && (
<div>
<p className="mb-1 text-sm font-medium">{t('ipam.recent_activity')}</p>
<div className="rounded-lg border divide-y text-sm">
{recent.map((r, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<span className="font-mono">{r.ip}</span>
<span className="text-muted-foreground">{r.resource || ''}</span>
<span className="ml-auto text-xs text-muted-foreground">{r.by} · {r.at ? new Date(r.at).toLocaleString() : ''}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
function AllocateCard() {
const { t } = useTranslation();
const { data: networks = [] } = useIPAMNetworks();
const autoAllocate = useAutoAllocate();
const nextAvail = useNextAvailableIP();
const [networkId, setNetworkId] = useState('');
const [resourceName, setResourceName] = useState('');
const [hostname, setHostname] = useState('');
const handleNext = () => {
if (!networkId) { toast.error(t('ipam.select_network')); return; }
nextAvail.mutate({ networkId: Number(networkId) }, {
onError: (e: Error) => toast.error(e.message),
});
};
const handleAllocate = () => {
if (!networkId) { toast.error(t('ipam.select_network')); return; }
autoAllocate.mutate(
{ network_id: Number(networkId), resource_name: resourceName || undefined, hostname: hostname || undefined },
{
onSuccess: (a) => { toast.success(`${t('ipam.allocated')}: ${a.ip_address}`); setResourceName(''); setHostname(''); },
onError: (e: Error) => toast.error(e.message),
},
);
};
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><Wand2 className="h-5 w-5" />{t('ipam.auto_allocate')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<Label>{t('ipam.network')}</Label>
<Select value={networkId} onValueChange={v => { if (v) setNetworkId(v); }}>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('ipam.select_network')} /></SelectTrigger>
<SelectContent>
{networks.map(n => <SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('ipam.resource')}</Label>
<Input value={resourceName} onChange={e => setResourceName(e.target.value)} className="mt-1" placeholder="web-01" />
</div>
<div>
<Label>Hostname</Label>
<Input value={hostname} onChange={e => setHostname(e.target.value)} className="mt-1" />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={handleNext} disabled={nextAvail.isPending || !networkId}>
<Search className="mr-1 h-4 w-4" />{t('ipam.next_available')}
</Button>
{nextAvail.data && <Badge variant="secondary" className="font-mono">{nextAvail.data.next_available_ip}</Badge>}
<Button size="sm" onClick={handleAllocate} disabled={autoAllocate.isPending || !networkId} className="ml-auto">
<Wand2 className="mr-1 h-4 w-4" />{t('ipam.allocate_next')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('ipam.pool_required_hint')}</p>
</CardContent>
</Card>
);
}
function ConflictsCard() {
const { t } = useTranslation();
const { data, isLoading } = useIPAMConflicts();
const conflicts = data?.conflicts ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertTriangle className="h-5 w-5" />{t('ipam.conflicts')}
{data && <Badge variant={data.count > 0 ? 'destructive' : 'secondary'}>{data.count}</Badge>}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? <p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
: conflicts.length === 0 ? <p className="py-4 text-center text-sm text-muted-foreground">{t('ipam.no_conflicts')}</p>
: (
<div className="rounded-lg border divide-y text-sm">
{conflicts.map((c, i) => (
<div key={i} className="px-3 py-2 font-mono">{c.ip_address} — {JSON.stringify(c)}</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function OrphansCard() {
const { t } = useTranslation();
const { data, isLoading } = useIPAMOrphans();
const cleanup = useCleanupOrphans();
const orphans = data?.orphans ?? [];
const handleCleanup = () => {
if (!confirm(t('ipam.cleanup_confirm'))) return;
cleanup.mutate(undefined, {
onSuccess: (r) => toast.success(`${t('ipam.released')}: ${r.released_count}`),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-lg">
<Ghost className="h-5 w-5" />{t('ipam.orphans')}
{data && <Badge variant={data.count > 0 ? 'destructive' : 'secondary'}>{data.count}</Badge>}
</CardTitle>
{orphans.length > 0 && (
<Button size="sm" variant="outline" onClick={handleCleanup} disabled={cleanup.isPending}>
<Trash2 className="mr-1 h-4 w-4" />{t('ipam.cleanup_orphans')}
</Button>
)}
</CardHeader>
<CardContent>
{isLoading ? <p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
: orphans.length === 0 ? <p className="py-4 text-center text-sm text-muted-foreground">{t('ipam.no_orphans')}</p>
: (
<Table>
<TableHeader><TableRow>
<TableHead>IP</TableHead><TableHead>{t('ipam.resource')}</TableHead>
<TableHead>VMID</TableHead><TableHead>{t('common.status')}</TableHead>
</TableRow></TableHeader>
<TableBody>
{orphans.map(o => (
<TableRow key={o.id}>
<TableCell className="font-mono">{o.ip_address}</TableCell>
<TableCell>{o.resource_name || '—'}</TableCell>
<TableCell>{o.proxmox_vmid ?? '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{o.reason}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
function UnlinkedCard() {
const { t } = useTranslation();
const { data, isLoading } = useIPAMUnlinked();
const link = useLinkAllocations();
const unlinked = data?.unlinked ?? [];
const handleLink = () => {
link.mutate(undefined, {
onSuccess: (r) => toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-lg">
<Unlink className="h-5 w-5" />{t('ipam.unlinked')}
{data && <Badge variant="secondary">{data.count}</Badge>}
</CardTitle>
{unlinked.length > 0 && (
<Button size="sm" variant="outline" onClick={handleLink} disabled={link.isPending}>
<Link2 className="mr-1 h-4 w-4" />{t('ipam.link_allocations')}
</Button>
)}
</CardHeader>
<CardContent>
{isLoading ? <p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
: unlinked.length === 0 ? <p className="py-4 text-center text-sm text-muted-foreground">{t('ipam.no_unlinked')}</p>
: (
<Table>
<TableHeader><TableRow>
<TableHead>{t('ipam.resource')}</TableHead><TableHead>IP</TableHead>
<TableHead>VMID</TableHead><TableHead>{t('nodes.title')}</TableHead>
</TableRow></TableHeader>
<TableBody>
{unlinked.map((u, i) => (
<TableRow key={i}>
<TableCell>{u.resource_name || '—'}</TableCell>
<TableCell className="font-mono">{u.ip_address}</TableCell>
<TableCell>{u.suggested_vmid ?? '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{u.suggested_server_name || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
function IPHistoryCard() {
const { t } = useTranslation();
const lookup = useIPHistoryLookup();
const [ip, setIp] = useState('');
const rows = (lookup.data as Array<Record<string, unknown>>) || [];
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><History className="h-5 w-5" />{t('ipam.ip_history')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Input value={ip} onChange={e => setIp(e.target.value)} placeholder="10.0.0.10" className="max-w-xs font-mono"
onKeyDown={e => { if (e.key === 'Enter' && ip) lookup.mutate(ip); }} />
<Button size="sm" variant="outline" onClick={() => ip && lookup.mutate(ip)} disabled={lookup.isPending || !ip}>
<Search className="mr-1 h-4 w-4" />{t('common.search')}
</Button>
</div>
{lookup.isSuccess && (
rows.length === 0 ? <p className="text-sm text-muted-foreground">{t('common.no_data')}</p>
: (
<div className="rounded-lg border divide-y text-sm">
{rows.map((h, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<Badge variant="outline" className="text-[10px]">{String(h.action ?? '')}</Badge>
<span className="text-muted-foreground">{String(h.resource_name ?? '')}</span>
<span className="ml-auto text-xs text-muted-foreground">
{String(h.performed_by ?? '')} · {h.performed_at ? new Date(String(h.performed_at)).toLocaleString() : ''}
</span>
</div>
))}
</div>
)
)}
</CardContent>
</Card>
);
}

View File

@@ -129,3 +129,90 @@ export function useDeleteAllocation() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
});
}
// ==================== 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 function useLinkAllocations() {
const invalidate = useInvalidateIPAM();
return useMutation({
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
apiClient.post<{ linked: unknown[]; not_found: unknown[] }>('/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)}`),
});
}

View File

@@ -488,6 +488,26 @@
}
},
"ipam": {
"tools": "Tools",
"summary": "Summary",
"recent_activity": "Recent activity",
"select_network": "Select network",
"auto_allocate": "Auto-allocate IP",
"next_available": "Next available",
"allocate_next": "Allocate next IP",
"conflicts": "Conflicts",
"no_conflicts": "No conflicts detected",
"orphans": "Orphan allocations",
"no_orphans": "No orphan allocations",
"cleanup_orphans": "Clean up",
"cleanup_confirm": "Release all orphan IP allocations?",
"released": "Released",
"unlinked": "Unlinked VMs",
"no_unlinked": "All VMs are linked",
"link_allocations": "Scan & link",
"linked": "Linked",
"ip_history": "IP history lookup",
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
"dashboard": "IPAM Dashboard",
"networks": "Networks",
"networks_count": "Networks",

View File

@@ -488,6 +488,26 @@
}
},
"ipam": {
"tools": "Инструменты",
"summary": "Сводка",
"recent_activity": "Недавняя активность",
"select_network": "Выберите сеть",
"auto_allocate": "Авто-выделение IP",
"next_available": "Следующий свободный",
"allocate_next": "Выделить следующий IP",
"conflicts": "Конфликты",
"no_conflicts": "Конфликтов не обнаружено",
"orphans": "Осиротевшие аллокации",
"no_orphans": "Осиротевших аллокаций нет",
"cleanup_orphans": "Очистить",
"cleanup_confirm": "Освободить все осиротевшие IP-аллокации?",
"released": "Освобождено",
"unlinked": "Несвязанные VM",
"no_unlinked": "Все VM связаны",
"link_allocations": "Сканировать и связать",
"linked": "Связано",
"ip_history": "История по IP",
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
"dashboard": "IPAM Дашборд",
"networks": "Сети",
"networks_count": "Сетей",

View File

@@ -25,6 +25,7 @@ const IPAMNetworksPage = lazy(() => import('./features/ipam/IPAMNetworksPage.tsx
const IPAMNetworkDetailPage = lazy(() => import('./features/ipam/IPAMNetworkDetailPage.tsx'));
const IPAMAllocationsPage = lazy(() => import('./features/ipam/IPAMAllocationsPage.tsx'));
const IPAMHistoryPage = lazy(() => import('./features/ipam/IPAMHistoryPage.tsx'));
const IPAMToolsPage = lazy(() => import('./features/ipam/IPAMToolsPage.tsx'));
const UsersPage = lazy(() => import('./features/users/UsersPage.tsx'));
const WorkspacesPage = lazy(() => import('./features/workspaces/WorkspacesPage.tsx'));
const LogsPage = lazy(() => import('./features/logs/LogsPage.tsx'));
@@ -78,6 +79,7 @@ export const router = createBrowserRouter([
{ path: '/ipam/network/:id', element: <SuspenseWrap><IPAMNetworkDetailPage /></SuspenseWrap> },
{ path: '/ipam/allocations', element: <SuspenseWrap><IPAMAllocationsPage /></SuspenseWrap> },
{ path: '/ipam/history', element: <SuspenseWrap><IPAMHistoryPage /></SuspenseWrap> },
{ path: '/ipam/tools', element: <SuspenseWrap><IPAMToolsPage /></SuspenseWrap> },
{ path: '/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
{ path: '/users', element: <SuspenseWrap><UsersPage /></SuspenseWrap> },
{ path: '/workspaces', element: <SuspenseWrap><WorkspacesPage /></SuspenseWrap> },