feat(ipam): expose network/pool/allocation management in UI
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s

Wire up backend IPAM endpoints that had no frontend:
- edit/delete network (with force) on network detail page
- full pool CRUD (create/edit/delete) on network detail page
- manual allocation create + edit on allocations page
- IP address map visualization per network

Add missing hooks (useCreatePool/useUpdatePool/useDeletePool,
useUpdateAllocation), force support in useDeleteNetwork, typed
ip-map entries, writable allocation fields, and RU/EN locale keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 02:12:40 +05:00
parent e1951bafe1
commit 14a6f5ace7
6 changed files with 609 additions and 42 deletions

View File

@@ -1,20 +1,32 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search } from 'lucide-react';
import { Search, Plus, Pencil, Trash2, Wand2 } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
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 { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useIPAMAllocations, useIPAMNetworks, useDeleteAllocation } from '@/hooks/use-ipam';
import { Trash2 } from 'lucide-react';
import {
useIPAMAllocations, useIPAMNetworks, useDeleteAllocation,
useCreateAllocation, useUpdateAllocation, useNextAvailableIP,
} from '@/hooks/use-ipam';
import type { IPAMAllocation } from '@/types';
import { toast } from 'sonner';
const STATUSES = ['allocated', 'reserved', 'available', 'conflict'] as const;
const RESOURCE_TYPES = ['vm', 'lxc', 'physical', 'service', 'reserved'] as const;
export default function IPAMAllocationsPage() {
const { t } = useTranslation();
const [networkId, setNetworkId] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [editing, setEditing] = useState<IPAMAllocation | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const { data: networks = [] } = useIPAMNetworks();
const { data: allocations = [] } = useIPAMAllocations({
network_id: networkId !== 'all' ? Number(networkId) : undefined,
@@ -36,9 +48,20 @@ export default function IPAMAllocationsPage() {
return true;
});
const handleDelete = (a: IPAMAllocation) => {
if (!confirm(t('ipam.delete_allocation_confirm', { ip: a.ip_address }))) return;
deleteAllocation.mutate(a.id, {
onSuccess: () => toast.success(t('ipam.released')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('ipam.allocations')}</h1>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('ipam.allocations')}</h1>
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />{t('ipam.add_allocation')}</Button>
</div>
<Card>
<CardHeader>
@@ -92,9 +115,14 @@ export default function IPAMAllocationsPage() {
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteAllocation.mutate(a.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditing(a)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDelete(a)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -105,6 +133,160 @@ export default function IPAMAllocationsPage() {
</Table>
</CardContent>
</Card>
<CreateAllocationDialog open={createOpen} onOpenChange={setCreateOpen} networks={networks} />
<EditAllocationDialog allocation={editing} onClose={() => setEditing(null)} />
</div>
);
}
function CreateAllocationDialog({
open, onOpenChange, networks,
}: { open: boolean; onOpenChange: (v: boolean) => void; networks: { id: number; name: string; network: string }[] }) {
const { t } = useTranslation();
const createAllocation = useCreateAllocation();
const nextAvail = useNextAvailableIP();
const empty = { network_id: '', ip_address: '', mac_address: '', resource_name: '', resource_type: 'vm', status: 'allocated', hostname: '', notes: '' };
const [form, setForm] = useState(empty);
const handleNext = () => {
if (!form.network_id) { toast.error(t('ipam.select_network')); return; }
nextAvail.mutate({ networkId: Number(form.network_id) }, {
onSuccess: (r) => setForm(p => ({ ...p, ip_address: r.next_available_ip })),
onError: (e: Error) => toast.error(e.message),
});
};
const handleSubmit = () => {
createAllocation.mutate(
{
network_id: Number(form.network_id),
ip_address: form.ip_address,
mac_address: form.mac_address || undefined,
resource_name: form.resource_name || undefined,
resource_type: form.resource_type || undefined,
status: form.status as IPAMAllocation['status'],
hostname: form.hostname || undefined,
notes: form.notes || undefined,
},
{
onSuccess: () => { toast.success(t('ipam.allocation_created')); onOpenChange(false); setForm(empty); },
onError: (e: Error) => toast.error(e.message),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ipam.add_allocation')}</DialogTitle></DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t('ipam.network')} full>
<Select value={form.network_id} onValueChange={v => { if (v) setForm(p => ({ ...p, network_id: v })); }}>
<SelectTrigger><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>
</Field>
<Field label="IP" full>
<div className="flex gap-2">
<Input value={form.ip_address} onChange={e => setForm(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.0.0.10" className="font-mono" />
<Button type="button" variant="outline" size="sm" onClick={handleNext} disabled={!form.network_id || nextAvail.isPending} title={t('ipam.next_available')}>
<Wand2 className="h-4 w-4" />
</Button>
</div>
</Field>
<Field label="MAC"><Input value={form.mac_address} onChange={e => setForm(p => ({ ...p, mac_address: e.target.value }))} placeholder="AA:BB:CC:DD:EE:FF" className="font-mono" /></Field>
<Field label={t('ipam.resource')}><Input value={form.resource_name} onChange={e => setForm(p => ({ ...p, resource_name: e.target.value }))} /></Field>
<Field label={t('common.type')}>
<Select value={form.resource_type} onValueChange={v => { if (v) setForm(p => ({ ...p, resource_type: v })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{RESOURCE_TYPES.map(r => <SelectItem key={r} value={r}>{r}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field label={t('common.status')}>
<Select value={form.status} onValueChange={v => { if (v) setForm(p => ({ ...p, status: v })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{STATUSES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field label="Hostname"><Input value={form.hostname} onChange={e => setForm(p => ({ ...p, hostname: e.target.value }))} /></Field>
<Field label={t('ipam.notes')} full><Input value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} /></Field>
</div>
<DialogFooter>
<Button onClick={handleSubmit} disabled={!form.network_id || !form.ip_address || createAllocation.isPending}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function EditAllocationDialog({ allocation, onClose }: { allocation: IPAMAllocation | null; onClose: () => void }) {
const { t } = useTranslation();
const updateAllocation = useUpdateAllocation();
const [form, setForm] = useState({ mac_address: '', resource_name: '', status: 'allocated', hostname: '', notes: '' });
// Sync local form when a new allocation is selected.
const [syncedId, setSyncedId] = useState<number | null>(null);
if (allocation && allocation.id !== syncedId) {
setSyncedId(allocation.id);
setForm({
mac_address: allocation.mac_address ?? '',
resource_name: allocation.resource_name ?? '',
status: allocation.status,
hostname: allocation.hostname ?? '',
notes: allocation.notes ?? '',
});
}
const handleSubmit = () => {
if (!allocation) return;
updateAllocation.mutate(
{
id: allocation.id,
mac_address: form.mac_address || undefined,
resource_name: form.resource_name || undefined,
status: form.status as IPAMAllocation['status'],
hostname: form.hostname || undefined,
notes: form.notes || undefined,
},
{
onSuccess: () => { toast.success(t('ipam.allocation_updated')); onClose(); },
onError: (e: Error) => toast.error(e.message),
},
);
};
return (
<Dialog open={!!allocation} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ipam.edit_allocation')} {allocation?.ip_address}</DialogTitle></DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="MAC"><Input value={form.mac_address} onChange={e => setForm(p => ({ ...p, mac_address: e.target.value }))} className="font-mono" /></Field>
<Field label={t('ipam.resource')}><Input value={form.resource_name} onChange={e => setForm(p => ({ ...p, resource_name: e.target.value }))} /></Field>
<Field label={t('common.status')}>
<Select value={form.status} onValueChange={v => { if (v) setForm(p => ({ ...p, status: v })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{STATUSES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field label="Hostname"><Input value={form.hostname} onChange={e => setForm(p => ({ ...p, hostname: e.target.value }))} /></Field>
<Field label={t('ipam.notes')} full><Input value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} /></Field>
</div>
<DialogFooter>
<Button onClick={handleSubmit} disabled={updateAllocation.isPending}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children, full }: { label: string; children: React.ReactNode; full?: boolean }) {
return (
<div className={full ? 'sm:col-span-2' : ''}>
<Label className="mb-1 block">{label}</Label>
{children}
</div>
);
}

View File

@@ -1,11 +1,21 @@
import { useParams, Link } from 'react-router';
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Pencil, Trash2, Plus } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useIPAMNetwork, useIPAMNetworkStats, useIPAMAllocations, useIPAMPools } from '@/hooks/use-ipam';
import {
useIPAMNetwork, useIPAMNetworkStats, useIPAMAllocations, useIPAMPools,
useUpdateNetwork, useDeleteNetwork, useCreatePool, useUpdatePool, useDeletePool, useIPAMIPMap,
} from '@/hooks/use-ipam';
import type { IPAMNetwork, IPAMPool } from '@/types';
import { toast } from 'sonner';
export default function IPAMNetworkDetailPage() {
const { t } = useTranslation();
@@ -27,6 +37,7 @@ export default function IPAMNetworkDetailPage() {
<p className="text-sm text-muted-foreground font-mono">{network.network}</p>
</div>
<Badge variant={network.is_active ? 'default' : 'secondary'} className="ml-auto">{network.is_active ? 'Active' : 'Inactive'}</Badge>
<NetworkActions network={network} />
</div>
{/* Stats */}
@@ -67,33 +78,10 @@ export default function IPAMNetworkDetailPage() {
</Card>
{/* Pools */}
{pools.length > 0 && (
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.pools')} ({pools.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead>{t('ipam.range')}</TableHead>
<TableHead>{t('ipam.usage')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pools.map(pool => (
<TableRow key={pool.id}>
<TableCell className="font-medium">{pool.name}</TableCell>
<TableCell><Badge variant="outline">{pool.pool_type}</Badge></TableCell>
<TableCell className="font-mono text-xs">{pool.range_start} — {pool.range_end}</TableCell>
<TableCell className="text-xs">{pool.used_ips ?? 0} / {pool.total_ips ?? 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<PoolsCard networkId={nid} pools={pools} />
{/* IP map */}
<IPMapCard networkId={nid} />
{/* Allocations */}
<Card>
@@ -132,6 +120,288 @@ export default function IPAMNetworkDetailPage() {
);
}
function NetworkActions({ network }: { network: IPAMNetwork }) {
const { t } = useTranslation();
const navigate = useNavigate();
const updateNet = useUpdateNetwork();
const deleteNet = useDeleteNetwork();
const [editOpen, setEditOpen] = useState(false);
const [delOpen, setDelOpen] = useState(false);
const [force, setForce] = useState(false);
const [form, setForm] = useState({
name: network.name,
network: network.network,
gateway: network.gateway ?? '',
vlan_id: network.vlan_id ? String(network.vlan_id) : '',
dns_primary: network.dns_primary ?? '',
dns_secondary: network.dns_secondary ?? '',
dns_domain: network.dns_domain ?? '',
proxmox_bridge: network.proxmox_bridge ?? '',
is_active: network.is_active,
description: network.description ?? '',
});
const handleSave = () => {
updateNet.mutate(
{
id: network.id,
name: form.name,
gateway: form.gateway || undefined,
vlan_id: form.vlan_id ? Number(form.vlan_id) : undefined,
dns_primary: form.dns_primary || undefined,
dns_secondary: form.dns_secondary || undefined,
dns_domain: form.dns_domain || undefined,
proxmox_bridge: form.proxmox_bridge || undefined,
is_active: form.is_active,
description: form.description || undefined,
},
{
onSuccess: () => { toast.success(t('ipam.network_updated')); setEditOpen(false); },
onError: (e: Error) => toast.error(e.message),
},
);
};
const handleDelete = () => {
deleteNet.mutate(
{ id: network.id, force },
{
onSuccess: () => { toast.success(t('ipam.network_deleted')); navigate('/ipam/networks'); },
onError: (e: Error) => { toast.error(e.message); setForce(true); },
},
);
};
return (
<>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}><Pencil className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" onClick={() => { setForce(false); setDelOpen(true); }}><Trash2 className="h-4 w-4 text-destructive" /></Button>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ipam.edit_network')}</DialogTitle></DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t('common.name')}><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></Field>
<Field label="CIDR"><Input value={form.network} disabled className="font-mono" /></Field>
<Field label={t('common.gateway')}><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} /></Field>
<Field label={t('common.vlan')}><Input value={form.vlan_id} onChange={e => setForm(p => ({ ...p, vlan_id: e.target.value }))} placeholder="100" /></Field>
<Field label={`${t('common.dns')} 1`}><Input value={form.dns_primary} onChange={e => setForm(p => ({ ...p, dns_primary: e.target.value }))} /></Field>
<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>
<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 }))} />
{t('ipam.is_active')}
</label>
</div>
<DialogFooter>
<Button onClick={handleSave} disabled={!form.name || !form.network || updateNet.isPending}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={delOpen} onOpenChange={setDelOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ipam.delete_network')}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground">{t('ipam.delete_network_confirm', { name: network.name })}</p>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={force} onChange={e => setForce(e.target.checked)} />
{t('ipam.force_delete')}
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => setDelOpen(false)}>{t('common.cancel')}</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteNet.isPending}>{t('common.delete')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
const emptyPool = { name: '', pool_type: 'static', range_start: '', range_end: '', auto_assign: false, description: '' };
function PoolsCard({ networkId, pools }: { networkId: number; pools: IPAMPool[] }) {
const { t } = useTranslation();
const createPool = useCreatePool();
const updatePool = useUpdatePool();
const deletePool = useDeletePool();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<IPAMPool | null>(null);
const [form, setForm] = useState<typeof emptyPool>(emptyPool);
const openCreate = () => { setEditing(null); setForm(emptyPool); setOpen(true); };
const openEdit = (p: IPAMPool) => {
setEditing(p);
setForm({ name: p.name, pool_type: p.pool_type, range_start: p.range_start, range_end: p.range_end, auto_assign: p.auto_assign, description: p.description ?? '' });
setOpen(true);
};
const handleSubmit = () => {
const payload = {
name: form.name,
pool_type: form.pool_type as IPAMPool['pool_type'],
range_start: form.range_start,
range_end: form.range_end,
auto_assign: form.auto_assign,
description: form.description || undefined,
};
if (editing) {
updatePool.mutate({ id: editing.id, ...payload }, {
onSuccess: () => { toast.success(t('ipam.pool_updated')); setOpen(false); },
onError: (e: Error) => toast.error(e.message),
});
} else {
createPool.mutate({ network_id: networkId, ...payload }, {
onSuccess: () => { toast.success(t('ipam.pool_created')); setOpen(false); },
onError: (e: Error) => toast.error(e.message),
});
}
};
const handleDelete = (p: IPAMPool) => {
if (!confirm(t('ipam.delete_pool_confirm', { name: p.name }))) return;
deletePool.mutate(p.id, {
onSuccess: () => toast.success(t('ipam.pool_deleted')),
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="text-sm">{t('ipam.pools')} ({pools.length})</CardTitle>
<Button size="sm" variant="outline" onClick={openCreate}><Plus className="mr-1 h-4 w-4" />{t('ipam.add_pool')}</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead>{t('ipam.range')}</TableHead>
<TableHead>{t('ipam.usage')}</TableHead>
<TableHead className="w-[1%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pools.map(pool => (
<TableRow key={pool.id}>
<TableCell className="font-medium">{pool.name}</TableCell>
<TableCell><Badge variant="outline">{pool.pool_type}</Badge></TableCell>
<TableCell className="font-mono text-xs">{pool.range_start} — {pool.range_end}</TableCell>
<TableCell className="text-xs">{pool.used_ips ?? 0} / {pool.total_ips ?? 0}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => openEdit(pool)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(pool)}><Trash2 className="h-3.5 w-3.5 text-destructive" /></Button>
</div>
</TableCell>
</TableRow>
))}
{pools.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{editing ? t('ipam.edit_pool') : t('ipam.add_pool')}</DialogTitle></DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t('common.name')}><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></Field>
<Field label={t('ipam.pool_type')}>
<Select value={form.pool_type} onValueChange={v => { if (v) setForm(p => ({ ...p, pool_type: v })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="dhcp">dhcp</SelectItem>
<SelectItem value="reserved">reserved</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={`${t('ipam.range')} (start)`}><Input value={form.range_start} onChange={e => setForm(p => ({ ...p, range_start: e.target.value }))} placeholder="10.0.0.10" /></Field>
<Field label={`${t('ipam.range')} (end)`}><Input value={form.range_end} onChange={e => setForm(p => ({ ...p, range_end: e.target.value }))} placeholder="10.0.0.250" /></Field>
<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.auto_assign} onChange={e => setForm(p => ({ ...p, auto_assign: e.target.checked }))} />
{t('ipam.auto_assign')}
</label>
</div>
<DialogFooter>
<Button onClick={handleSubmit} disabled={!form.name || !form.range_start || !form.range_end || createPool.isPending || updatePool.isPending}>
{editing ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
const ipMapColors: Record<string, string> = {
gateway: 'bg-purple-500',
allocated: 'bg-blue-500',
reserved: 'bg-amber-500',
available: 'bg-muted',
};
function IPMapCard({ networkId }: { networkId: number }) {
const { t } = useTranslation();
const { data, isLoading } = useIPAMIPMap(networkId);
const entries = data?.ip_map ?? [];
const MAX = 1024;
const shown = entries.slice(0, MAX);
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t('ipam.ip_map')}{entries.length > 0 && ` (${entries.length})`}</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : entries.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
) : (
<>
<div className="flex flex-wrap gap-1">
{shown.map(e => (
<div
key={e.ip}
title={`${e.ip}${e.resource_name ? ` · ${e.resource_name}` : ''} (${e.status})`}
className={`h-3.5 w-3.5 rounded-sm ${ipMapColors[e.status] ?? 'bg-muted'} ${e.status === 'available' ? 'border border-border' : ''}`}
/>
))}
</div>
{entries.length > MAX && (
<p className="mt-2 text-xs text-muted-foreground">{t('ipam.ip_map_truncated', { shown: MAX, total: entries.length })}</p>
)}
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
{(['gateway', 'allocated', 'reserved', 'available'] as const).map(s => (
<span key={s} className="flex items-center gap-1">
<span className={`h-3 w-3 rounded-sm ${ipMapColors[s]} ${s === 'available' ? 'border border-border' : ''}`} />
{t(`ipam.${s}`, s)}
</span>
))}
</div>
</>
)}
</CardContent>
</Card>
);
}
function Field({ label, children, full }: { label: string; children: React.ReactNode; full?: boolean }) {
return (
<div className={full ? 'sm:col-span-2' : ''}>
<Label className="mb-1 block">{label}</Label>
{children}
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<Card>

View File

@@ -37,10 +37,18 @@ export function useIPAMNetworkStats(id: number) {
});
}
export interface IPAMIPMapEntry {
ip: string;
status: 'gateway' | 'allocated' | 'reserved' | 'available' | string;
resource_name?: string | null;
resource_type?: string | null;
last_seen?: string | null;
}
export function useIPAMIPMap(id: number) {
return useQuery({
queryKey: ipamKeys.ipMap(id),
queryFn: () => apiClient.get<{ network_id: number; ip_map: unknown[] }>(`/ipam/api/networks/${id}/ip-map`),
queryFn: () => apiClient.get<{ network_id: number; ip_map: IPAMIPMapEntry[] }>(`/ipam/api/networks/${id}/ip-map`),
enabled: id > 0,
});
}
@@ -109,15 +117,66 @@ export function useUpdateNetwork() {
export function useDeleteNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/networks/${id}`),
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'] }),
});
}
@@ -126,7 +185,11 @@ export function useDeleteAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/allocations/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-allocations'] });
qc.invalidateQueries({ queryKey: ipamKeys.summary });
qc.invalidateQueries({ queryKey: ['ipam-network-stats'] });
},
});
}

View File

@@ -616,7 +616,31 @@
"cidr": "CIDR",
"gateway": "Gateway",
"vms_linked": "VMs Linked",
"network_overview": "Network Overview"
"network_overview": "Network Overview",
"edit_network": "Edit Network",
"delete_network": "Delete Network",
"delete_network_confirm": "Delete network \"{{name}}\"? This action cannot be undone.",
"force_delete": "Force delete (including allocations)",
"network_updated": "Network updated",
"network_deleted": "Network deleted",
"is_active": "Active",
"dns_domain": "DNS Domain",
"add_pool": "Add Pool",
"edit_pool": "Edit Pool",
"pool_type": "Pool Type",
"auto_assign": "Auto-assign",
"pool_created": "Pool created",
"pool_updated": "Pool updated",
"pool_deleted": "Pool deleted",
"delete_pool_confirm": "Delete pool \"{{name}}\"?",
"ip_map": "IP Map",
"ip_map_truncated": "Showing {{shown}} of {{total}} addresses",
"add_allocation": "Add IP",
"edit_allocation": "Edit Allocation",
"allocation_created": "Allocation created",
"allocation_updated": "Allocation updated",
"delete_allocation_confirm": "Release IP {{ip}}?",
"notes": "Notes"
},
"logs": {
"level": "Level",

View File

@@ -616,7 +616,31 @@
"cidr": "CIDR",
"gateway": "Шлюз",
"vms_linked": "Привязано ВМ",
"network_overview": "Обзор сетей"
"network_overview": "Обзор сетей",
"edit_network": "Изменить сеть",
"delete_network": "Удалить сеть",
"delete_network_confirm": "Удалить сеть «{{name}}»? Это действие необратимо.",
"force_delete": "Удалить принудительно (вместе с аллокациями)",
"network_updated": "Сеть обновлена",
"network_deleted": "Сеть удалена",
"is_active": "Активна",
"dns_domain": "DNS-домен",
"add_pool": "Добавить пул",
"edit_pool": "Изменить пул",
"pool_type": "Тип пула",
"auto_assign": "Авто-назначение",
"pool_created": "Пул создан",
"pool_updated": "Пул обновлён",
"pool_deleted": "Пул удалён",
"delete_pool_confirm": "Удалить пул «{{name}}»?",
"ip_map": "Карта IP",
"ip_map_truncated": "Показано {{shown}} из {{total}} адресов",
"add_allocation": "Добавить IP",
"edit_allocation": "Изменить аллокацию",
"allocation_created": "Аллокация создана",
"allocation_updated": "Аллокация обновлена",
"delete_allocation_confirm": "Освободить IP {{ip}}?",
"notes": "Заметки"
},
"logs": {
"level": "Уровень",

View File

@@ -336,6 +336,10 @@ export interface IPAMAllocation {
proxmox_node?: string;
status: 'allocated' | 'reserved' | 'available' | 'conflict';
description?: string;
hostname?: string;
fqdn?: string;
allocation_type?: string;
notes?: string;
created_at: string;
updated_at: string;
}