feat(ipam): expose network/pool/allocation management in UI
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Уровень",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user