mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 03:23:44 +00:00
feat(*): Add IPAM panel with subnet utilization and IP suggestions
This commit is contained in:
@@ -53,5 +53,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"subnets": [
|
||||||
|
{
|
||||||
|
"cidr": "192.168.1.0/24",
|
||||||
|
"name": "LAN",
|
||||||
|
"vlanId": 1
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
<span class="ip-suggest-row">
|
||||||
<input type="text" bind:value={newMachineDraft.ipAddress} />
|
<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
|
||||||
|
<span class="ip-suggest-row">
|
||||||
<input type="text" bind:value={newDeviceDraft.ipAddress} />
|
<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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user