Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee143e607a | |||
| fe1e3c2abf | |||
| ec7598e15c | |||
| b4f5f12731 | |||
| b4c7d1bce4 | |||
| 2144eda352 | |||
| acf63a9707 | |||
| 336ee5e91b | |||
| 9cea45945c | |||
| 72fd77b9c9 | |||
| b04bc0acc9 | |||
| d04536d09d | |||
| ad3dccbdd4 | |||
| 5bed9cafb8 | |||
| ca26ea9063 | |||
| b1a3f08d5e | |||
| 9ffd8c4750 | |||
| a192bcab06 | |||
| b107d5d357 | |||
| e6487f7d25 | |||
| 53059caf31 | |||
| 808fe5a440 | |||
| c2bffede3e | |||
| 2fd4291bb9 | |||
| a09c98ec46 | |||
| bf0eac88f4 | |||
| 129041368b | |||
| 3615c38794 | |||
| 3ce0adfa0b | |||
| e7dd300098 | |||
| d2826f4cb3 | |||
| b1fb4fc141 |
@@ -16,14 +16,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: docker.io/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build (PR validation only)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Docker Hub Description
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- README.md
|
||||
- .github/workflows/dockerhub-description.yml
|
||||
|
||||
jobs:
|
||||
dockerhub-description:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Preflight Docker Hub repository access
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_REPOSITORY: jcreek23/open-network-diagram
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_PASSWORD}" ]; then
|
||||
echo "Missing DOCKERHUB_USERNAME or DOCKERHUB_PASSWORD secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOGIN_RESPONSE="$(curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_PASSWORD}\"}" \
|
||||
https://hub.docker.com/v2/users/login/)"
|
||||
|
||||
TOKEN="$(printf '%s' "${LOGIN_RESPONSE}" | ruby -rjson -e 'input = STDIN.read; data = JSON.parse(input); puts data.fetch("token", "")')"
|
||||
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo "Docker Hub login succeeded but token was missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: JWT ${TOKEN}" \
|
||||
"https://hub.docker.com/v2/repositories/${DOCKERHUB_REPOSITORY}/" >/dev/null
|
||||
|
||||
echo "Docker Hub preflight access check passed for ${DOCKERHUB_REPOSITORY}."
|
||||
|
||||
- name: Update Docker Hub repository overview
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: jcreek23/open-network-diagram
|
||||
readme-filepath: ./README.md
|
||||
enable-url-completion: true
|
||||
@@ -13,27 +13,6 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
changed-files:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
readme: ${{ steps.filter.outputs.readme }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed files
|
||||
id: filter
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
readme:
|
||||
- README.md
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -48,18 +27,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Create Git tag + GitHub release
|
||||
id: semantic
|
||||
uses: cycjimmy/semantic-release-action@v5
|
||||
uses: cycjimmy/semantic-release-action@v6
|
||||
with:
|
||||
extra_plugins: |
|
||||
@semantic-release/commit-analyzer
|
||||
@@ -78,20 +57,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: docker.io/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -101,7 +80,7 @@ jobs:
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push release image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -110,23 +89,3 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
dockerhub-overview:
|
||||
needs: changed-files
|
||||
if: needs.changed-files.outputs.readme == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Docker Hub repository overview
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: ${{ env.IMAGE_NAME }}
|
||||
readme-filepath: ./README.md
|
||||
enable-url-completion: true
|
||||
|
||||
@@ -12,7 +12,7 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
||||
|
||||
- Homelab-friendly: run it in minutes with Docker.
|
||||
- Practical: edit in the UI and autosave to `network.json`.
|
||||
- Declarative: keep your topology in Git if you want.
|
||||
- Declarative: keep your topology in Git if you wish.
|
||||
|
||||
[Docker Hub](https://hub.docker.com/r/jcreek23/open-network-diagram) | [Live Demo (Read-Only)](https://opennetworkdiagram.jcreek.co.uk) | [GitHub Releases](https://github.com/jcreek/OpenNetworkDiagram/releases)
|
||||
|
||||
@@ -22,8 +22,13 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
||||
|
||||
- Network view with ethernet labels to make physical and logical links easy to read.
|
||||
- Non-network view for host-first inventory and service mapping.
|
||||
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
|
||||
- Node search that highlights matches by name, IP, role or OS and dims everything else.
|
||||
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
|
||||
- Subnet and VLAN declarations, with nodes colour-coded by VLAN and a clickable legend filter.
|
||||
- Cable tracking on connections (type, colour, length) with colour-coded links and hover details.
|
||||
- Expandable VM lists per machine for quick virtualization visibility.
|
||||
- Modal editor for machines/devices with live diagram updates.
|
||||
- Modal editor for machines/devices with live diagram updates, notes and MAC address fields.
|
||||
- JSON-backed persistence with autosave in self-hosted mode.
|
||||
- Docker-first deployment with writable data volume support.
|
||||
- Optional read-only mode for public demos and safe sharing.
|
||||
@@ -32,6 +37,8 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
||||
## Why Home Lab Users Use It
|
||||
|
||||
- Keep an always-up-to-date map of machines, VMs, and devices.
|
||||
- Find a free IP and spot duplicate assignments without a spreadsheet.
|
||||
- Know exactly what's in your rack and where, down to the U.
|
||||
- Edit quickly through a modal UI instead of hand-editing large diagrams.
|
||||
- Persist everything to JSON so backups and Git workflows stay simple.
|
||||
- Stay fully self-hosted with no runtime dependency on external APIs.
|
||||
@@ -74,10 +81,14 @@ docker rm open-network-diagram
|
||||
|
||||
## What It Looks Like
|
||||
|
||||
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
| IPAM panel with subnet utilization | Rack view with shelf items |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
## Docker Compose Option
|
||||
|
||||
If you prefer compose:
|
||||
@@ -128,6 +139,7 @@ pnpm run icons:manifest # regenerate local vendor icon manifest
|
||||
|
||||
- API endpoint: `GET/PUT /api/network-data`
|
||||
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
||||
- When writes are unavailable, API responses include `writableReason` for diagnostics.
|
||||
- Writes are persisted atomically to the configured data file
|
||||
- Rolling backups are kept in the backup directory (last 5)
|
||||
|
||||
@@ -150,25 +162,73 @@ Environment variables:
|
||||
"ipAddress": "10.0.0.3",
|
||||
"role": "Hypervisor",
|
||||
"operatingSystem": "Proxmox",
|
||||
"notes": "Primary router box.",
|
||||
"software": {
|
||||
"vms": [{ "name": "OpnSense", "role": "Router", "ipAddress": "10.0.0.4" }]
|
||||
"vms": [
|
||||
{
|
||||
"name": "OpnSense",
|
||||
"role": "Router",
|
||||
"ipAddress": "10.0.0.4",
|
||||
"macAddress": "bc:24:11:5a:2e:01"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Intel N100",
|
||||
"ram": "8GB",
|
||||
"networkPorts": 4
|
||||
}
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Switch",
|
||||
"port": "1",
|
||||
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"rack": { "name": "Lab Rack", "unit": 10 }
|
||||
}
|
||||
],
|
||||
"devices": [{ "name": "Switch", "ipAddress": "10.0.0.2", "type": "Nintendo Switch" }]
|
||||
"devices": [
|
||||
{
|
||||
"name": "Switch",
|
||||
"ipAddress": "10.0.0.2",
|
||||
"type": "Network Switch",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "1",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "ProxRouter",
|
||||
"port": "eth0",
|
||||
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"rack": { "name": "Lab Rack", "unit": 12 }
|
||||
}
|
||||
],
|
||||
"subnets": [{ "cidr": "10.0.0.0/24", "name": "LAN", "vlanId": 1 }],
|
||||
"racks": [{ "name": "Lab Rack", "heightU": 12 }]
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the optional fields:
|
||||
|
||||
- `subnets` powers the IPAM panel and VLAN colouring (`vlanId` is optional per subnet).
|
||||
- `racks` declares rack names and total units for the rack view; machines/devices opt in with a `rack` placement (`unit` is the bottom U, `heightU` defaults to 1). Items with the identical U range render side by side as shelf-mates.
|
||||
- `cable` on a `connectedTo` records type/colour/length and is kept identical on both ends automatically.
|
||||
- `notes` (machines and devices) and `macAddress` (ports and VMs) are free text.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
OpenNetworkDiagram/
|
||||
├── src/ # Svelte app source
|
||||
├── src/lib/shared/ # Schema + persistence core (shared with server.mjs)
|
||||
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
|
||||
├── static/data/network.json # Demo dataset (Netlify)
|
||||
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"ipAddress": "192.168.1.10",
|
||||
"role": "Hypervisor",
|
||||
"operatingSystem": "Linux",
|
||||
"notes": "Optional notes",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"macAddress": "aa:bb:cc:dd:ee:01",
|
||||
"connectedTo": {
|
||||
"device": "Example Switch",
|
||||
"port": "1"
|
||||
@@ -20,7 +22,8 @@
|
||||
{
|
||||
"name": "Example VM",
|
||||
"role": "Service",
|
||||
"ipAddress": "192.168.1.20"
|
||||
"ipAddress": "192.168.1.20",
|
||||
"macAddress": "aa:bb:cc:dd:ee:02"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -39,6 +42,10 @@
|
||||
"ipAddress": "192.168.1.2",
|
||||
"type": "Network Switch",
|
||||
"notes": "Optional notes",
|
||||
"rack": {
|
||||
"name": "Example Rack",
|
||||
"unit": 12
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "1",
|
||||
@@ -50,5 +57,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"subnets": [
|
||||
{
|
||||
"cidr": "192.168.1.0/24",
|
||||
"name": "LAN",
|
||||
"vlanId": 1
|
||||
}
|
||||
],
|
||||
"racks": [
|
||||
{
|
||||
"name": "Example Rack",
|
||||
"heightU": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -4,8 +4,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
||||
import {
|
||||
checkWritableState,
|
||||
isWriteEnabled,
|
||||
getWritableState,
|
||||
readNetworkFile,
|
||||
writeNetworkFile
|
||||
} from './src/lib/shared/networkPersistenceCore.mjs';
|
||||
@@ -50,10 +49,17 @@ function badRequest(response, message, details) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveReadOnlyErrorMessage(reason) {
|
||||
if (!reason) {
|
||||
return 'Write API unavailable in current deployment.';
|
||||
}
|
||||
return `Write API unavailable: ${reason}`;
|
||||
}
|
||||
|
||||
async function serveApi(request, response) {
|
||||
if (request.method === 'GET') {
|
||||
try {
|
||||
const payload = await readNetworkFile();
|
||||
const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]);
|
||||
const validation = validateNetworkData(payload.data);
|
||||
if (!validation.valid) {
|
||||
sendJson(response, 500, {
|
||||
@@ -63,11 +69,10 @@ async function serveApi(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canWrite = await checkWritableState();
|
||||
|
||||
sendJson(response, 200, {
|
||||
data: validation.data,
|
||||
writable: canWrite,
|
||||
writable: writableState.writable,
|
||||
writableReason: writableState.reason,
|
||||
source: payload.source,
|
||||
updatedAt: payload.updatedAt
|
||||
});
|
||||
@@ -80,9 +85,12 @@ async function serveApi(request, response) {
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
if (!isWriteEnabled()) {
|
||||
const writableStateBeforeWrite = await getWritableState();
|
||||
if (!writableStateBeforeWrite.writable) {
|
||||
sendJson(response, 403, {
|
||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
||||
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||
writable: false,
|
||||
writableReason: writableStateBeforeWrite.reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -123,9 +131,11 @@ async function serveApi(request, response) {
|
||||
|
||||
try {
|
||||
const metadata = await writeNetworkFile(validation.data);
|
||||
const writableStateAfterWrite = await getWritableState();
|
||||
sendJson(response, 200, {
|
||||
data: validation.data,
|
||||
writable: true,
|
||||
writable: writableStateAfterWrite.writable,
|
||||
writableReason: writableStateAfterWrite.reason,
|
||||
source: metadata.source,
|
||||
updatedAt: metadata.updatedAt
|
||||
});
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { resolveIconPath } from '$lib/config/iconRegistry';
|
||||
import type { RackLayout, RackSlot } from '$lib/data/rackLayout';
|
||||
|
||||
export let layout: RackLayout;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: { kind: RackSlot['kind']; name: string };
|
||||
}>();
|
||||
|
||||
const unitHeightPx = 34;
|
||||
|
||||
function slotStyle(slot: RackSlot, rackHeightU: number): string {
|
||||
const top = (rackHeightU - (slot.unit + slot.heightU - 1)) * unitHeightPx;
|
||||
const laneWidth = `(100% - 2.45rem) / ${slot.laneCount}`;
|
||||
const left = `calc(2.1rem + (${laneWidth}) * ${slot.laneIndex})`;
|
||||
const width = `calc(${laneWidth} - 4px)`;
|
||||
return `top: ${top}px; height: ${slot.heightU * unitHeightPx - 4}px; left: ${left}; width: ${width};`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rack-view">
|
||||
{#if layout.racks.length === 0}
|
||||
<div class="rack-empty">
|
||||
<h3>No rack placements yet</h3>
|
||||
<p>
|
||||
Open a machine or device and fill in the Rack section (rack name + U position) to see it
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rack-columns">
|
||||
{#each layout.racks as rack (rack.name)}
|
||||
<div class="rack-column">
|
||||
<h3>{rack.name} <span class="rack-height-tag">{rack.heightU}U</span></h3>
|
||||
<div class="rack-frame" style={`height: ${rack.heightU * unitHeightPx}px;`}>
|
||||
{#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)}
|
||||
<div class="rack-unit-row" style={`height: ${unitHeightPx}px;`}>
|
||||
<span class="rack-unit-label">{unit}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#each rack.slots as slot (`${slot.kind}:${slot.name}`)}
|
||||
<button
|
||||
type="button"
|
||||
class="rack-slot"
|
||||
class:machine={slot.kind === 'machine'}
|
||||
class:device={slot.kind === 'device'}
|
||||
style={slotStyle(slot, rack.heightU)}
|
||||
title={`${slot.name} — U${slot.unit}${slot.heightU > 1 ? `–U${slot.unit + slot.heightU - 1}` : ''}`}
|
||||
on:click={() => dispatch('select', { kind: slot.kind, name: slot.name })}
|
||||
>
|
||||
{#if resolveIconPath(slot.iconKey)}
|
||||
<img src={resolveIconPath(slot.iconKey)} alt="" loading="lazy" />
|
||||
{/if}
|
||||
<span class="rack-slot-text">
|
||||
<span class="rack-slot-name">{slot.name}</span>
|
||||
<span class="rack-slot-subtitle">{slot.subtitle}</span>
|
||||
</span>
|
||||
<span class="rack-slot-units">{slot.heightU}U</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if layout.unrackedCount > 0}
|
||||
<p class="rack-footnote">
|
||||
{layout.unrackedCount}
|
||||
{layout.unrackedCount === 1 ? 'entity is' : 'entities are'} not rack-mounted.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rack-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
padding: 10rem 1rem 1rem;
|
||||
background: var(--app-bg, transparent);
|
||||
}
|
||||
|
||||
.rack-empty {
|
||||
max-width: 420px;
|
||||
margin: 4rem auto 0;
|
||||
text-align: center;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.rack-empty h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--panel-contrast);
|
||||
}
|
||||
|
||||
.rack-columns {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rack-column h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
color: var(--panel-contrast);
|
||||
}
|
||||
|
||||
.rack-height-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.rack-frame {
|
||||
position: relative;
|
||||
width: min(86vw, 340px);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, var(--panel-bg) 88%, transparent 12%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rack-unit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px dashed color-mix(in oklab, var(--panel-border) 55%, transparent 45%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rack-unit-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rack-unit-label {
|
||||
width: 1.9rem;
|
||||
text-align: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.rack-slot {
|
||||
position: absolute;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid;
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rack-slot.machine {
|
||||
background: color-mix(in oklab, var(--panel-bg) 55%, #3b82f6 45%);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.rack-slot.device {
|
||||
background: color-mix(in oklab, var(--panel-bg) 55%, #d97706 45%);
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
.rack-slot:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.rack-slot img {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rack-slot-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rack-slot-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--panel-contrast);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rack-slot-subtitle {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rack-slot-units {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rack-footnote {
|
||||
text-align: center;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
|
||||
import type { CableInfo, Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
|
||||
|
||||
export type OwnerKind = 'machine' | 'device';
|
||||
|
||||
@@ -119,9 +119,10 @@ function ensureReciprocalConnections(data: NetworkData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedReciprocal = {
|
||||
const expectedReciprocal: Port['connectedTo'] = {
|
||||
device: owner.name,
|
||||
port: port.portName
|
||||
port: port.portName,
|
||||
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
|
||||
};
|
||||
if (!targetPort.connectedTo) {
|
||||
targetPort.connectedTo = expectedReciprocal;
|
||||
@@ -132,6 +133,19 @@ function ensureReciprocalConnections(data: NetworkData) {
|
||||
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
|
||||
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
|
||||
if (hasMatchingReciprocal) {
|
||||
// keep cable metadata identical on both ends; the lexicographically
|
||||
// smaller owner:port key is the canonical copy so the sync is deterministic
|
||||
const ownKey = `${owner.name}:${port.portName}`.toLowerCase();
|
||||
const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase();
|
||||
const [from, to] =
|
||||
ownKey <= targetKey
|
||||
? [port.connectedTo, targetPort.connectedTo]
|
||||
: [targetPort.connectedTo, port.connectedTo];
|
||||
if (from.cable) {
|
||||
to.cable = deepClone(from.cable);
|
||||
} else {
|
||||
delete to.cable;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -330,6 +344,83 @@ export function deletePort(
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function renameRack(data: NetworkData, previousName: string, nextName: string): NetworkData {
|
||||
const cloned = cloneNetworkData(data);
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || equalsIgnoreCase(previousName, trimmed)) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
for (const machine of cloned.machines) {
|
||||
if (machine.rack && equalsIgnoreCase(machine.rack.name, previousName)) {
|
||||
machine.rack.name = trimmed;
|
||||
}
|
||||
}
|
||||
for (const device of cloned.devices) {
|
||||
if (device.rack && equalsIgnoreCase(device.rack.name, previousName)) {
|
||||
device.rack.name = trimmed;
|
||||
}
|
||||
}
|
||||
const definition = cloned.racks?.find((rack) => equalsIgnoreCase(rack.name, previousName));
|
||||
if (definition) {
|
||||
definition.name = trimmed;
|
||||
}
|
||||
// renaming onto an existing rack merges them; keep the first definition
|
||||
if (cloned.racks) {
|
||||
const seen = new Set<string>();
|
||||
cloned.racks = cloned.racks.filter((rack) => {
|
||||
const key = rack.name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function setPortCable(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
ownerName: string,
|
||||
portName: string,
|
||||
cable: CableInfo | undefined
|
||||
): NetworkData {
|
||||
const cloned = cloneNetworkData(data);
|
||||
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
|
||||
const port = owner ? findPort(owner, portName) : undefined;
|
||||
if (!owner || !port?.connectedTo) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const entries = Object.entries(cable ?? {}).filter(([, value]) =>
|
||||
typeof value === 'number' ? Number.isFinite(value) : Boolean(value)
|
||||
);
|
||||
const normalized = entries.length > 0 ? (Object.fromEntries(entries) as CableInfo) : undefined;
|
||||
|
||||
if (normalized) {
|
||||
port.connectedTo.cable = deepClone(normalized);
|
||||
} else {
|
||||
delete port.connectedTo.cable;
|
||||
}
|
||||
|
||||
const peerOwner = findOwnerByName(cloned, port.connectedTo.device);
|
||||
const peerPort = peerOwner ? findPort(peerOwner, port.connectedTo.port) : undefined;
|
||||
if (
|
||||
peerPort?.connectedTo &&
|
||||
equalsIgnoreCase(peerPort.connectedTo.device, owner.name) &&
|
||||
equalsIgnoreCase(peerPort.connectedTo.port, port.portName)
|
||||
) {
|
||||
if (normalized) {
|
||||
peerPort.connectedTo.cable = deepClone(normalized);
|
||||
} else {
|
||||
delete peerPort.connectedTo.cable;
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function setPortConnection(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { NetworkData } from '../types';
|
||||
|
||||
export interface RackSlot {
|
||||
kind: 'machine' | 'device';
|
||||
name: string;
|
||||
index: number;
|
||||
unit: number;
|
||||
heightU: number;
|
||||
iconKey?: string;
|
||||
subtitle: string;
|
||||
/** Horizontal lane when several items share the same U (e.g. on a shelf). */
|
||||
laneIndex: number;
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
export interface RackColumn {
|
||||
name: string;
|
||||
heightU: number;
|
||||
declared: boolean;
|
||||
declaredHeightU?: number;
|
||||
slots: RackSlot[];
|
||||
}
|
||||
|
||||
export interface RackLayout {
|
||||
racks: RackColumn[];
|
||||
warnings: string[];
|
||||
unrackedCount: number;
|
||||
}
|
||||
|
||||
const minimumRackHeightU = 12;
|
||||
|
||||
function slotsOverlap(a: RackSlot, b: RackSlot): boolean {
|
||||
const aTop = a.unit + a.heightU - 1;
|
||||
const bTop = b.unit + b.heightU - 1;
|
||||
return a.unit <= bTop && b.unit <= aTop;
|
||||
}
|
||||
|
||||
function sameRange(a: RackSlot, b: RackSlot): boolean {
|
||||
return a.unit === b.unit && a.heightU === b.heightU;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns horizontal lanes so overlapping items render side by side.
|
||||
* Items occupying the exact same U range are treated as sharing a shelf and
|
||||
* do not warn; partially overlapping ranges are reported as conflicts.
|
||||
*/
|
||||
function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) {
|
||||
// connected components of the overlap graph
|
||||
const componentOf = new Map<RackSlot, number>();
|
||||
let componentCount = 0;
|
||||
for (const slot of slots) {
|
||||
const touching = slots.filter((other) => componentOf.has(other) && slotsOverlap(slot, other));
|
||||
const componentIds = new Set(touching.map((other) => componentOf.get(other) ?? 0));
|
||||
if (componentIds.size === 0) {
|
||||
componentOf.set(slot, componentCount);
|
||||
componentCount += 1;
|
||||
continue;
|
||||
}
|
||||
const [target] = [...componentIds].sort((a, b) => a - b);
|
||||
componentOf.set(slot, target);
|
||||
for (const [other, id] of componentOf.entries()) {
|
||||
if (componentIds.has(id)) {
|
||||
componentOf.set(other, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentSlots = new Map<number, RackSlot[]>();
|
||||
for (const slot of slots) {
|
||||
const id = componentOf.get(slot) ?? 0;
|
||||
const members = componentSlots.get(id) ?? [];
|
||||
members.push(slot);
|
||||
componentSlots.set(id, members);
|
||||
}
|
||||
|
||||
for (const members of componentSlots.values()) {
|
||||
const ordered = [...members].sort((a, b) => a.unit - b.unit || a.name.localeCompare(b.name));
|
||||
const lanes: RackSlot[][] = [];
|
||||
for (const slot of ordered) {
|
||||
let placed = false;
|
||||
for (const [laneIndex, lane] of lanes.entries()) {
|
||||
if (!lane.some((other) => slotsOverlap(slot, other))) {
|
||||
lane.push(slot);
|
||||
slot.laneIndex = laneIndex;
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
slot.laneIndex = lanes.length;
|
||||
lanes.push([slot]);
|
||||
}
|
||||
}
|
||||
for (const slot of members) {
|
||||
slot.laneCount = lanes.length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
for (let j = i + 1; j < ordered.length; j += 1) {
|
||||
const a = ordered[i];
|
||||
const b = ordered[j];
|
||||
if (slotsOverlap(a, b) && !sameRange(a, b)) {
|
||||
warnings.push(
|
||||
`Rack "${rackName}": "${a.name}" (U${a.unit}–U${a.unit + a.heightU - 1}) partially overlaps "${b.name}" (U${b.unit}–U${b.unit + b.heightU - 1}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRackLayout(data: NetworkData): RackLayout {
|
||||
const slotsByRack = new Map<string, RackSlot[]>();
|
||||
const canonicalNames = new Map<string, string>();
|
||||
let unrackedCount = 0;
|
||||
|
||||
const canonical = (rawName: string): string => {
|
||||
const trimmed = rawName.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
const existing = canonicalNames.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
canonicalNames.set(key, trimmed);
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
for (const definition of data.racks ?? []) {
|
||||
const name = canonical(definition.name);
|
||||
if (!slotsByRack.has(name)) {
|
||||
slotsByRack.set(name, []);
|
||||
}
|
||||
}
|
||||
|
||||
const place = (
|
||||
slot: Omit<RackSlot, 'heightU' | 'laneIndex' | 'laneCount'> & { heightU?: number },
|
||||
rackName: string
|
||||
) => {
|
||||
const name = canonical(rackName);
|
||||
const slots = slotsByRack.get(name) ?? [];
|
||||
slots.push({
|
||||
...slot,
|
||||
heightU: Math.max(1, Math.round(slot.heightU ?? 1)),
|
||||
laneIndex: 0,
|
||||
laneCount: 1
|
||||
});
|
||||
slotsByRack.set(name, slots);
|
||||
};
|
||||
|
||||
for (const [index, machine] of data.machines.entries()) {
|
||||
if (!machine.rack?.name) {
|
||||
unrackedCount += 1;
|
||||
continue;
|
||||
}
|
||||
place(
|
||||
{
|
||||
kind: 'machine',
|
||||
name: machine.machineName,
|
||||
index,
|
||||
unit: machine.rack.unit,
|
||||
heightU: machine.rack.heightU,
|
||||
iconKey: machine.iconKey,
|
||||
subtitle: machine.role
|
||||
},
|
||||
machine.rack.name
|
||||
);
|
||||
}
|
||||
for (const [index, device] of data.devices.entries()) {
|
||||
if (!device.rack?.name) {
|
||||
unrackedCount += 1;
|
||||
continue;
|
||||
}
|
||||
place(
|
||||
{
|
||||
kind: 'device',
|
||||
name: device.name,
|
||||
index,
|
||||
unit: device.rack.unit,
|
||||
heightU: device.rack.heightU,
|
||||
iconKey: device.iconKey,
|
||||
subtitle: device.type
|
||||
},
|
||||
device.rack.name
|
||||
);
|
||||
}
|
||||
|
||||
const declaredByName = new Map(
|
||||
(data.racks ?? []).map((definition) => [definition.name.trim().toLowerCase(), definition])
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const racks: RackColumn[] = [...slotsByRack.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, slots]) => {
|
||||
const sorted = [...slots].sort((a, b) => b.unit - a.unit || a.name.localeCompare(b.name));
|
||||
assignLanes(sorted, name, warnings);
|
||||
const topUnit =
|
||||
sorted.length > 0 ? Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)) : 0;
|
||||
const definition = declaredByName.get(name.toLowerCase());
|
||||
const declaredHeightU = definition?.heightU;
|
||||
if (typeof declaredHeightU === 'number' && topUnit > declaredHeightU) {
|
||||
warnings.push(
|
||||
`Rack "${name}" is declared as ${declaredHeightU}U but items reach U${topUnit}.`
|
||||
);
|
||||
}
|
||||
const heightU =
|
||||
typeof declaredHeightU === 'number'
|
||||
? Math.max(declaredHeightU, topUnit)
|
||||
: Math.max(minimumRackHeightU, topUnit);
|
||||
return {
|
||||
name,
|
||||
heightU,
|
||||
declared: Boolean(definition),
|
||||
declaredHeightU,
|
||||
slots: sorted
|
||||
};
|
||||
});
|
||||
|
||||
return { racks, warnings, unrackedCount };
|
||||
}
|
||||
|
||||
export function knownRackNames(data: NetworkData): string[] {
|
||||
const names = new Map<string, string>();
|
||||
const add = (name: string | undefined) => {
|
||||
const trimmed = name?.trim();
|
||||
if (trimmed && !names.has(trimmed.toLowerCase())) {
|
||||
names.set(trimmed.toLowerCase(), trimmed);
|
||||
}
|
||||
};
|
||||
for (const definition of data.racks ?? []) {
|
||||
add(definition.name);
|
||||
}
|
||||
for (const machine of data.machines) {
|
||||
add(machine.rack?.name);
|
||||
}
|
||||
for (const device of data.devices) {
|
||||
add(device.rack?.name);
|
||||
}
|
||||
return [...names.values()].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { GraphNodeDetails, GraphNodeElement, GraphTransformResult } from './types';
|
||||
|
||||
function collectSearchableText(node: GraphNodeElement): string[] {
|
||||
const texts: string[] = [node.data.rawName ?? node.data.label];
|
||||
const details = node.data.details as GraphNodeDetails | undefined;
|
||||
if (!details) {
|
||||
return texts;
|
||||
}
|
||||
|
||||
texts.push(details.ip);
|
||||
if (details.type === 'machine') {
|
||||
texts.push(details.role, details.os);
|
||||
for (const vm of details.vms) {
|
||||
texts.push(vm.name, vm.ip, vm.role);
|
||||
}
|
||||
} else if (details.type === 'device') {
|
||||
texts.push(details.deviceType);
|
||||
} else {
|
||||
texts.push(details.role);
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of nodes matching the query (case-insensitive substring on
|
||||
* name, IP, role, OS and device type). When a VM matches, its host machine ID
|
||||
* is included too so hits remain visible while the host's VMs are collapsed.
|
||||
*/
|
||||
export function computeSearchMatches(
|
||||
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||
query: string
|
||||
): Set<string> {
|
||||
const matches = new Set<string>();
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return matches;
|
||||
}
|
||||
|
||||
for (const node of transformed.nodes) {
|
||||
const isMatch = collectSearchableText(node).some((text) =>
|
||||
text.toLowerCase().includes(normalized)
|
||||
);
|
||||
if (!isMatch) {
|
||||
continue;
|
||||
}
|
||||
matches.add(node.data.id);
|
||||
if (node.data.kind === 'vm' && node.data.hostMachineId) {
|
||||
matches.add(node.data.hostMachineId);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ConnectedPortRef, NetworkData, Port } from '../types';
|
||||
import type { ConnectedPortRef, NetworkData, Port, Subnet } from '../types';
|
||||
import { cidrContains, parseCidr } from '../data/ipam';
|
||||
import { resolveIconPath } from '../config/iconRegistry';
|
||||
import { vlanColor } from './vlanPalette';
|
||||
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
|
||||
|
||||
interface EndpointOwner {
|
||||
@@ -55,6 +57,41 @@ function connectionKey(a: string, b: string): string {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
}
|
||||
|
||||
const cableColorHex: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
purple: '#a855f7',
|
||||
pink: '#ec4899',
|
||||
white: '#e2e8f0',
|
||||
grey: '#94a3b8',
|
||||
gray: '#94a3b8',
|
||||
black: '#334155'
|
||||
};
|
||||
|
||||
function resolveCableColor(name: string | undefined): string | undefined {
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
return cableColorHex[trimmed.toLowerCase()] ?? (/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined);
|
||||
}
|
||||
|
||||
function buildVlanResolver(subnets: Subnet[] | undefined) {
|
||||
const vlanSubnets = (subnets ?? [])
|
||||
.filter((subnet) => typeof subnet.vlanId === 'number')
|
||||
.sort((a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0));
|
||||
return (ip: string): { vlanId: number; vlanColor: string } | undefined => {
|
||||
const home = vlanSubnets.find((subnet) => cidrContains(subnet.cidr, ip));
|
||||
if (!home || typeof home.vlanId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return { vlanId: home.vlanId, vlanColor: vlanColor(home.vlanId) };
|
||||
};
|
||||
}
|
||||
|
||||
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Math.min(a, b);
|
||||
@@ -82,6 +119,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
> = {};
|
||||
const ownersByLookup = new Map<string, EndpointOwner>();
|
||||
const seenWarnings = new Set<string>();
|
||||
const resolveVlan = buildVlanResolver(data.subnets);
|
||||
|
||||
const warn = (message: string) => {
|
||||
if (!seenWarnings.has(message)) {
|
||||
@@ -110,7 +148,8 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
const machineVms = sourceMachineVms.map((vm) => ({
|
||||
name: vm.name,
|
||||
ip: vm.ipAddress,
|
||||
role: vm.role
|
||||
role: vm.role,
|
||||
macAddress: vm.macAddress
|
||||
}));
|
||||
|
||||
nodes.push({
|
||||
@@ -123,6 +162,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
iconUrl: resolveIconPath(machine.iconKey),
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 110,
|
||||
...resolveVlan(machine.ipAddress),
|
||||
details: {
|
||||
type: 'machine',
|
||||
name: machine.machineName,
|
||||
@@ -133,6 +173,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
cpu: machine.hardware?.cpu ?? 'Unknown',
|
||||
ram: machine.hardware?.ram ?? 'Unknown',
|
||||
gpu: machine.hardware?.gpu ?? undefined,
|
||||
notes: machine.notes,
|
||||
ports: machinePortsList,
|
||||
vmCount: machineVms.length,
|
||||
vms: machineVms
|
||||
@@ -160,12 +201,14 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
iconUrl: resolveIconPath(vm.iconKey),
|
||||
nodeWidth: 140,
|
||||
nodeHeight: 66,
|
||||
...resolveVlan(vm.ipAddress),
|
||||
details: {
|
||||
type: 'vm',
|
||||
name: vm.name,
|
||||
iconKey: vm.iconKey,
|
||||
ip: vm.ipAddress,
|
||||
role: vm.role,
|
||||
macAddress: vm.macAddress,
|
||||
hostName: machine.machineName
|
||||
}
|
||||
}
|
||||
@@ -214,6 +257,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
iconUrl: resolveIconPath(device.iconKey),
|
||||
nodeWidth: 176,
|
||||
nodeHeight: 116,
|
||||
...resolveVlan(device.ipAddress),
|
||||
details: {
|
||||
type: 'device',
|
||||
name: device.name,
|
||||
@@ -293,6 +337,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
|
||||
physicalEdgeCounter += 1;
|
||||
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
|
||||
const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable;
|
||||
|
||||
edges.push({
|
||||
data: {
|
||||
@@ -304,7 +349,11 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
sourcePort: port.portName,
|
||||
targetPort: connectedTo.port,
|
||||
speedGbps,
|
||||
reciprocal
|
||||
reciprocal,
|
||||
...(cable?.type ? { cableType: cable.type } : {}),
|
||||
...(cable?.color ? { cableColorName: cable.color } : {}),
|
||||
...(resolveCableColor(cable?.color) ? { cableColor: resolveCableColor(cable?.color) } : {}),
|
||||
...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ export type GraphEdgeKind = 'physical' | 'hosting';
|
||||
export interface PortDetails {
|
||||
portName: string;
|
||||
speedGbps?: number;
|
||||
macAddress?: string;
|
||||
connectedTo?: {
|
||||
device: string;
|
||||
port: string;
|
||||
cable?: {
|
||||
type?: string;
|
||||
color?: string;
|
||||
lengthM?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,12 +26,14 @@ export interface MachineDetails {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
gpu?: string;
|
||||
notes?: string;
|
||||
ports: PortDetails[];
|
||||
vmCount: number;
|
||||
vms: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
role: string;
|
||||
macAddress?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -45,6 +53,7 @@ export interface VmDetails {
|
||||
iconKey?: string;
|
||||
ip: string;
|
||||
role: string;
|
||||
macAddress?: string;
|
||||
hostName: string;
|
||||
}
|
||||
|
||||
@@ -60,6 +69,8 @@ export interface GraphNodeData {
|
||||
nodeHeight?: number;
|
||||
iconUrl?: string;
|
||||
iconKey?: string;
|
||||
vlanId?: number;
|
||||
vlanColor?: string;
|
||||
details?: GraphNodeDetails;
|
||||
}
|
||||
|
||||
@@ -73,6 +84,10 @@ export interface GraphEdgeData {
|
||||
targetPort?: string;
|
||||
speedGbps?: number;
|
||||
reciprocal?: boolean;
|
||||
cableType?: string;
|
||||
cableColorName?: string;
|
||||
cableColor?: string;
|
||||
cableLengthM?: number;
|
||||
}
|
||||
|
||||
export interface GraphNodeElement {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Distinct from the kind colours (machine blue, VM green, device amber) so a
|
||||
// VLAN border reads as VLAN, not as an entity kind. Mid-saturation hues stay
|
||||
// legible against both the light and dark node fills.
|
||||
const palette = [
|
||||
'#e11d48',
|
||||
'#7c3aed',
|
||||
'#0891b2',
|
||||
'#ca8a04',
|
||||
'#15803d',
|
||||
'#c2410c',
|
||||
'#4f46e5',
|
||||
'#0d9488',
|
||||
'#be185d',
|
||||
'#57534e'
|
||||
];
|
||||
|
||||
export function vlanColor(vlanId: number): string {
|
||||
return palette[Math.abs(Math.trunc(vlanId)) % palette.length];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
checkWritableState as checkWritableStateCore,
|
||||
getWritableState as getWritableStateCore,
|
||||
isWriteEnabled as isWriteEnabledCore,
|
||||
readNetworkFile as readNetworkFileCore,
|
||||
writeNetworkFile as writeNetworkFileCore
|
||||
@@ -12,6 +13,11 @@ export interface NetworkFileMetadata {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WritableState {
|
||||
writable: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export function isWriteEnabled(): boolean {
|
||||
return isWriteEnabledCore();
|
||||
}
|
||||
@@ -24,6 +30,10 @@ export async function writeNetworkFile(data: NetworkData): Promise<NetworkFileMe
|
||||
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
||||
}
|
||||
|
||||
export async function getWritableState(): Promise<WritableState> {
|
||||
return (await getWritableStateCore()) as WritableState;
|
||||
}
|
||||
|
||||
export async function checkWritableState(): Promise<boolean> {
|
||||
return checkWritableStateCore();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import path from 'node:path';
|
||||
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
||||
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
||||
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
||||
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
||||
*/
|
||||
|
||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||
@@ -43,6 +44,38 @@ export function isWriteEnabled() {
|
||||
return !NETWORK_READ_ONLY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} error
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatFileSystemErrorCode(error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
|
||||
return error.code;
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} directory
|
||||
* @param {string} label
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function assertDirectoryWritable(directory, label) {
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
} catch (error) {
|
||||
return `${label} directory "${directory}" could not be created (${formatFileSystemErrorCode(error)}).`;
|
||||
}
|
||||
|
||||
try {
|
||||
await access(directory, constants.W_OK);
|
||||
} catch (error) {
|
||||
return `${label} directory "${directory}" is not writable (${formatFileSystemErrorCode(error)}).`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataFilePath
|
||||
* @returns {Promise<string>}
|
||||
@@ -151,20 +184,62 @@ export async function writeNetworkFile(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
* @returns {Promise<WritableState>}
|
||||
*/
|
||||
export async function checkWritableState() {
|
||||
export async function getWritableState() {
|
||||
if (!isWriteEnabled()) {
|
||||
return false;
|
||||
return {
|
||||
writable: false,
|
||||
reason: 'Writes disabled by NETWORK_READ_ONLY=true.'
|
||||
};
|
||||
}
|
||||
|
||||
const source = resolveDataFilePath();
|
||||
const directory = path.dirname(source);
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await access(directory, constants.W_OK);
|
||||
} catch {
|
||||
return false;
|
||||
const dataDirectoryError = await assertDirectoryWritable(directory, 'Data');
|
||||
if (dataDirectoryError) {
|
||||
return {
|
||||
writable: false,
|
||||
reason: dataDirectoryError
|
||||
};
|
||||
}
|
||||
return true;
|
||||
|
||||
const backupDirectory = resolveBackupDirectory();
|
||||
const backupDirectoryError = await assertDirectoryWritable(backupDirectory, 'Backup');
|
||||
if (backupDirectoryError) {
|
||||
return {
|
||||
writable: false,
|
||||
reason: backupDirectoryError
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await access(source, constants.F_OK);
|
||||
} catch {
|
||||
return {
|
||||
writable: true,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await access(source, constants.R_OK | constants.W_OK);
|
||||
} catch (error) {
|
||||
return {
|
||||
writable: false,
|
||||
reason: `Data file "${source}" exists but is not readable and writable (${formatFileSystemErrorCode(error)}).`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
writable: true,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function checkWritableState() {
|
||||
return (await getWritableState()).writable;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* @typedef {{ path: string; message: string }} ValidationIssue
|
||||
* @typedef {{ device: string; port: string }} ConnectedTo
|
||||
* @typedef {{ portName: string; speedGbps?: number; connectedTo?: ConnectedTo }} Port
|
||||
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string }} VM
|
||||
* @typedef {{ type?: string; color?: string; lengthM?: number }} CableInfo
|
||||
* @typedef {{ device: string; port: string; cable?: CableInfo }} ConnectedTo
|
||||
* @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port
|
||||
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM
|
||||
* @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware
|
||||
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine
|
||||
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice
|
||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[] }} NetworkData
|
||||
* @typedef {{ name: string; unit: number; heightU?: number }} RackPlacement
|
||||
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[]; rack?: RackPlacement }} Machine
|
||||
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[]; rack?: RackPlacement }} NetworkDevice
|
||||
* @typedef {{ cidr: string; name?: string; vlanId?: number }} Subnet
|
||||
* @typedef {{ name: string; heightU?: number }} RackDefinition
|
||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[]; racks?: RackDefinition[] }} NetworkData
|
||||
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
||||
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
||||
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
||||
@@ -105,6 +109,10 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
||||
optional: true,
|
||||
min: 0
|
||||
});
|
||||
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
|
||||
let connectedTo;
|
||||
if (value.connectedTo !== undefined) {
|
||||
@@ -113,8 +121,36 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
||||
} else {
|
||||
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
|
||||
const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port);
|
||||
let cable;
|
||||
const cableRaw = value.connectedTo.cable;
|
||||
if (cableRaw !== undefined) {
|
||||
if (!isRecord(cableRaw)) {
|
||||
issues.push({ path: `${path}.connectedTo.cable`, message: 'must be an object' });
|
||||
} else {
|
||||
const cableType = readString(issues, `${path}.connectedTo.cable.type`, cableRaw.type, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
const cableColor = readString(issues, `${path}.connectedTo.cable.color`, cableRaw.color, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
const lengthM = readNumber(issues, `${path}.connectedTo.cable.lengthM`, cableRaw.lengthM, {
|
||||
optional: true,
|
||||
min: 0
|
||||
});
|
||||
const candidate = {
|
||||
...(cableType ? { type: cableType } : {}),
|
||||
...(cableColor ? { color: cableColor } : {}),
|
||||
...(typeof lengthM === 'number' ? { lengthM } : {})
|
||||
};
|
||||
if (Object.keys(candidate).length > 0) {
|
||||
cable = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (device && port) {
|
||||
connectedTo = { device, port };
|
||||
connectedTo = { device, port, ...(cable ? { cable } : {}) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,10 +162,38 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
||||
return {
|
||||
portName,
|
||||
...(typeof speedGbps === 'number' ? { speedGbps } : {}),
|
||||
...(macAddress ? { macAddress } : {}),
|
||||
...(connectedTo ? { connectedTo } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ValidationIssue[]} issues
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {RackPlacement | undefined}
|
||||
*/
|
||||
function normalizeRack(issues, path, value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
issues.push({ path, message: 'must be an object (rack placement)' });
|
||||
return undefined;
|
||||
}
|
||||
const name = readString(issues, `${path}.name`, value.name);
|
||||
const unit = readNumber(issues, `${path}.unit`, value.unit, { min: 1 });
|
||||
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||
if (!name || typeof unit !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
unit,
|
||||
...(typeof heightU === 'number' ? { heightU } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ValidationIssue[]} issues
|
||||
* @param {PortEntry[]} portEntries
|
||||
@@ -166,6 +230,10 @@ export function normalizeVm(issues, path, value) {
|
||||
const role = readString(issues, `${path}.role`, value.role);
|
||||
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
|
||||
if (!name || !role || !ipAddress) {
|
||||
return null;
|
||||
@@ -175,7 +243,8 @@ export function normalizeVm(issues, path, value) {
|
||||
name,
|
||||
role,
|
||||
ipAddress,
|
||||
...(iconKey ? { iconKey } : {})
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(macAddress ? { macAddress } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +265,8 @@ function normalizeMachine(issues, path, value) {
|
||||
const role = readString(issues, `${path}.role`, value.role);
|
||||
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
|
||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
|
||||
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||
|
||||
const softwareRaw = value.software;
|
||||
if (!isRecord(softwareRaw)) {
|
||||
@@ -287,6 +358,8 @@ function normalizeMachine(issues, path, value) {
|
||||
role,
|
||||
operatingSystem,
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
...(rack ? { rack } : {}),
|
||||
software: { vms },
|
||||
hardware: {
|
||||
cpu: hardware.cpu,
|
||||
@@ -318,6 +391,7 @@ function normalizeDevice(issues, path, value) {
|
||||
const type = readString(issues, `${path}.type`, value.type);
|
||||
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
|
||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||
|
||||
const portsRaw = value.ports;
|
||||
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
|
||||
@@ -345,10 +419,84 @@ function normalizeDevice(issues, path, value) {
|
||||
type,
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
...(rack ? { rack } : {}),
|
||||
ports
|
||||
};
|
||||
}
|
||||
|
||||
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 {ValidationIssue[]} issues
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {RackDefinition | null}
|
||||
*/
|
||||
function normalizeRackDefinition(issues, path, value) {
|
||||
if (!isRecord(value)) {
|
||||
issues.push({ path, message: 'must be an object (rack)' });
|
||||
return null;
|
||||
}
|
||||
const name = readString(issues, `${path}.name`, value.name);
|
||||
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
...(typeof heightU === 'number' ? { heightU } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {ValidationResult}
|
||||
@@ -426,9 +574,64 @@ 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 racksRaw = value.racks;
|
||||
const racksProvided = racksRaw !== undefined;
|
||||
if (racksProvided && !Array.isArray(racksRaw)) {
|
||||
issues.push({ path: '$.racks', message: 'must be an array when provided' });
|
||||
}
|
||||
const racks = [];
|
||||
const rackEntries = [];
|
||||
for (const [rackIndex, rackValue] of (Array.isArray(racksRaw) ? racksRaw : []).entries()) {
|
||||
const rack = normalizeRackDefinition(issues, `$.racks[${rackIndex}]`, rackValue);
|
||||
if (rack) {
|
||||
racks.push(rack);
|
||||
rackEntries.push({ rack, originalIndex: rackIndex });
|
||||
}
|
||||
}
|
||||
|
||||
const seenRackNames = new Set();
|
||||
for (const { rack, originalIndex } of rackEntries) {
|
||||
const key = rack.name.toLowerCase();
|
||||
if (seenRackNames.has(key)) {
|
||||
issues.push({
|
||||
path: `$.racks[${originalIndex}].name`,
|
||||
message: `duplicate rack name "${rack.name}"`
|
||||
});
|
||||
}
|
||||
seenRackNames.add(key);
|
||||
}
|
||||
|
||||
const data = {
|
||||
machines,
|
||||
devices
|
||||
devices,
|
||||
...(subnetsProvided ? { subnets } : {}),
|
||||
...(racksProvided ? { racks } : {})
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
export interface CableInfo {
|
||||
type?: string;
|
||||
color?: string;
|
||||
lengthM?: number;
|
||||
}
|
||||
|
||||
export interface ConnectedPortRef {
|
||||
device: string;
|
||||
port: string;
|
||||
cable?: CableInfo;
|
||||
}
|
||||
|
||||
export interface Port {
|
||||
portName: string;
|
||||
speedGbps?: number; // default to 1 if not provided
|
||||
macAddress?: string;
|
||||
connectedTo?: ConnectedPortRef;
|
||||
}
|
||||
|
||||
export interface RackPlacement {
|
||||
name: string;
|
||||
unit: number; // bottom U position, 1-based
|
||||
heightU?: number; // defaults to 1
|
||||
}
|
||||
|
||||
export interface Hardware {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
@@ -22,6 +36,7 @@ export interface VM {
|
||||
role: string;
|
||||
ipAddress: string;
|
||||
iconKey?: string;
|
||||
macAddress?: string;
|
||||
}
|
||||
|
||||
export interface Software {
|
||||
@@ -34,9 +49,11 @@ export interface Machine {
|
||||
role: string;
|
||||
operatingSystem: string;
|
||||
iconKey?: string;
|
||||
notes?: string;
|
||||
software: Software;
|
||||
hardware: Hardware;
|
||||
ports?: Port[];
|
||||
rack?: RackPlacement;
|
||||
}
|
||||
|
||||
export interface NetworkDevice {
|
||||
@@ -46,9 +63,23 @@ export interface NetworkDevice {
|
||||
iconKey?: string;
|
||||
notes?: string;
|
||||
ports?: Port[];
|
||||
rack?: RackPlacement;
|
||||
}
|
||||
|
||||
export interface Subnet {
|
||||
cidr: string;
|
||||
name?: string;
|
||||
vlanId?: number;
|
||||
}
|
||||
|
||||
export interface RackDefinition {
|
||||
name: string;
|
||||
heightU?: number;
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
machines: Machine[];
|
||||
devices: NetworkDevice[];
|
||||
subnets?: Subnet[];
|
||||
racks?: RackDefinition[];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
|
||||
import { validateNetworkData } from '$lib/data/networkSchema';
|
||||
import {
|
||||
checkWritableState,
|
||||
isWriteEnabled,
|
||||
getWritableState,
|
||||
readNetworkFile,
|
||||
writeNetworkFile
|
||||
} from '$lib/server/networkPersistence';
|
||||
@@ -16,11 +15,18 @@ function serializeValidationErrors(errors: Array<{ path: string; message: string
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveReadOnlyErrorMessage(reason: string | null): string {
|
||||
if (!reason) {
|
||||
return 'Write API unavailable in current deployment.';
|
||||
}
|
||||
return `Write API unavailable: ${reason}`;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const [{ data, source, updatedAt }, writable] = await Promise.all([
|
||||
const [{ data, source, updatedAt }, writableState] = await Promise.all([
|
||||
readNetworkFile(),
|
||||
checkWritableState()
|
||||
getWritableState()
|
||||
]);
|
||||
const validation = validateNetworkData(data);
|
||||
if (!validation.valid || !validation.data) {
|
||||
@@ -35,7 +41,8 @@ export const GET: RequestHandler = async () => {
|
||||
|
||||
return json({
|
||||
data: validation.data,
|
||||
writable,
|
||||
writable: writableState.writable,
|
||||
writableReason: writableState.reason,
|
||||
source,
|
||||
updatedAt
|
||||
});
|
||||
@@ -47,10 +54,13 @@ export const GET: RequestHandler = async () => {
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ request }) => {
|
||||
if (!isWriteEnabled()) {
|
||||
const writableStateBeforeWrite = await getWritableState();
|
||||
if (!writableStateBeforeWrite.writable) {
|
||||
return json(
|
||||
{
|
||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
||||
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||
writable: false,
|
||||
writableReason: writableStateBeforeWrite.reason
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
@@ -76,10 +86,11 @@ export const PUT: RequestHandler = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const metadata = await writeNetworkFile(validation.data);
|
||||
const writable = await checkWritableState();
|
||||
const writableStateAfterWrite = await getWritableState();
|
||||
return json({
|
||||
data: validation.data,
|
||||
writable,
|
||||
writable: writableStateAfterWrite.writable,
|
||||
writableReason: writableStateAfterWrite.reason,
|
||||
source: metadata.source,
|
||||
updatedAt: metadata.updatedAt
|
||||
});
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
"role": "Hypervisor",
|
||||
"operatingSystem": "Proxmox",
|
||||
"iconKey": "homarr:proxmox",
|
||||
"notes": "Primary router box; do not power off without failover in place.",
|
||||
"software": {
|
||||
"vms": [
|
||||
{
|
||||
"name": "OpnSense",
|
||||
"role": "Router/Firewall/Gateway (DHCP 10.0.0.100-254)",
|
||||
"ipAddress": "10.0.0.1"
|
||||
"ipAddress": "10.0.0.1",
|
||||
"macAddress": "bc:24:11:5a:2e:01"
|
||||
},
|
||||
{
|
||||
"name": "TPLink Omada Controller",
|
||||
@@ -50,9 +52,15 @@
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"macAddress": "84:47:09:1c:aa:10",
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "1"
|
||||
"port": "1",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -64,7 +72,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "2"
|
||||
"port": "2",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -75,7 +88,11 @@
|
||||
"port": "wan"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"machineName": "Asustor NAS",
|
||||
@@ -98,7 +115,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "3"
|
||||
"port": "3",
|
||||
"cable": {
|
||||
"type": "Cat5e",
|
||||
"color": "red",
|
||||
"lengthM": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -254,7 +276,12 @@
|
||||
"port": "8"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 7,
|
||||
"heightU": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"machineName": "AI Server",
|
||||
@@ -292,7 +319,12 @@
|
||||
"port": "9"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 4,
|
||||
"heightU": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"machineName": "Immich Mini PC",
|
||||
@@ -334,7 +366,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "ProxRouter",
|
||||
"port": "eth0"
|
||||
"port": "eth0",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -342,7 +379,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "ProxRouter",
|
||||
"port": "eth2"
|
||||
"port": "eth2",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -350,7 +392,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Asustor NAS",
|
||||
"port": "eth0"
|
||||
"port": "eth0",
|
||||
"cable": {
|
||||
"type": "Cat5e",
|
||||
"color": "red",
|
||||
"lengthM": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -473,7 +520,11 @@
|
||||
"portName": "24",
|
||||
"speedGbps": 1
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "WAN Uplink",
|
||||
@@ -505,7 +556,11 @@
|
||||
"port": "11"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3DS",
|
||||
@@ -547,7 +602,11 @@
|
||||
"port": "20"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "UPS Pi",
|
||||
@@ -559,7 +618,12 @@
|
||||
"portName": "port0",
|
||||
"speedGbps": 1
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 1,
|
||||
"heightU": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Waveshare",
|
||||
@@ -568,5 +632,23 @@
|
||||
"notes": "Port link not yet modeled.",
|
||||
"ports": []
|
||||
}
|
||||
],
|
||||
"subnets": [
|
||||
{
|
||||
"cidr": "10.0.0.0/24",
|
||||
"name": "LAN",
|
||||
"vlanId": 1
|
||||
},
|
||||
{
|
||||
"cidr": "10.0.20.0/24",
|
||||
"name": "IoT",
|
||||
"vlanId": 20
|
||||
}
|
||||
],
|
||||
"racks": [
|
||||
{
|
||||
"name": "Lab Rack",
|
||||
"heightU": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||