feat(*): Add IPAM panel with subnet utilization and IP suggestions

This commit is contained in:
Josh Creek
2026-07-12 20:20:19 +01:00
parent 336ee5e91b
commit acf63a9707
6 changed files with 795 additions and 8 deletions
+7
View File
@@ -53,5 +53,12 @@
} }
] ]
} }
],
"subnets": [
{
"cidr": "192.168.1.0/24",
"name": "LAN",
"vlanId": 1
}
] ]
} }
+453 -6
View File
@@ -20,6 +20,7 @@
withReconciledConnections withReconciledConnections
} from '$lib/data/networkEditor'; } from '$lib/data/networkEditor';
import loadNetworkData from '$lib/data/loadNetworkData'; import loadNetworkData from '$lib/data/loadNetworkData';
import { buildIpamReport, suggestNextFreeIp, type IpamReport } from '$lib/data/ipam';
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema'; import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData'; import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
import { computeSearchMatches } from '$lib/graph/searchHighlight'; import { computeSearchMatches } from '$lib/graph/searchHighlight';
@@ -82,6 +83,7 @@
let searchQuery = ''; let searchQuery = '';
let searchMatchCount = 0; let searchMatchCount = 0;
let lastTransformResult: GraphTransformResult | null = null; let lastTransformResult: GraphTransformResult | null = null;
let showIpamPanel = false;
let machineVmIndex: GraphTransformResult['machineVmIndex'] = {}; let machineVmIndex: GraphTransformResult['machineVmIndex'] = {};
let collapsedHosts: Record<string, boolean> = {}; let collapsedHosts: Record<string, boolean> = {};
let exportFileName = 'network-diagram'; let exportFileName = 'network-diagram';
@@ -135,6 +137,19 @@
}); });
$: visibleIconDefinitions = filteredIconDefinitions.slice(0, iconResultLimit); $: visibleIconDefinitions = filteredIconDefinitions.slice(0, iconResultLimit);
$: ipamReport = buildIpamReport(networkData);
$: ipamWarnings = ipamReport.duplicates.map(
(duplicate) => `Duplicate IP ${duplicate.ip}: assigned to ${duplicate.owners.join(' and ')}.`
);
function duplicateOwnersForIp(report: IpamReport, ip: string, selfLabel: string): string[] {
const entry = report.duplicates.find((duplicate) => duplicate.ip === ip.trim());
if (!entry) {
return [];
}
return entry.owners.filter((owner) => owner !== selfLabel);
}
$: selectedMachine = $: selectedMachine =
selectedTarget?.type === 'machine' ? networkData.machines[selectedTarget.index] : null; selectedTarget?.type === 'machine' ? networkData.machines[selectedTarget.index] : null;
$: selectedDevice = $: selectedDevice =
@@ -143,6 +158,20 @@
selectedTarget?.type === 'vm' selectedTarget?.type === 'vm'
? networkData.machines[selectedTarget.machineIndex]?.software.vms[selectedTarget.vmIndex] ? networkData.machines[selectedTarget.machineIndex]?.software.vms[selectedTarget.vmIndex]
: null; : null;
$: selectedMachineIpConflicts = selectedMachine
? duplicateOwnersForIp(ipamReport, selectedMachine.ipAddress, selectedMachine.machineName)
: [];
$: selectedDeviceIpConflicts = selectedDevice
? duplicateOwnersForIp(ipamReport, selectedDevice.ipAddress, selectedDevice.name)
: [];
$: selectedVmIpConflicts =
selectedVm && selectedTarget?.type === 'vm'
? duplicateOwnersForIp(
ipamReport,
selectedVm.ipAddress,
`${selectedVm.name} (VM on ${networkData.machines[selectedTarget.machineIndex]?.machineName ?? ''})`
)
: [];
$: selectedMachineHostId = selectedMachine ? toMachineNodeId(selectedMachine.machineName) : null; $: selectedMachineHostId = selectedMachine ? toMachineNodeId(selectedMachine.machineName) : null;
$: selectedMachineVmCount = $: selectedMachineVmCount =
selectedMachineHostId && machineVmIndex[selectedMachineHostId] selectedMachineHostId && machineVmIndex[selectedMachineHostId]
@@ -1380,6 +1409,7 @@
...createEmptyMachine(), ...createEmptyMachine(),
machineName: nextUniqueName('New Machine', existingNames) machineName: nextUniqueName('New Machine', existingNames)
}; };
newMachineDraft.ipAddress = suggestNextFreeIp(networkData) ?? newMachineDraft.ipAddress;
addModalKind = 'machine'; addModalKind = 'machine';
} }
@@ -1389,9 +1419,47 @@
...createEmptyDevice(), ...createEmptyDevice(),
name: nextUniqueName('New Device', existingNames) name: nextUniqueName('New Device', existingNames)
}; };
newDeviceDraft.ipAddress = suggestNextFreeIp(networkData) ?? newDeviceDraft.ipAddress;
addModalKind = 'device'; addModalKind = 'device';
} }
function suggestIpForAddModal() {
const suggested = suggestNextFreeIp(networkData);
if (!suggested) {
return;
}
if (addModalKind === 'machine') {
newMachineDraft.ipAddress = suggested;
} else if (addModalKind === 'device') {
newDeviceDraft.ipAddress = suggested;
}
}
function addDeclaredSubnet() {
const firstInferred = ipamReport.subnets.find((subnet) => !subnet.declared);
const existing = new Set((networkData.subnets ?? []).map((subnet) => subnet.cidr));
let cidr = firstInferred?.cidr ?? '192.168.1.0/24';
if (existing.has(cidr)) {
cidr = '192.168.1.0/24';
}
if (existing.has(cidr)) {
return;
}
mutateDraft((draft) => {
draft.subnets ??= [];
draft.subnets.push({ cidr });
});
}
function removeDeclaredSubnet(index: number) {
mutateDraft((draft) => {
draft.subnets?.splice(index, 1);
if (draft.subnets && draft.subnets.length === 0) {
delete draft.subnets;
}
});
}
function submitAddEntity() { function submitAddEntity() {
if (addModalKind === 'machine') { if (addModalKind === 'machine') {
mutateDraft((draft) => { mutateDraft((draft) => {
@@ -1728,6 +1796,13 @@
/> />
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label> </label>
<button
type="button"
class:active-toggle={showIpamPanel}
on:click={() => (showIpamPanel = !showIpamPanel)}
>
IPAM
</button>
<button type="button" on:click={() => (showExportModal = true)}>Export PNG</button> <button type="button" on:click={() => (showExportModal = true)}>Export PNG</button>
<label class="toggle-control"> <label class="toggle-control">
<span>Dark Mode</span> <span>Dark Mode</span>
@@ -1765,6 +1840,157 @@
{/if} {/if}
</div> </div>
{#if showIpamPanel}
<div class="ipam-panel">
<div class="ipam-panel-head">
<h3>IP Address Management</h3>
<button
type="button"
class="ipam-close"
aria-label="Close IPAM panel"
on:click={() => (showIpamPanel = false)}
>
×
</button>
</div>
<section class="ipam-section">
<h4>Subnets</h4>
{#each ipamReport.subnets as subnet (subnet.cidr)}
<div class="ipam-subnet">
<div class="ipam-subnet-head">
<strong>{subnet.cidr}</strong>
{#if subnet.name}<span class="ipam-subnet-name">{subnet.name}</span>{/if}
{#if subnet.vlanId}<span class="ipam-badge">VLAN {subnet.vlanId}</span>{/if}
{#if !subnet.declared}<span class="ipam-badge inferred">inferred</span>{/if}
</div>
<div class="ipam-bar" role="presentation">
<div
class="ipam-bar-fill"
style={`width: ${Math.min(100, Math.round((subnet.used / Math.max(subnet.capacity, 1)) * 100))}%;`}
></div>
</div>
<div class="ipam-subnet-meta">
{subnet.used} of {subnet.capacity} in use
{#if suggestNextFreeIp(networkData, subnet.cidr)}
· next free: <code>{suggestNextFreeIp(networkData, subnet.cidr)}</code>
{/if}
</div>
</div>
{:else}
<p class="ipam-empty">No IPv4 addresses found yet.</p>
{/each}
</section>
{#if ipamReport.duplicates.length > 0}
<section class="ipam-section">
<h4>Conflicts</h4>
<ul class="ipam-conflicts">
{#each ipamReport.duplicates as duplicate (duplicate.ip)}
<li><code>{duplicate.ip}</code>{duplicate.owners.join(', ')}</li>
{/each}
</ul>
</section>
{/if}
{#if ipamReport.unparsed.length > 0}
<section class="ipam-section">
<h4>Not an IPv4 address</h4>
<ul class="ipam-unparsed">
{#each ipamReport.unparsed as assignment (`${assignment.ownerLabel}:${assignment.ip}`)}
<li>{assignment.ownerLabel}: <code>{assignment.ip}</code></li>
{/each}
</ul>
</section>
{/if}
<section class="ipam-section">
<h4>Declared subnets</h4>
{#each networkData.subnets ?? [] as subnet, subnetIndex (`${subnet.cidr}:${subnetIndex}`)}
<div class="ipam-subnet-editor">
<label>
CIDR
<input
type="text"
value={subnet.cidr}
on:change={(event) =>
mutateDraft(
(draft) => {
if (draft.subnets?.[subnetIndex]) {
draft.subnets[subnetIndex].cidr = (
event.currentTarget as HTMLInputElement
).value;
}
},
{ autosave: true }
)}
/>
</label>
<label>
Name
<input
type="text"
value={subnet.name ?? ''}
on:change={(event) =>
mutateDraft(
(draft) => {
const nextName = (event.currentTarget as HTMLInputElement).value.trim();
if (draft.subnets?.[subnetIndex]) {
if (nextName) {
draft.subnets[subnetIndex].name = nextName;
} else {
delete draft.subnets[subnetIndex].name;
}
}
},
{ autosave: true }
)}
/>
</label>
<label>
VLAN
<input
type="number"
min="1"
max="4094"
value={subnet.vlanId ?? ''}
on:change={(event) =>
mutateDraft(
(draft) => {
const parsed = Number((event.currentTarget as HTMLInputElement).value);
if (draft.subnets?.[subnetIndex]) {
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 4094) {
draft.subnets[subnetIndex].vlanId = parsed;
} else {
delete draft.subnets[subnetIndex].vlanId;
}
}
},
{ autosave: true }
)}
/>
</label>
<button
type="button"
class="danger"
aria-label={`Remove subnet ${subnet.cidr}`}
on:click={() => removeDeclaredSubnet(subnetIndex)}
>
Remove
</button>
</div>
{:else}
<p class="ipam-empty">
None declared; subnets above are inferred as /24 from the IPs in use.
</p>
{/each}
<div class="inline-actions">
<button type="button" on:click={addDeclaredSubnet}>Declare Subnet</button>
</div>
</section>
</div>
{/if}
<div class="data-source">Source: {dataSourceLabel}</div> <div class="data-source">Source: {dataSourceLabel}</div>
<div class="status-chip" class:error={saveState === 'error'}> <div class="status-chip" class:error={saveState === 'error'}>
{saveStateLabel()} {saveStateLabel()}
@@ -1792,11 +2018,11 @@
{/if} {/if}
</div> </div>
{#if warnings.length > 0} {#if warnings.length + ipamWarnings.length > 0}
<details class="warnings-panel"> <details class="warnings-panel">
<summary>Data warnings ({warnings.length})</summary> <summary>Data warnings ({warnings.length + ipamWarnings.length})</summary>
<ul> <ul>
{#each warnings as warning (warning)} {#each [...warnings, ...ipamWarnings] as warning (warning)}
<li>{warning}</li> <li>{warning}</li>
{/each} {/each}
</ul> </ul>
@@ -1898,6 +2124,9 @@
{ autosave: true } { autosave: true }
)} )}
/> />
{#if selectedMachineIpConflicts.length > 0}
<span class="ip-conflict-hint">Also used by {selectedMachineIpConflicts.join(', ')}</span>
{/if}
</label> </label>
<label> <label>
Role Role
@@ -2019,7 +2248,8 @@
name: nextUniqueName( name: nextUniqueName(
'New VM', 'New VM',
draft.machines[selectedTarget.index].software.vms.map((vm) => vm.name) draft.machines[selectedTarget.index].software.vms.map((vm) => vm.name)
) ),
ipAddress: suggestNextFreeIp(draft) ?? createEmptyVm().ipAddress
}); });
})} })}
> >
@@ -2219,6 +2449,9 @@
{ autosave: true } { autosave: true }
)} )}
/> />
{#if selectedDeviceIpConflicts.length > 0}
<span class="ip-conflict-hint">Also used by {selectedDeviceIpConflicts.join(', ')}</span>
{/if}
</label> </label>
<label> <label>
Type Type
@@ -2415,6 +2648,9 @@
{ autosave: true } { autosave: true }
)} )}
/> />
{#if selectedVmIpConflicts.length > 0}
<span class="ip-conflict-hint">Also used by {selectedVmIpConflicts.join(', ')}</span>
{/if}
</label> </label>
<label> <label>
MAC Address MAC Address
@@ -2503,7 +2739,10 @@
</label> </label>
<label> <label>
IP Address IP Address
<input type="text" bind:value={newMachineDraft.ipAddress} /> <span class="ip-suggest-row">
<input type="text" bind:value={newMachineDraft.ipAddress} />
<button type="button" on:click={suggestIpForAddModal}>Suggest</button>
</span>
</label> </label>
<label> <label>
Role Role
@@ -2526,7 +2765,10 @@
</label> </label>
<label> <label>
IP Address IP Address
<input type="text" bind:value={newDeviceDraft.ipAddress} /> <span class="ip-suggest-row">
<input type="text" bind:value={newDeviceDraft.ipAddress} />
<button type="button" on:click={suggestIpForAddModal}>Suggest</button>
</span>
</label> </label>
<label> <label>
Type Type
@@ -2738,6 +2980,211 @@
cursor: pointer; cursor: pointer;
} }
.map-controls button.active-toggle {
border-color: #3b82f6;
background: color-mix(in oklab, var(--panel-bg) 82%, #3b82f6 18%);
}
.ipam-panel {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
z-index: 15;
width: min(88vw, 360px);
max-height: min(72%, 620px);
overflow-y: auto;
background: color-mix(in oklab, var(--panel-bg) 96%, transparent 4%);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 0.7rem 0.8rem;
color: var(--panel-contrast);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.24);
}
.ipam-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.ipam-panel-head h3 {
margin: 0;
font-size: 0.92rem;
}
.ipam-close {
border: none;
background: transparent;
color: var(--panel-contrast);
font-size: 1.2rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
padding: 0 0.2rem;
}
.ipam-section {
margin-top: 0.7rem;
}
.ipam-section h4 {
margin: 0 0 0.4rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-text);
}
.ipam-subnet {
margin-bottom: 0.6rem;
}
.ipam-subnet-head {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
flex-wrap: wrap;
}
.ipam-subnet-name {
color: var(--muted-text);
font-size: 0.78rem;
font-weight: 600;
}
.ipam-badge {
font-size: 0.66rem;
font-weight: 700;
border: 1px solid #3b82f6;
color: #3b82f6;
border-radius: 999px;
padding: 0.05rem 0.4rem;
}
.ipam-badge.inferred {
border-color: var(--panel-border);
color: var(--muted-text);
}
.ipam-bar {
height: 0.4rem;
border-radius: 999px;
background: color-mix(in oklab, var(--panel-bg) 70%, var(--panel-border) 30%);
margin: 0.25rem 0;
overflow: hidden;
}
.ipam-bar-fill {
height: 100%;
border-radius: 999px;
background: #3b82f6;
min-width: 2px;
}
.ipam-subnet-meta {
font-size: 0.74rem;
color: var(--muted-text);
}
.ipam-subnet-meta code,
.ipam-conflicts code,
.ipam-unparsed code {
font-size: 0.74rem;
}
.ipam-conflicts,
.ipam-unparsed {
margin: 0;
padding-left: 1.1rem;
font-size: 0.78rem;
}
.ipam-conflicts li {
color: #dc2626;
margin-bottom: 0.2rem;
}
.ipam-unparsed li {
margin-bottom: 0.2rem;
}
.ipam-empty {
margin: 0 0 0.4rem;
font-size: 0.76rem;
color: var(--muted-text);
}
.ipam-subnet-editor {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) 3.6rem auto;
gap: 0.35rem;
align-items: end;
margin-bottom: 0.45rem;
}
.ipam-subnet-editor label {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--muted-text);
}
.ipam-subnet-editor input {
border: 1px solid var(--panel-border);
background: var(--panel-bg);
color: var(--panel-contrast);
border-radius: 6px;
padding: 0.25rem 0.35rem;
font-size: 0.76rem;
min-width: 0;
}
.ipam-subnet-editor button {
border: 1px solid var(--panel-border);
background: var(--panel-bg);
color: var(--panel-contrast);
border-radius: 6px;
padding: 0.25rem 0.4rem;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.ip-conflict-hint {
display: block;
margin-top: 0.2rem;
font-size: 0.74rem;
font-weight: 600;
color: #dc2626;
}
.ip-suggest-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.ip-suggest-row input {
flex: 1;
min-width: 0;
}
.ip-suggest-row button {
border: 1px solid var(--panel-border);
background: var(--panel-bg);
color: var(--panel-contrast);
border-radius: 7px;
padding: 0.35rem 0.55rem;
font-size: 0.76rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.select-control { .select-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+240
View File
@@ -0,0 +1,240 @@
import type { NetworkData, Subnet } from '../types';
export type IpOwnerKind = 'machine' | 'device' | 'vm';
export interface IpAssignment {
ip: string;
ownerLabel: string;
ownerKind: IpOwnerKind;
}
export interface SubnetReport {
cidr: string;
name?: string;
vlanId?: number;
declared: boolean;
used: number;
capacity: number;
members: IpAssignment[];
}
export interface IpamReport {
subnets: SubnetReport[];
duplicates: Array<{ ip: string; owners: string[] }>;
unparsed: IpAssignment[];
}
/** Parses a dotted-quad IPv4 address into a 32-bit number; null for anything else ("DHCP", empty, IPv6). */
export function parseIpv4(text: string): number | null {
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text.trim());
if (!match) {
return null;
}
const octets = match.slice(1).map(Number);
if (octets.some((octet) => octet > 255)) {
return null;
}
return octets[0] * 2 ** 24 + octets[1] * 2 ** 16 + octets[2] * 256 + octets[3];
}
function formatIpv4(value: number): string {
return [
Math.floor(value / 2 ** 24) % 256,
Math.floor(value / 2 ** 16) % 256,
Math.floor(value / 256) % 256,
value % 256
].join('.');
}
export function parseCidr(cidr: string): { base: number; prefix: number } | null {
const slash = cidr.indexOf('/');
if (slash < 0) {
return null;
}
const base = parseIpv4(cidr.slice(0, slash));
const prefix = Number(cidr.slice(slash + 1));
if (base === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
return null;
}
const hostBlock = 2 ** (32 - prefix);
return { base: Math.floor(base / hostBlock) * hostBlock, prefix };
}
export function cidrContains(cidr: string, ip: string): boolean {
const parsed = parseCidr(cidr);
const value = parseIpv4(ip);
if (!parsed || value === null) {
return false;
}
const hostBlock = 2 ** (32 - parsed.prefix);
return Math.floor(value / hostBlock) * hostBlock === parsed.base;
}
export function inferSlash24(ip: string): string | null {
const value = parseIpv4(ip);
if (value === null) {
return null;
}
return `${formatIpv4(Math.floor(value / 256) * 256)}/24`;
}
export function collectIpAssignments(data: NetworkData): IpAssignment[] {
const assignments: IpAssignment[] = [];
for (const machine of data.machines) {
assignments.push({
ip: machine.ipAddress,
ownerLabel: machine.machineName,
ownerKind: 'machine'
});
for (const vm of machine.software?.vms ?? []) {
assignments.push({
ip: vm.ipAddress,
ownerLabel: `${vm.name} (VM on ${machine.machineName})`,
ownerKind: 'vm'
});
}
}
for (const device of data.devices) {
assignments.push({ ip: device.ipAddress, ownerLabel: device.name, ownerKind: 'device' });
}
return assignments;
}
function hostCapacity(prefix: number): number {
if (prefix >= 31) {
return prefix === 31 ? 2 : 1;
}
return 2 ** (32 - prefix) - 2;
}
export function buildIpamReport(data: NetworkData): IpamReport {
const assignments = collectIpAssignments(data);
const declared = data.subnets ?? [];
const parsed: Array<IpAssignment & { value: number }> = [];
const unparsed: IpAssignment[] = [];
for (const assignment of assignments) {
const value = parseIpv4(assignment.ip);
if (value === null) {
unparsed.push(assignment);
} else {
parsed.push({ ...assignment, value });
}
}
const byIp = new Map<string, IpAssignment[]>();
for (const assignment of parsed) {
const normalizedIp = formatIpv4(assignment.value);
const owners = byIp.get(normalizedIp) ?? [];
owners.push(assignment);
byIp.set(normalizedIp, owners);
}
const duplicates = [...byIp.entries()]
.filter(([, owners]) => owners.length > 1)
.map(([ip, owners]) => ({ ip, owners: owners.map((owner) => owner.ownerLabel) }))
.sort((a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0));
const subnets: SubnetReport[] = [];
const claimed = new Set<IpAssignment>();
const declaredSorted = [...declared].sort(
(a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0)
);
const reportBySubnet = new Map<Subnet, IpAssignment[]>();
for (const subnet of declaredSorted) {
reportBySubnet.set(subnet, []);
}
for (const assignment of parsed) {
// most-specific declared subnet wins; each IP is counted once
const home = declaredSorted.find((subnet) => cidrContains(subnet.cidr, assignment.ip));
if (home) {
reportBySubnet.get(home)?.push(assignment);
claimed.add(assignment);
}
}
for (const subnet of declared) {
const members = (reportBySubnet.get(subnet) ?? []).sort(
(a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0)
);
const prefix = parseCidr(subnet.cidr)?.prefix ?? 24;
subnets.push({
cidr: subnet.cidr,
name: subnet.name,
vlanId: subnet.vlanId,
declared: true,
used: new Set(members.map((member) => member.ip)).size,
capacity: hostCapacity(prefix),
members
});
}
const inferredGroups = new Map<string, IpAssignment[]>();
for (const assignment of parsed) {
if (claimed.has(assignment)) {
continue;
}
const inferred = inferSlash24(assignment.ip);
if (!inferred) {
continue;
}
const members = inferredGroups.get(inferred) ?? [];
members.push(assignment);
inferredGroups.set(inferred, members);
}
const inferredSorted = [...inferredGroups.entries()].sort(
(a, b) => (parseCidr(a[0])?.base ?? 0) - (parseCidr(b[0])?.base ?? 0)
);
for (const [cidr, members] of inferredSorted) {
members.sort((a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0));
subnets.push({
cidr,
declared: false,
used: new Set(members.map((member) => member.ip)).size,
capacity: hostCapacity(24),
members
});
}
return { subnets, duplicates, unparsed };
}
/**
* Lowest unused host address in the subnet, skipping the network address,
* broadcast address and .1 (conventionally the gateway). Defaults to the
* most-populated subnet when no CIDR is given.
*/
export function suggestNextFreeIp(data: NetworkData, cidr?: string): string | null {
const report = buildIpamReport(data);
const target = cidr
? (report.subnets.find((subnet) => subnet.cidr === cidr) ??
(parseCidr(cidr) ? { cidr, members: [] as IpAssignment[] } : undefined))
: [...report.subnets].sort((a, b) => b.used - a.used)[0];
if (!target) {
return null;
}
const parsed = parseCidr(target.cidr);
if (!parsed || parsed.prefix > 30) {
return null;
}
const used = new Set<number>();
for (const assignment of collectIpAssignments(data)) {
const value = parseIpv4(assignment.ip);
if (value !== null) {
used.add(value);
}
}
const size = 2 ** (32 - parsed.prefix);
const first = parsed.base + 1;
const last = parsed.base + size - 2;
for (let candidate = first; candidate <= last; candidate += 1) {
if (candidate % 256 === 1) {
continue;
}
if (!used.has(candidate)) {
return formatIpv4(candidate);
}
}
return null;
}
+81 -2
View File
@@ -6,7 +6,8 @@
* @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware * @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine * @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice * @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice
* @typedef {{ machines: Machine[]; devices: NetworkDevice[] }} NetworkData * @typedef {{ cidr: string; name?: string; vlanId?: number }} Subnet
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[] }} NetworkData
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult * @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions * @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions * @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
@@ -361,6 +362,57 @@ function normalizeDevice(issues, path, value) {
}; };
} }
const ipv4CidrPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/;
/**
* @param {string} cidr
* @returns {boolean}
*/
export function isValidIpv4Cidr(cidr) {
const match = ipv4CidrPattern.exec(cidr);
if (!match) {
return false;
}
const octets = match.slice(1, 5).map(Number);
const prefix = Number(match[5]);
return octets.every((octet) => octet <= 255) && prefix <= 32;
}
/**
* @param {ValidationIssue[]} issues
* @param {string} path
* @param {unknown} value
* @returns {Subnet | null}
*/
function normalizeSubnet(issues, path, value) {
if (!isRecord(value)) {
issues.push({ path, message: 'must be an object (subnet)' });
return null;
}
const cidr = readString(issues, `${path}.cidr`, value.cidr);
if (cidr && !isValidIpv4Cidr(cidr)) {
issues.push({ path: `${path}.cidr`, message: 'must be an IPv4 CIDR like "192.168.1.0/24"' });
return null;
}
const name = readString(issues, `${path}.name`, value.name, { optional: true, allowEmpty: true });
const vlanId = readNumber(issues, `${path}.vlanId`, value.vlanId, { optional: true, min: 1 });
if (typeof vlanId === 'number' && vlanId > 4094) {
issues.push({ path: `${path}.vlanId`, message: 'must be <= 4094' });
return null;
}
if (!cidr) {
return null;
}
return {
cidr,
...(name ? { name } : {}),
...(typeof vlanId === 'number' ? { vlanId } : {})
};
}
/** /**
* @param {unknown} value * @param {unknown} value
* @returns {ValidationResult} * @returns {ValidationResult}
@@ -438,9 +490,36 @@ export function validateNetworkData(value) {
} }
} }
const subnetsRaw = value.subnets;
const subnetsProvided = subnetsRaw !== undefined;
if (subnetsProvided && !Array.isArray(subnetsRaw)) {
issues.push({ path: '$.subnets', message: 'must be an array when provided' });
}
const subnets = [];
const subnetEntries = [];
for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw) ? subnetsRaw : []).entries()) {
const subnet = normalizeSubnet(issues, `$.subnets[${subnetIndex}]`, subnetValue);
if (subnet) {
subnets.push(subnet);
subnetEntries.push({ subnet, originalIndex: subnetIndex });
}
}
const seenCidrs = new Set();
for (const { subnet, originalIndex } of subnetEntries) {
if (seenCidrs.has(subnet.cidr)) {
issues.push({
path: `$.subnets[${originalIndex}].cidr`,
message: `duplicate subnet CIDR "${subnet.cidr}"`
});
}
seenCidrs.add(subnet.cidr);
}
const data = { const data = {
machines, machines,
devices devices,
...(subnetsProvided ? { subnets } : {})
}; };
return { return {
+7
View File
@@ -51,7 +51,14 @@ export interface NetworkDevice {
ports?: Port[]; ports?: Port[];
} }
export interface Subnet {
cidr: string;
name?: string;
vlanId?: number;
}
export interface NetworkData { export interface NetworkData {
machines: Machine[]; machines: Machine[];
devices: NetworkDevice[]; devices: NetworkDevice[];
subnets?: Subnet[];
} }
+7
View File
@@ -571,5 +571,12 @@
"notes": "Port link not yet modeled.", "notes": "Port link not yet modeled.",
"ports": [] "ports": []
} }
],
"subnets": [
{
"cidr": "10.0.0.0/24",
"name": "LAN",
"vlanId": 1
}
] ]
} }