Files
proxpanel/src/pages/NetworkIpam.jsx

250 lines
16 KiB
JavaScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { appClient } from "@/api/appClient";
import PageHeader from "@/components/shared/PageHeader";
import EmptyState from "@/components/shared/EmptyState";
import StatusBadge from "@/components/shared/StatusBadge";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const splitMultiValue = (input) =>
input
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
export default function NetworkIpam() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [ipData, setIpData] = useState([]);
const [assignments, setAssignments] = useState([]);
const [vms, setVms] = useState([]);
const [tenants, setTenants] = useState([]);
const [utilization, setUtilization] = useState({ subnets: [], assignment_summary: { total: 0, ipv4: 0, ipv6: 0 } });
const [quotas, setQuotas] = useState([]);
const [ranges, setRanges] = useState([]);
const [policies, setPolicies] = useState([]);
const [importForm, setImportForm] = useState({ addresses: "", cidr_blocks: "", scope: "PUBLIC" });
const [assignForm, setAssignForm] = useState({ vm_id: "", address: "", scope: "PUBLIC", version: "IPV4", assignment_type: "ADDITIONAL" });
const [quotaForm, setQuotaForm] = useState({ tenant_id: "", ipv4_limit: "", ipv6_limit: "" });
const [rangeForm, setRangeForm] = useState({ name: "", cidr: "", scope: "PUBLIC", tenant_id: "" });
const [policyForm, setPolicyForm] = useState({ name: "", tenant_id: "", allocation_strategy: "BEST_FIT", priority: "100" });
const activeAssignments = useMemo(() => assignments.filter((x) => x.is_active), [assignments]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [ips, asn, vmList, tenantList, dash, quotaList, rangeList, policyList] = await Promise.all([
appClient.network.listIpAddresses({ limit: 300 }),
appClient.network.listIpAssignments({ active_only: true }),
appClient.entities.VirtualMachine.list("-created_at", 200),
appClient.entities.Tenant.list("-created_at", 200),
appClient.network.subnetUtilization(),
appClient.network.listTenantQuotas(),
appClient.network.listReservedRanges(),
appClient.network.listPolicies()
]);
setIpData(ips?.data ?? []);
setAssignments(asn?.data ?? []);
setVms(vmList ?? []);
setTenants(tenantList ?? []);
setUtilization(dash ?? { subnets: [], assignment_summary: { total: 0, ipv4: 0, ipv6: 0 } });
setQuotas(quotaList?.data ?? []);
setRanges(rangeList?.data ?? []);
setPolicies(policyList?.data ?? []);
} catch (error) {
toast({ title: "Load Failed", description: error?.message ?? "Could not load network data", variant: "destructive" });
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
loadData();
}, [loadData]);
const runAction = async (fn, success) => {
try {
await fn();
toast({ title: success });
await loadData();
} catch (error) {
toast({ title: "Action Failed", description: error?.message ?? "Request failed", variant: "destructive" });
}
};
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
}
return (
<div className="space-y-6">
<PageHeader title="Network & IPAM" description="Phase 4 Step 2: utilization dashboard + quota/reserved/best-fit policy controls" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">Assigned</p><p className="text-2xl">{utilization.assignment_summary?.total ?? 0}</p></div>
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">IPv4</p><p className="text-2xl">{utilization.assignment_summary?.ipv4 ?? 0}</p></div>
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">IPv6</p><p className="text-2xl">{utilization.assignment_summary?.ipv6 ?? 0}</p></div>
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">Subnets</p><p className="text-2xl">{utilization.subnets?.length ?? 0}</p></div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Import IPs</h2>
<textarea className="w-full min-h-[70px] rounded-md border bg-muted px-3 py-2 text-sm" placeholder="Addresses (comma/new line)" value={importForm.addresses} onChange={(e) => setImportForm((p) => ({ ...p, addresses: e.target.value }))} />
<textarea className="w-full min-h-[70px] rounded-md border bg-muted px-3 py-2 text-sm" placeholder="CIDR blocks (comma/new line)" value={importForm.cidr_blocks} onChange={(e) => setImportForm((p) => ({ ...p, cidr_blocks: e.target.value }))} />
<Select value={importForm.scope} onValueChange={(value) => setImportForm((p) => ({ ...p, scope: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PUBLIC">PUBLIC</SelectItem><SelectItem value="PRIVATE">PRIVATE</SelectItem></SelectContent>
</Select>
<Button onClick={() => runAction(() => appClient.network.importIpAddresses({ scope: importForm.scope, addresses: splitMultiValue(importForm.addresses), cidr_blocks: splitMultiValue(importForm.cidr_blocks) }), "IP import completed")}>Import</Button>
</div>
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Assign IP (Best-Fit Policy)</h2>
<Select value={assignForm.vm_id} onValueChange={(value) => setAssignForm((p) => ({ ...p, vm_id: value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Select VM" /></SelectTrigger>
<SelectContent>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent>
</Select>
<Input className="bg-muted" placeholder="Specific IP (optional)" value={assignForm.address} onChange={(e) => setAssignForm((p) => ({ ...p, address: e.target.value }))} />
<div className="grid grid-cols-3 gap-2">
<Select value={assignForm.scope} onValueChange={(value) => setAssignForm((p) => ({ ...p, scope: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PUBLIC">PUBLIC</SelectItem><SelectItem value="PRIVATE">PRIVATE</SelectItem></SelectContent>
</Select>
<Select value={assignForm.version} onValueChange={(value) => setAssignForm((p) => ({ ...p, version: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="IPV4">IPV4</SelectItem><SelectItem value="IPV6">IPV6</SelectItem></SelectContent>
</Select>
<Select value={assignForm.assignment_type} onValueChange={(value) => setAssignForm((p) => ({ ...p, assignment_type: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PRIMARY">PRIMARY</SelectItem><SelectItem value="ADDITIONAL">ADDITIONAL</SelectItem><SelectItem value="FLOATING">FLOATING</SelectItem></SelectContent>
</Select>
</div>
<Button disabled={!assignForm.vm_id} onClick={() => runAction(() => appClient.network.assignIpAddress(assignForm), "IP assigned")}>Assign</Button>
<div className="space-y-1 pt-2 border-t">
{activeAssignments.slice(0, 6).map((item) => (
<div key={item.id} className="flex items-center justify-between rounded-md bg-muted/40 px-2 py-1.5 text-xs">
<span className="font-mono">{item.ip_address?.address}/{item.ip_address?.cidr}</span>
<Button size="sm" variant="outline" onClick={() => runAction(() => appClient.network.returnIpAddress({ assignment_id: item.id }), "IP returned")}>Return</Button>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Tenant Quota</h2>
<Select value={quotaForm.tenant_id} onValueChange={(value) => setQuotaForm((p) => ({ ...p, tenant_id: value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Tenant" /></SelectTrigger>
<SelectContent>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-2">
<Input className="bg-muted" placeholder="IPv4 limit" value={quotaForm.ipv4_limit} onChange={(e) => setQuotaForm((p) => ({ ...p, ipv4_limit: e.target.value }))} />
<Input className="bg-muted" placeholder="IPv6 limit" value={quotaForm.ipv6_limit} onChange={(e) => setQuotaForm((p) => ({ ...p, ipv6_limit: e.target.value }))} />
</div>
<Button disabled={!quotaForm.tenant_id} onClick={() => runAction(() => appClient.network.upsertTenantQuota({ tenant_id: quotaForm.tenant_id, ipv4_limit: quotaForm.ipv4_limit ? Number(quotaForm.ipv4_limit) : null, ipv6_limit: quotaForm.ipv6_limit ? Number(quotaForm.ipv6_limit) : null }), "Quota saved")}>Save Quota</Button>
<p className="text-xs text-muted-foreground">Configured quotas: {quotas.length}</p>
</div>
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Reserved Range</h2>
<Input className="bg-muted" placeholder="Name" value={rangeForm.name} onChange={(e) => setRangeForm((p) => ({ ...p, name: e.target.value }))} />
<Input className="bg-muted" placeholder="CIDR (10.0.10.0/24)" value={rangeForm.cidr} onChange={(e) => setRangeForm((p) => ({ ...p, cidr: e.target.value }))} />
<Select value={rangeForm.scope} onValueChange={(value) => setRangeForm((p) => ({ ...p, scope: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PUBLIC">PUBLIC</SelectItem><SelectItem value="PRIVATE">PRIVATE</SelectItem></SelectContent>
</Select>
<Select value={rangeForm.tenant_id || "__GLOBAL__"} onValueChange={(value) => setRangeForm((p) => ({ ...p, tenant_id: value === "__GLOBAL__" ? "" : value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Tenant (optional)" /></SelectTrigger>
<SelectContent><SelectItem value="__GLOBAL__">Global</SelectItem>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
<Button onClick={() => runAction(() => appClient.network.createReservedRange({ ...rangeForm, tenant_id: rangeForm.tenant_id || undefined }), "Reserved range created")}>Create Range</Button>
<p className="text-xs text-muted-foreground">Reserved ranges: {ranges.length}</p>
</div>
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">IP Pool Policy</h2>
<Input className="bg-muted" placeholder="Policy name" value={policyForm.name} onChange={(e) => setPolicyForm((p) => ({ ...p, name: e.target.value }))} />
<Select value={policyForm.tenant_id || "__GLOBAL__"} onValueChange={(value) => setPolicyForm((p) => ({ ...p, tenant_id: value === "__GLOBAL__" ? "" : value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Tenant (optional)" /></SelectTrigger>
<SelectContent><SelectItem value="__GLOBAL__">Global</SelectItem>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
<Select value={policyForm.allocation_strategy} onValueChange={(value) => setPolicyForm((p) => ({ ...p, allocation_strategy: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="BEST_FIT">BEST_FIT</SelectItem><SelectItem value="FIRST_AVAILABLE">FIRST_AVAILABLE</SelectItem></SelectContent>
</Select>
<Input className="bg-muted" placeholder="Priority" value={policyForm.priority} onChange={(e) => setPolicyForm((p) => ({ ...p, priority: e.target.value }))} />
<Button onClick={() => runAction(() => appClient.network.createPolicy({ name: policyForm.name, tenant_id: policyForm.tenant_id || undefined, allocation_strategy: policyForm.allocation_strategy, priority: Number(policyForm.priority || 100) }), "Policy created")}>Create Policy</Button>
<p className="text-xs text-muted-foreground">Policies: {policies.length}</p>
</div>
</div>
<div className="surface-card p-4">
<h2 className="text-sm font-semibold mb-3">Subnet Utilization</h2>
{utilization.subnets?.length === 0 ? (
<EmptyState title="No Subnets Yet" description="Import IP blocks to populate utilization." />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs uppercase text-muted-foreground">
<th className="text-left px-3 py-2">Subnet</th>
<th className="text-left px-3 py-2">Scope</th>
<th className="text-left px-3 py-2">Assigned</th>
<th className="text-left px-3 py-2">Available</th>
<th className="text-left px-3 py-2">Utilization</th>
</tr>
</thead>
<tbody className="divide-y">
{utilization.subnets.slice(0, 30).map((item) => (
<tr key={`${item.scope}-${item.subnet}`}>
<td className="px-3 py-2 font-mono text-xs">{item.subnet}</td>
<td className="px-3 py-2">{item.scope}</td>
<td className="px-3 py-2">{item.assigned}</td>
<td className="px-3 py-2">{item.available}</td>
<td className="px-3 py-2">{item.utilization_pct}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="surface-card p-4">
<h2 className="text-sm font-semibold mb-3">IP Pool</h2>
{ipData.length === 0 ? (
<EmptyState title="No IP Addresses In Pool" description="Import addresses or CIDR blocks to initialize IPAM." />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs uppercase text-muted-foreground">
<th className="text-left px-3 py-2">Address</th>
<th className="text-left px-3 py-2">Scope</th>
<th className="text-left px-3 py-2">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{ipData.slice(0, 100).map((item) => (
<tr key={item.id}>
<td className="px-3 py-2 font-mono text-xs">{item.address}/{item.cidr}</td>
<td className="px-3 py-2">{item.scope}</td>
<td className="px-3 py-2"><StatusBadge status={item.status?.toLowerCase()} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}