Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7118bbf8 | |||
| 7f1bad009b | |||
| 0b1d647aec | |||
| 5976b46cad | |||
| 6a1c8f870a | |||
| cab77a1e48 | |||
| d0a631de8f | |||
| a067cc3231 | |||
| 68496ae904 | |||
| e58e427093 | |||
| 94bc7085bf | |||
| 6517fcb373 | |||
| ee143e607a | |||
| fe1e3c2abf | |||
| ec7598e15c | |||
| b4f5f12731 | |||
| b4c7d1bce4 | |||
| 2144eda352 | |||
| acf63a9707 | |||
| 336ee5e91b | |||
| 9cea45945c | |||
| 72fd77b9c9 | |||
| b04bc0acc9 |
@@ -16,7 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -36,6 +39,7 @@ jobs:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Preflight Docker Hub repository access
|
||||
env:
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -57,7 +57,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -85,6 +88,7 @@ jobs:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -5,3 +5,8 @@ yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
src/lib/config/vendorIconManifest.ts
|
||||
|
||||
# Design handoff bundles (prototypes, not code)
|
||||
new-design/
|
||||
claude-design/
|
||||
homelab-hub-main/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**A declarative, self-hosted containerised tool for visualising and managing home lab & network architecture diagrams.**
|
||||
|
||||
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can version, back up, and reuse.
|
||||
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can back up, reuse, and optionally version after reviewing it for sensitive information.
|
||||
|
||||
- Homelab-friendly: run it in minutes with Docker.
|
||||
- Practical: edit in the UI and autosave to `network.json`.
|
||||
@@ -21,9 +21,14 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
||||
## Features
|
||||
|
||||
- Network view with ethernet labels to make physical and logical links easy to read.
|
||||
- Non-network view for host-first inventory and service mapping.
|
||||
- Hosts & VMs 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.
|
||||
@@ -40,11 +47,10 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
||||
|
||||
This is the fastest way to run Open Network Diagram for a home lab.
|
||||
|
||||
1. Create a local data folder and seed your first `network.json`:
|
||||
1. Create a local data folder:
|
||||
|
||||
```bash
|
||||
mkdir -p ond-data
|
||||
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example -o ond-data/network.json
|
||||
mkdir -p data
|
||||
```
|
||||
|
||||
2. Run the published Docker image:
|
||||
@@ -54,15 +60,18 @@ docker run -d \
|
||||
--name open-network-diagram \
|
||||
--restart unless-stopped \
|
||||
-p 8080:3000 \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e NETWORK_DATA_FILE=/app/data/network.json \
|
||||
-e NETWORK_BACKUP_DIR=/app/data/.backups \
|
||||
-v "$(pwd)/ond-data:/app/data" \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
jcreek23/open-network-diagram:latest
|
||||
```
|
||||
|
||||
On first access, Open Network Diagram creates a valid blank `data/network.json` and shows the getting-started card.
|
||||
|
||||
3. Open the app at `http://localhost:8080`.
|
||||
|
||||
4. Edit your topology in the UI. Changes persist to `ond-data/network.json`.
|
||||
4. Add your first machine. Changes and rolling backups remain directly accessible in the local `data` directory across container updates and restarts.
|
||||
|
||||
Useful follow-up commands:
|
||||
|
||||
@@ -72,11 +81,42 @@ docker stop open-network-diagram
|
||||
docker rm open-network-diagram
|
||||
```
|
||||
|
||||
The `--user` option runs the container with your host UID and GID so files in the bind mount remain writable and owned by your account. Docker Desktop commonly handles bind-mount permissions without this mapping, so macOS users can omit `--user` if their Docker Desktop configuration requires the image's built-in user. If storage is not writable, the app leaves it untouched, loads the bundled read-only demo, and reports the exact permission problem in the UI.
|
||||
|
||||
### Optional Sample Data
|
||||
|
||||
The application starts with a blank network by default. To explore the sample topology instead, download it before starting the container:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example \
|
||||
-o data/network.json
|
||||
```
|
||||
|
||||
The sample is optional and is never required for startup.
|
||||
|
||||
## Deployment Security
|
||||
|
||||
Open Network Diagram does not include authentication or TLS. The quick-start port mapping `-p 8080:3000` listens on all host interfaces, so any device that can reach the host can view the topology and, in writable mode, change it.
|
||||
|
||||
Do not expose a writable deployment directly to the internet. Restrict access with a firewall, LAN or VPN, or place the application behind an authenticated reverse proxy that terminates TLS.
|
||||
|
||||
To make the service reachable only from the Docker host, replace the quick-start mapping with `-p 127.0.0.1:8080:3000`.
|
||||
|
||||
For a public demonstration, add `-e NETWORK_READ_ONLY=true` to `docker run`, or add `NETWORK_READ_ONLY: 'true'` under Compose's `environment`. Read-only mode prevents changes and uses the bundled demo if the configured data file is missing, but it does not hide a configured topology or replace authentication.
|
||||
|
||||
## What It Looks Like
|
||||
|
||||
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
|
||||
| Network view with ethernet labels | Hosts & VMs view with VMs expanded | Modal editing a machine |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
| Zero-setup first run | Read-only live demo |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
## Docker Compose Option
|
||||
|
||||
@@ -86,10 +126,11 @@ If you prefer compose:
|
||||
services:
|
||||
open-network-diagram:
|
||||
image: jcreek23/open-network-diagram:latest
|
||||
user: '${OND_UID:?Set OND_UID to your host user ID}:${OND_GID:?Set OND_GID to your host group ID}'
|
||||
ports:
|
||||
- '8080:3000'
|
||||
volumes:
|
||||
- ./ond-data:/app/data
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
NETWORK_DATA_FILE: /app/data/network.json
|
||||
NETWORK_BACKUP_DIR: /app/data/.backups
|
||||
@@ -99,9 +140,40 @@ services:
|
||||
Start it with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
mkdir -p data
|
||||
OND_UID="$(id -u)" OND_GID="$(id -g)" docker compose up -d
|
||||
```
|
||||
|
||||
`OND_UID` and `OND_GID` are required Compose interpolation values supplied by the command above; they are not application environment variables. Docker Desktop users can remove the `user` line if their file-sharing configuration requires the image's built-in user.
|
||||
|
||||
### Using a Docker Named Volume
|
||||
|
||||
If host-directory permissions are inconvenient, Docker can manage the storage volume instead:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name open-network-diagram \
|
||||
--restart unless-stopped \
|
||||
-p 8080:3000 \
|
||||
-e NETWORK_DATA_FILE=/app/data/network.json \
|
||||
-e NETWORK_BACKUP_DIR=/app/data/.backups \
|
||||
-v ond-data:/app/data \
|
||||
jcreek23/open-network-diagram:latest
|
||||
```
|
||||
|
||||
Docker creates the `ond-data` volume automatically. Use `docker volume inspect ond-data` to inspect its location or `docker cp open-network-diagram:/app/data/network.json ./network.json` to copy the data file out.
|
||||
|
||||
### Versioning Your Topology
|
||||
|
||||
`data/network.json` and `data/.backups` are ignored by Git because topology data may expose host names, addresses, MAC addresses, and other sensitive infrastructure details. If you have reviewed and sanitized the data and deliberately want to track it, opt in with:
|
||||
|
||||
```bash
|
||||
git add -f data/network.json
|
||||
git commit -m "Track network topology"
|
||||
```
|
||||
|
||||
Once the file is tracked, normal Git commands include later changes. Backup files remain ignored.
|
||||
|
||||
## For Developers
|
||||
|
||||
### Local Development
|
||||
@@ -128,6 +200,8 @@ pnpm run icons:manifest # regenerate local vendor icon manifest
|
||||
|
||||
- API endpoint: `GET/PUT /api/network-data`
|
||||
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
||||
- Writable deployments automatically initialize a missing or whitespace-only data file with a blank network.
|
||||
- Read-only deployments never initialize or modify the configured data file and continue to use the bundled demo when the API data is unavailable.
|
||||
- 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)
|
||||
@@ -151,29 +225,77 @@ 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)
|
||||
├── data/network.json.example # Starter data template for Docker users
|
||||
├── data/network.json.example # Optional starter data template
|
||||
├── third_party/ # Third-party provenance + licensing
|
||||
├── Dockerfile # Docker build/runtime image
|
||||
├── server.mjs # Node runtime server (static + API)
|
||||
@@ -184,17 +306,28 @@ OpenNetworkDiagram/
|
||||
|
||||
### CI/CD
|
||||
|
||||
- `docker.yml`: validates Docker build on pull requests.
|
||||
- `release.yml`: semantic release on `main` and Docker Hub publish for tagged releases.
|
||||
- `docker.yml`: validates AMD64 and ARM64 Docker builds on pull requests.
|
||||
- `release.yml`: semantic release on `main` and multi-platform Docker Hub publish for tagged releases.
|
||||
- Published images support AMD64 and 64-bit ARM (including Raspberry Pi systems); Docker selects the matching variant automatically.
|
||||
- Docker Hub image: [`jcreek23/open-network-diagram`](https://hub.docker.com/r/jcreek23/open-network-diagram)
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch.
|
||||
3. Commit your changes.
|
||||
4. Push your branch.
|
||||
5. Open a pull request.
|
||||
3. Before committing, run:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm check
|
||||
pnpm run build:docker
|
||||
pnpm run build:netlify
|
||||
```
|
||||
|
||||
4. Commit your changes.
|
||||
5. Push your branch.
|
||||
6. Open a pull request.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: open-network-diagram:local
|
||||
user: '${OND_UID:?Set OND_UID to your host user ID}:${OND_GID:?Set OND_GID to your host group ID}'
|
||||
ports:
|
||||
- "8080:3000"
|
||||
- '8080:3000'
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
NETWORK_DATA_FILE: "/app/data/network.json"
|
||||
NETWORK_BACKUP_DIR: "/app/data/.backups"
|
||||
NETWORK_DATA_FILE: '/app/data/network.json'
|
||||
NETWORK_BACKUP_DIR: '/app/data/.backups'
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"build:docker": "DEPLOY_TARGET=docker vite build",
|
||||
"build:netlify": "NETWORK_READ_ONLY=true DEPLOY_TARGET=netlify vite build",
|
||||
"build:netlify": "NETWORK_READ_ONLY=true OND_STATIC_DEMO=true DEPLOY_TARGET=netlify vite build",
|
||||
"start": "node server.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -38,8 +39,8 @@
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||
"dependencies": {
|
||||
"cytoscape": "^3.31.2",
|
||||
"cytoscape-dagre": "^2.5.0"
|
||||
"cytoscape-dagre": "^2.5.0",
|
||||
"html-to-image": "^1.11.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ importers:
|
||||
cytoscape-dagre:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(cytoscape@3.33.1)
|
||||
html-to-image:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
devDependencies:
|
||||
'@eslint/compat':
|
||||
specifier: ^1.2.5
|
||||
@@ -76,11 +79,11 @@ importers:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.11
|
||||
version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
||||
svelte:
|
||||
specifier: ^5.0.0
|
||||
version: 5.25.8
|
||||
@@ -1447,6 +1450,9 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1881,17 +1887,20 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier-plugin-svelte@3.3.3:
|
||||
resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==}
|
||||
prettier-plugin-svelte@4.1.1:
|
||||
resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
prettier: ^3.0.0
|
||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
||||
svelte: ^5.0.0
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.11:
|
||||
resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
prettier-plugin-tailwindcss@0.8.0:
|
||||
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||
'@prettier/plugin-hermes': '*'
|
||||
'@prettier/plugin-oxc': '*'
|
||||
'@prettier/plugin-pug': '*'
|
||||
'@shopify/prettier-plugin-liquid': '*'
|
||||
'@trivago/prettier-plugin-sort-imports': '*'
|
||||
@@ -1899,18 +1908,20 @@ packages:
|
||||
prettier: ^3.0
|
||||
prettier-plugin-astro: '*'
|
||||
prettier-plugin-css-order: '*'
|
||||
prettier-plugin-import-sort: '*'
|
||||
prettier-plugin-jsdoc: '*'
|
||||
prettier-plugin-marko: '*'
|
||||
prettier-plugin-multiline-arrays: '*'
|
||||
prettier-plugin-organize-attributes: '*'
|
||||
prettier-plugin-organize-imports: '*'
|
||||
prettier-plugin-sort-imports: '*'
|
||||
prettier-plugin-style-order: '*'
|
||||
prettier-plugin-svelte: '*'
|
||||
peerDependenciesMeta:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
optional: true
|
||||
'@prettier/plugin-hermes':
|
||||
optional: true
|
||||
'@prettier/plugin-oxc':
|
||||
optional: true
|
||||
'@prettier/plugin-pug':
|
||||
optional: true
|
||||
'@shopify/prettier-plugin-liquid':
|
||||
@@ -1923,8 +1934,6 @@ packages:
|
||||
optional: true
|
||||
prettier-plugin-css-order:
|
||||
optional: true
|
||||
prettier-plugin-import-sort:
|
||||
optional: true
|
||||
prettier-plugin-jsdoc:
|
||||
optional: true
|
||||
prettier-plugin-marko:
|
||||
@@ -1937,8 +1946,6 @@ packages:
|
||||
optional: true
|
||||
prettier-plugin-sort-imports:
|
||||
optional: true
|
||||
prettier-plugin-style-order:
|
||||
optional: true
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
|
||||
@@ -3631,6 +3638,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -4022,16 +4031,16 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8):
|
||||
prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8):
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
svelte: 5.25.8
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
|
||||
prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
optionalDependencies:
|
||||
prettier-plugin-svelte: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
||||
prettier-plugin-svelte: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||
|
||||
prettier@3.8.1: {}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -12,181 +12,186 @@ const prettierIgnorePath = path.join(projectRoot, '.prettierignore');
|
||||
|
||||
const allowedExtensions = new Set(['.svg', '.png', '.webp']);
|
||||
const extensionPriority = {
|
||||
'.svg': 0,
|
||||
'.png': 1,
|
||||
'.webp': 2
|
||||
'.svg': 0,
|
||||
'.png': 1,
|
||||
'.webp': 2
|
||||
};
|
||||
|
||||
/** @param {string} input */
|
||||
function toPosixPath(input) {
|
||||
return input.split(path.sep).join('/');
|
||||
return input.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/** @param {string} filename */
|
||||
function toSlug(filename) {
|
||||
return filename
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, '-')
|
||||
.replace(/[._]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return filename
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, '-')
|
||||
.replace(/[._]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/** @param {string} slug */
|
||||
function toLabel(slug) {
|
||||
return slug
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
return slug
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** @param {string} dir */
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
const directories = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
const nestedFileLists = await Promise.all(directories.map((childDirectory) => walk(childDirectory)));
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
const directories = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
const nestedFileLists = await Promise.all(
|
||||
directories.map((childDirectory) => walk(childDirectory))
|
||||
);
|
||||
|
||||
for (const nestedFiles of nestedFileLists) {
|
||||
files.push(...nestedFiles);
|
||||
}
|
||||
return files;
|
||||
for (const nestedFiles of nestedFileLists) {
|
||||
files.push(...nestedFiles);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function compareIconRecords(a, b) {
|
||||
const labelCompare = a.label.localeCompare(b.label);
|
||||
if (labelCompare !== 0) {
|
||||
return labelCompare;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
const labelCompare = a.label.localeCompare(b.label);
|
||||
if (labelCompare !== 0) {
|
||||
return labelCompare;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
}
|
||||
|
||||
/** @param {string} filePath @param {string} entry */
|
||||
async function ensureIgnoreEntry(filePath, entry) {
|
||||
const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
||||
const existingEntries = contents
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
.map((line) => toPosixPath(line).replace(/^\.?\//, ''));
|
||||
if (existingEntries.includes(normalizedEntry)) {
|
||||
return;
|
||||
}
|
||||
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
||||
const existingEntries = contents
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
.map((line) => toPosixPath(line).replace(/^\.?\//, ''));
|
||||
if (existingEntries.includes(normalizedEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
||||
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
|
||||
await fs.writeFile(filePath, nextContents, 'utf8');
|
||||
const suffix =
|
||||
contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
||||
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
|
||||
await fs.writeFile(filePath, nextContents, 'utf8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const vendorExists = await fs
|
||||
.access(vendorRoot)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const vendorExists = await fs
|
||||
.access(vendorRoot)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!vendorExists) {
|
||||
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
|
||||
}
|
||||
if (!vendorExists) {
|
||||
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
|
||||
}
|
||||
|
||||
const allFiles = await walk(vendorRoot);
|
||||
const dedupedBySlug = new Map();
|
||||
const allFiles = await walk(vendorRoot);
|
||||
const dedupedBySlug = new Map();
|
||||
|
||||
for (const absoluteFilePath of allFiles) {
|
||||
const extension = path.extname(absoluteFilePath).toLowerCase();
|
||||
if (!allowedExtensions.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
for (const absoluteFilePath of allFiles) {
|
||||
const extension = path.extname(absoluteFilePath).toLowerCase();
|
||||
if (!allowedExtensions.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
|
||||
const basename = path.basename(relativePath, extension);
|
||||
const slug = toSlug(basename);
|
||||
if (!slug) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
|
||||
const basename = path.basename(relativePath, extension);
|
||||
const slug = toSlug(basename);
|
||||
if (!slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `homarr:${slug}`;
|
||||
const candidate = {
|
||||
key,
|
||||
label: toLabel(slug),
|
||||
path: `/icons/vendor/homarr/${relativePath}`,
|
||||
source: 'homarr-dashboard-icons',
|
||||
extension,
|
||||
priority: extensionPriority[extension] ?? 99,
|
||||
relativePath
|
||||
};
|
||||
const key = `homarr:${slug}`;
|
||||
const candidate = {
|
||||
key,
|
||||
label: toLabel(slug),
|
||||
path: `/icons/vendor/homarr/${relativePath}`,
|
||||
source: 'homarr-dashboard-icons',
|
||||
extension,
|
||||
priority: extensionPriority[extension] ?? 99,
|
||||
relativePath
|
||||
};
|
||||
|
||||
const existing = dedupedBySlug.get(slug);
|
||||
if (!existing) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
continue;
|
||||
}
|
||||
const existing = dedupedBySlug.get(slug);
|
||||
if (!existing) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.priority < existing.priority) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
continue;
|
||||
}
|
||||
if (candidate.priority < existing.priority) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
}
|
||||
}
|
||||
if (
|
||||
candidate.priority === existing.priority &&
|
||||
candidate.relativePath < existing.relativePath
|
||||
) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const records = Array.from(dedupedBySlug.values())
|
||||
.sort(compareIconRecords)
|
||||
.map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
|
||||
const records = Array.from(dedupedBySlug.values())
|
||||
.sort(compareIconRecords)
|
||||
.map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
|
||||
|
||||
const header = [
|
||||
'/* eslint-disable */',
|
||||
'// prettier-ignore',
|
||||
'// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
|
||||
'// Do not edit manually.',
|
||||
'',
|
||||
'export interface VendorIconDefinition {',
|
||||
' key: string;',
|
||||
' label: string;',
|
||||
' path: string;',
|
||||
" source: 'homarr-dashboard-icons';",
|
||||
'}',
|
||||
'',
|
||||
'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
|
||||
].join('\n');
|
||||
const header = [
|
||||
'/* eslint-disable */',
|
||||
'// prettier-ignore',
|
||||
'// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
|
||||
'// Do not edit manually.',
|
||||
'',
|
||||
'export interface VendorIconDefinition {',
|
||||
' key: string;',
|
||||
' label: string;',
|
||||
' path: string;',
|
||||
" source: 'homarr-dashboard-icons';",
|
||||
'}',
|
||||
'',
|
||||
'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
|
||||
].join('\n');
|
||||
|
||||
const body = records
|
||||
.map((record) => {
|
||||
const key = JSON.stringify(record.key);
|
||||
const label = JSON.stringify(record.label);
|
||||
const iconPath = JSON.stringify(record.path);
|
||||
return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
|
||||
})
|
||||
.join('\n');
|
||||
const body = records
|
||||
.map((record) => {
|
||||
const key = JSON.stringify(record.key);
|
||||
const label = JSON.stringify(record.label);
|
||||
const iconPath = JSON.stringify(record.path);
|
||||
return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const footer = ['];', ''].join('\n');
|
||||
const output = `${header}\n${body}\n${footer}`;
|
||||
const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
|
||||
await fs.writeFile(outputPath, output, 'utf8');
|
||||
const footer = ['];', ''].join('\n');
|
||||
const output = `${header}\n${body}\n${footer}`;
|
||||
const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
|
||||
await fs.writeFile(outputPath, output, 'utf8');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
||||
import {
|
||||
getWritableState,
|
||||
readNetworkFile,
|
||||
readOrInitializeNetworkFile,
|
||||
writeNetworkFile
|
||||
} from './src/lib/shared/networkPersistenceCore.mjs';
|
||||
|
||||
@@ -59,12 +59,17 @@ function resolveReadOnlyErrorMessage(reason) {
|
||||
async function serveApi(request, response) {
|
||||
if (request.method === 'GET') {
|
||||
try {
|
||||
const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]);
|
||||
const [payload, writableState] = await Promise.all([
|
||||
readOrInitializeNetworkFile(),
|
||||
getWritableState()
|
||||
]);
|
||||
const validation = validateNetworkData(payload.data);
|
||||
if (!validation.valid) {
|
||||
sendJson(response, 500, {
|
||||
error: 'Stored network data is invalid',
|
||||
details: validation.errors
|
||||
details: validation.errors,
|
||||
writable: false,
|
||||
writableReason: writableState.reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -77,8 +82,14 @@ async function serveApi(request, response) {
|
||||
updatedAt: payload.updatedAt
|
||||
});
|
||||
} catch (error) {
|
||||
const writableState = await getWritableState().catch(() => ({
|
||||
writable: false,
|
||||
reason: null
|
||||
}));
|
||||
sendJson(response, 500, {
|
||||
error: error instanceof Error ? error.message : 'Failed to read network data'
|
||||
error: error instanceof Error ? error.message : 'Failed to read network data',
|
||||
writable: false,
|
||||
writableReason: writableState.reason
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,30 +1,99 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
/* Design tokens from the UX review (new-design/project/UX Review.dc.html, panel 1b).
|
||||
Principle: calm chrome, loud data. */
|
||||
:root {
|
||||
--app-bg: #e2e8f0;
|
||||
--panel-bg: #f8fafc;
|
||||
--panel-border: #cbd5e1;
|
||||
--panel-contrast: #0f172a;
|
||||
--muted-text: #334155;
|
||||
--chip-bg: #e2e8f0;
|
||||
--chip-text: #0f172a;
|
||||
--modal-overlay: rgba(15, 23, 42, 0.58);
|
||||
/* Core colours */
|
||||
--bg-canvas: #eef0f3;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f2f4f7;
|
||||
--border: #dce0e6;
|
||||
--text: #1b212b;
|
||||
--text-2: #616a78;
|
||||
--accent: #3565c4;
|
||||
--danger: #c03d38;
|
||||
|
||||
/* Graph canvas tokens (plain hex — read via getComputedStyle for Cytoscape) */
|
||||
--graph-edge: #8b94a3;
|
||||
--graph-node-border: #c6ccd4;
|
||||
--graph-infra-fill: #3a4250;
|
||||
--graph-infra-border: #2b323d;
|
||||
--graph-dumb-border: #b3bac4;
|
||||
--graph-edge-chip-bg: #ffffff;
|
||||
--graph-edge-chip-text: #3a4250;
|
||||
|
||||
/* VLAN categorical set — equal lightness/chroma so no VLAN shouts louder */
|
||||
--vlan-0: oklch(0.55 0.13 290);
|
||||
--vlan-1: oklch(0.55 0.13 15);
|
||||
--vlan-2: oklch(0.55 0.13 175);
|
||||
--vlan-3: oklch(0.55 0.13 75);
|
||||
--vlan-4: oklch(0.55 0.13 240);
|
||||
--vlan-5: oklch(0.55 0.13 125);
|
||||
|
||||
/* Type */
|
||||
--font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Shape & elevation */
|
||||
--radius-control: 6px;
|
||||
--radius-panel: 10px;
|
||||
--radius-modal: 14px;
|
||||
--control-h: 32px;
|
||||
--shadow-panel: 0 4px 16px rgb(0 0 0 / 0.08);
|
||||
--shadow-popover: 0 8px 24px rgb(0 0 0 / 0.12);
|
||||
--modal-overlay: rgb(15 18 24 / 0.45);
|
||||
|
||||
/* Status */
|
||||
--status-ok: #2e7d4f;
|
||||
--status-warn: #b0782f;
|
||||
|
||||
/* Legacy aliases (older selectors still reference these) */
|
||||
--app-bg: var(--bg-canvas);
|
||||
--panel-bg: var(--surface);
|
||||
--panel-border: var(--border);
|
||||
--panel-contrast: var(--text);
|
||||
--muted-text: var(--text-2);
|
||||
--chip-bg: var(--surface-2);
|
||||
--chip-text: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--app-bg: #0b1220;
|
||||
--panel-bg: #111827;
|
||||
--panel-border: #334155;
|
||||
--panel-contrast: #e2e8f0;
|
||||
--muted-text: #94a3b8;
|
||||
--chip-bg: #1e293b;
|
||||
--chip-text: #e2e8f0;
|
||||
--modal-overlay: rgba(2, 6, 23, 0.78);
|
||||
--bg-canvas: #14171c;
|
||||
--surface: #1d222a;
|
||||
--surface-2: #262d38;
|
||||
--border: #363f4d;
|
||||
--text: #e7ebf2;
|
||||
--text-2: #9aa4b2;
|
||||
--accent: #7da5e8;
|
||||
--danger: #e0716c;
|
||||
|
||||
--graph-edge: #5b6575;
|
||||
--graph-node-border: #363f4d;
|
||||
--graph-infra-fill: #3e4c61;
|
||||
--graph-infra-border: #5b6c86;
|
||||
--graph-dumb-border: #414c5d;
|
||||
--graph-edge-chip-bg: #262d38;
|
||||
--graph-edge-chip-text: #c3cad4;
|
||||
|
||||
--vlan-0: oklch(0.68 0.13 290);
|
||||
--vlan-1: oklch(0.68 0.13 15);
|
||||
--vlan-2: oklch(0.68 0.13 175);
|
||||
--vlan-3: oklch(0.68 0.13 75);
|
||||
--vlan-4: oklch(0.68 0.13 240);
|
||||
--vlan-5: oklch(0.68 0.13 125);
|
||||
|
||||
--shadow-panel: 0 4px 16px rgb(0 0 0 / 0.35);
|
||||
--shadow-popover: 0 8px 24px rgb(0 0 0 / 0.45);
|
||||
--modal-overlay: rgb(8 10 14 / 0.6);
|
||||
|
||||
--status-ok: #5cb884;
|
||||
--status-warn: #f0b429;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--app-bg);
|
||||
color: var(--panel-contrast);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
interface ImportMetaEnv {
|
||||
readonly OND_STATIC_DEMO: boolean;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Svelte action: invoke the callback when a pointer goes down outside the
|
||||
// node. Listens in the capture phase so clicks swallowed by the Cytoscape
|
||||
// canvas still dismiss open popovers.
|
||||
export default function clickOutside(node: HTMLElement, onOutside: () => void) {
|
||||
let callback = onOutside;
|
||||
const handler = (event: PointerEvent) => {
|
||||
if (!node.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handler, true);
|
||||
return {
|
||||
update(next: () => void) {
|
||||
callback = next;
|
||||
},
|
||||
destroy() {
|
||||
document.removeEventListener('pointerdown', handler, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import clickOutside from '$lib/actions/clickOutside';
|
||||
|
||||
type DiagramViewMode = 'network' | 'device' | 'rack';
|
||||
type SaveState = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
export let dataSourceLabel = '';
|
||||
export let saveState: SaveState = 'saved';
|
||||
export let writable = false;
|
||||
export let hasLoadedInitialData = false;
|
||||
export let readOnlyNotice = '';
|
||||
export let saveError: string | null = null;
|
||||
export let isLoadingData = false;
|
||||
|
||||
export let viewMode: DiagramViewMode = 'network';
|
||||
export let searchQuery = '';
|
||||
export let searchCountLabel = '';
|
||||
|
||||
export let showEthernetLabels = false;
|
||||
export let showCableSpeeds = true;
|
||||
export let showVms = true;
|
||||
export let hasAnyVmHosts = false;
|
||||
export let ipamOpen = false;
|
||||
export let theme: 'light' | 'dark' = 'light';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
viewchange: DiagramViewMode;
|
||||
search: void;
|
||||
searchcycle: { direction: 1 | -1 };
|
||||
clearsearch: void;
|
||||
addmachine: void;
|
||||
adddevice: void;
|
||||
toggleipam: void;
|
||||
ethernetlabels: boolean;
|
||||
cablespeeds: boolean;
|
||||
showvms: boolean;
|
||||
exportpng: void;
|
||||
reload: void;
|
||||
toggletheme: void;
|
||||
}>();
|
||||
|
||||
let addMenuOpen = false;
|
||||
let displayMenuOpen = false;
|
||||
let searchInput: HTMLInputElement;
|
||||
|
||||
export function focusSearch() {
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
$: fileName = dataSourceLabel.split('/').pop() || dataSourceLabel;
|
||||
$: status = (() => {
|
||||
if (isLoadingData) {
|
||||
return { text: 'Loading…', kind: 'muted', title: undefined as string | undefined };
|
||||
}
|
||||
if (hasLoadedInitialData && !writable) {
|
||||
return { text: '⬦ Read-only', kind: 'warn', title: readOnlyNotice };
|
||||
}
|
||||
if (saveState === 'saving') {
|
||||
return { text: 'Saving…', kind: 'muted', title: undefined };
|
||||
}
|
||||
if (saveState === 'error') {
|
||||
return { text: '⚠ Save failed', kind: 'danger', title: saveError ?? undefined };
|
||||
}
|
||||
if (saveState === 'unsaved') {
|
||||
return { text: 'Unsaved changes', kind: 'muted', title: undefined };
|
||||
}
|
||||
return { text: '✓ Saved', kind: 'ok', title: undefined };
|
||||
})();
|
||||
|
||||
const views: Array<{ id: DiagramViewMode; label: string }> = [
|
||||
{ id: 'network', label: 'Network' },
|
||||
{ id: 'device', label: 'Hosts & VMs' },
|
||||
{ id: 'rack', label: 'Rack' }
|
||||
];
|
||||
|
||||
function selectView(mode: DiagramViewMode) {
|
||||
if (mode !== viewMode) {
|
||||
dispatch('viewchange', mode);
|
||||
}
|
||||
}
|
||||
|
||||
function onViewSelectChange(event: Event) {
|
||||
selectView((event.currentTarget as HTMLSelectElement).value as DiagramViewMode);
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
dispatch('searchcycle', { direction: event.shiftKey ? -1 : 1 });
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
dispatch('clearsearch');
|
||||
searchInput?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && (addMenuOpen || displayMenuOpen)) {
|
||||
addMenuOpen = false;
|
||||
displayMenuOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onWindowKeydown} />
|
||||
|
||||
<header class="app-bar">
|
||||
<div class="identity" title={dataSourceLabel}>
|
||||
<div class="glyph" aria-hidden="true">◆</div>
|
||||
<div class="identity-text">
|
||||
<span class="file-name">{fileName}</span>
|
||||
<span class="status status-{status.kind}" title={status.title}>{status.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="views">
|
||||
<div class="seg" role="radiogroup" aria-label="Diagram view">
|
||||
{#each views as view (view.id)}
|
||||
<label class="seg-option" class:checked={viewMode === view.id}>
|
||||
<input
|
||||
type="radio"
|
||||
name="diagram-view"
|
||||
value={view.id}
|
||||
checked={viewMode === view.id}
|
||||
on:change={() => selectView(view.id)}
|
||||
/>
|
||||
{view.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<select
|
||||
class="seg-select"
|
||||
value={viewMode}
|
||||
aria-label="Select diagram view"
|
||||
on:change={onViewSelectChange}
|
||||
>
|
||||
{#each views as view (view.id)}
|
||||
<option value={view.id}>{view.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="search" class:has-query={searchQuery.trim()}>
|
||||
<span class="search-glyph" aria-hidden="true">⌕</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find name, IP, VM…"
|
||||
aria-label="Search diagram nodes"
|
||||
bind:this={searchInput}
|
||||
bind:value={searchQuery}
|
||||
on:input={() => dispatch('search')}
|
||||
on:keydown={onSearchKeydown}
|
||||
/>
|
||||
{#if searchQuery.trim()}
|
||||
<span class="search-count">{searchCountLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
on:click={() => dispatch('clearsearch')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{:else}
|
||||
<kbd class="search-kbd" aria-hidden="true">/</kbd>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="menu-anchor" use:clickOutside={() => (addMenuOpen = false)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
disabled={!writable}
|
||||
title={!writable ? readOnlyNotice : undefined}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenuOpen}
|
||||
on:click={() => (addMenuOpen = !addMenuOpen)}
|
||||
>
|
||||
+ <span class="btn-text">Add</span> <span class="caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{#if addMenuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
on:click={() => {
|
||||
addMenuOpen = false;
|
||||
dispatch('addmachine');
|
||||
}}
|
||||
>
|
||||
Machine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
on:click={() => {
|
||||
addMenuOpen = false;
|
||||
dispatch('adddevice');
|
||||
}}
|
||||
>
|
||||
Device
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
class:active={ipamOpen}
|
||||
on:click={() => dispatch('toggleipam')}
|
||||
>
|
||||
IPAM
|
||||
</button>
|
||||
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="menu-anchor" use:clickOutside={() => (displayMenuOpen = false)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-quiet"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={displayMenuOpen}
|
||||
on:click={() => (displayMenuOpen = !displayMenuOpen)}
|
||||
>
|
||||
<span class="btn-text">Display</span><span class="btn-text-narrow" aria-hidden="true"
|
||||
>⚙</span
|
||||
>
|
||||
<span class="caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{#if displayMenuOpen}
|
||||
<div class="menu display-menu">
|
||||
<div class="menu-title">Display</div>
|
||||
<label
|
||||
class="menu-toggle"
|
||||
class:disabled={viewMode !== 'network'}
|
||||
title={viewMode !== 'network'
|
||||
? 'Ethernet labels are available in Network view.'
|
||||
: undefined}
|
||||
>
|
||||
<span>Ethernet labels</span>
|
||||
<input
|
||||
class="toggle-input"
|
||||
type="checkbox"
|
||||
checked={showEthernetLabels}
|
||||
disabled={viewMode !== 'network'}
|
||||
on:change={(event) => dispatch('ethernetlabels', event.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
<label
|
||||
class="menu-toggle"
|
||||
class:disabled={viewMode !== 'network' || !showEthernetLabels}
|
||||
title={viewMode !== 'network' || !showEthernetLabels
|
||||
? 'Cable speeds show inside ethernet labels.'
|
||||
: undefined}
|
||||
>
|
||||
<span>Cable speeds</span>
|
||||
<input
|
||||
class="toggle-input"
|
||||
type="checkbox"
|
||||
checked={showCableSpeeds}
|
||||
disabled={viewMode !== 'network' || !showEthernetLabels}
|
||||
on:change={(event) => dispatch('cablespeeds', event.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
<label class="menu-toggle" class:disabled={!hasAnyVmHosts}>
|
||||
<span>Show VMs</span>
|
||||
<input
|
||||
class="toggle-input"
|
||||
type="checkbox"
|
||||
checked={showVms}
|
||||
disabled={!hasAnyVmHosts}
|
||||
on:change={(event) => dispatch('showvms', event.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
<div class="menu-rule" aria-hidden="true"></div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={viewMode === 'rack'}
|
||||
title={viewMode === 'rack' ? 'PNG export is available in the graph views.' : undefined}
|
||||
on:click={() => {
|
||||
displayMenuOpen = false;
|
||||
dispatch('exportpng');
|
||||
}}
|
||||
>
|
||||
Export PNG…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isLoadingData}
|
||||
on:click={() => {
|
||||
displayMenuOpen = false;
|
||||
dispatch('reload');
|
||||
}}
|
||||
>
|
||||
Reload from disk
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
aria-label="Toggle dark mode"
|
||||
title="Toggle dark mode"
|
||||
on:click={() => dispatch('toggletheme')}
|
||||
>
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.app-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Left: identity + status */
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.identity-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 10.5px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
color: var(--status-ok);
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-muted {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* Centre: segmented view control */
|
||||
.views {
|
||||
margin: 0 auto 0 24px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
background: var(--surface-2);
|
||||
border-radius: 7px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.seg-option {
|
||||
padding: 5px 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seg-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.seg-option.checked {
|
||||
background: var(--surface);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.seg-option:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.seg-select {
|
||||
display: none;
|
||||
height: var(--control-h);
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Right cluster */
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: var(--control-h);
|
||||
width: 220px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.search-glyph {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
color: var(--text-2);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
font-size: 10px;
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
background: var(--surface);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-quiet,
|
||||
.btn-icon {
|
||||
height: var(--control-h);
|
||||
border-radius: var(--radius-control);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0 13px;
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-secondary.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-quiet {
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-quiet:hover,
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: var(--control-h);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.caret {
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-text-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Menus */
|
||||
.menu-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 170px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.display-menu {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
|
||||
.menu > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu > button:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.menu > button:disabled {
|
||||
color: var(--text-2);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-rule {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 4px;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-control);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-toggle.disabled {
|
||||
color: var(--text-2);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 19px;
|
||||
background: color-mix(in oklab, var(--text-2) 40%, var(--surface-2));
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
transition: background 120ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: var(--surface);
|
||||
border-radius: 50%;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track .toggle-thumb {
|
||||
transform: translateX(13px);
|
||||
}
|
||||
|
||||
.toggle-input:focus-visible + .toggle-track {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 800px) {
|
||||
.app-bar {
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.views {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.seg-select {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 36px;
|
||||
padding: 0 9px;
|
||||
transition: width 140ms ease;
|
||||
}
|
||||
|
||||
.search:focus-within,
|
||||
.search.has-query {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-text-narrow {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.identity-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phone widths: shed the divider and tighten spacing so the single row
|
||||
never overflows the viewport. */
|
||||
@media (max-width: 480px) {
|
||||
.app-bar {
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.views {
|
||||
margin-left: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.seg-select {
|
||||
max-width: 108px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-quiet {
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.caret {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search:focus-within,
|
||||
.search.has-query {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let isSampleData = false;
|
||||
export let writable = false;
|
||||
export let hasMachines = false;
|
||||
export let storageReady = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
addmachine: void;
|
||||
connectport: void;
|
||||
openipam: void;
|
||||
dismiss: void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="first-run">
|
||||
<h2>Map your network</h2>
|
||||
{#if storageReady}
|
||||
<p class="storage-note">
|
||||
Your network data file is ready. Add your first machine to start mapping.
|
||||
</p>
|
||||
{:else if isSampleData}
|
||||
<p class="sample-note">This is sample data — replace it with your own machines.</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if writable}
|
||||
<button type="button" on:click={() => dispatch('addmachine')}>
|
||||
<span class="action-glyph" aria-hidden="true">+</span>
|
||||
<span class="action-text">
|
||||
<strong>Add a machine</strong>
|
||||
<span>Servers, NAS boxes, mini PCs — anything with an IP.</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" disabled={!hasMachines} on:click={() => dispatch('connectport')}>
|
||||
<span class="action-glyph" aria-hidden="true">⇄</span>
|
||||
<span class="action-text">
|
||||
<strong>Connect a port</strong>
|
||||
<span>
|
||||
{hasMachines
|
||||
? 'Open a machine and add a port to draw your first cable.'
|
||||
: 'Add a machine first, then wire up its ports.'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" on:click={() => dispatch('openipam')}>
|
||||
<span class="action-glyph" aria-hidden="true">⌗</span>
|
||||
<span class="action-text">
|
||||
<strong>Open IPAM</strong>
|
||||
<span>See subnet utilisation and grab the next free IP.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="dismiss" on:click={() => dispatch('dismiss')}>Dismiss</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.first-run {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 20;
|
||||
width: min(92vw, 440px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sample-note {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
.storage-note {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions > button {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.actions > button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.actions > button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-glyph {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.action-text strong {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.action-text span {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
align-self: flex-end;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.dismiss:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { listIconDefinitions } from '$lib/config/iconRegistry';
|
||||
import clickOutside from '$lib/actions/clickOutside';
|
||||
|
||||
// Anchored popover for choosing an icon. The parent positions it (the
|
||||
// wrapper just needs position: relative); selection is reported via the
|
||||
// `select` event and the popover closes itself on outside click/Escape.
|
||||
export let currentIconKey: string | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher<{ select: string; close: void }>();
|
||||
|
||||
const iconDefinitions = listIconDefinitions();
|
||||
const iconResultLimit = 100;
|
||||
|
||||
let iconSearch = '';
|
||||
|
||||
function focusInput(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
$: searchTokens = normalize(iconSearch).split(' ').filter(Boolean);
|
||||
$: filteredIcons = iconDefinitions.filter((icon) => {
|
||||
if (searchTokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const searchable = normalize(`${icon.label} ${icon.key}`);
|
||||
return searchTokens.every((token) => searchable.includes(token));
|
||||
});
|
||||
$: visibleIcons = filteredIcons.slice(0, iconResultLimit);
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="icon-popover" use:clickOutside={() => dispatch('close')} on:keydown={onKeydown}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search icons…"
|
||||
aria-label="Search icons"
|
||||
bind:value={iconSearch}
|
||||
use:focusInput
|
||||
/>
|
||||
<div class="results-meta">
|
||||
Showing {visibleIcons.length} of {filteredIcons.length} icons
|
||||
</div>
|
||||
{#if visibleIcons.length > 0}
|
||||
<div class="results-grid" role="listbox" aria-label="Icon results">
|
||||
{#each visibleIcons as icon (icon.key)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={icon.key === currentIconKey}
|
||||
class:selected={icon.key === currentIconKey}
|
||||
title={icon.label}
|
||||
on:click={() => dispatch('select', icon.key)}
|
||||
>
|
||||
<img src={icon.path} alt="" loading="lazy" />
|
||||
<span>{icon.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-results">No icons match your search.</div>
|
||||
{/if}
|
||||
{#if currentIconKey}
|
||||
<div class="popover-footer">
|
||||
<button type="button" class="clear-icon" on:click={() => dispatch('select', '')}>
|
||||
Remove icon
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.icon-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: min(420px, 80vw);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
height: var(--control-h);
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.results-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results-grid button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.results-grid button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.results-grid button.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.results-grid img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.results-grid span {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
padding: 12px 4px;
|
||||
}
|
||||
|
||||
.popover-footer {
|
||||
border-top: 1px solid var(--surface-2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.clear-icon:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -32,14 +32,23 @@
|
||||
<div class="modal-backdrop" on:click={onBackdropClick}>
|
||||
<div class="modal-container" style={`max-width: ${maxWidth}`}>
|
||||
<header class="modal-header">
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
|
||||
<slot name="header">
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
</slot>
|
||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
|
||||
>×</button
|
||||
>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
{#if $$slots.footer}
|
||||
<footer class="modal-footer-bar">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -61,49 +70,64 @@
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-modal);
|
||||
box-shadow: 0 24px 64px rgb(15 18 24 / 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--panel-contrast);
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
border: 1px solid transparent;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted-text);
|
||||
font-size: 1.7rem;
|
||||
color: var(--text-2);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: var(--chip-bg);
|
||||
color: var(--panel-contrast);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-footer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.modal-backdrop {
|
||||
padding: 0;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type cytoscape from 'cytoscape';
|
||||
import { vlanPaletteColor } from '$lib/graph/vlanPalette';
|
||||
import type { GraphNodeElement } from '$lib/graph/types';
|
||||
|
||||
// HTML card overlay for graph nodes. Cytoscape keeps invisible hit-target
|
||||
// nodes (interaction, layout, edge anchoring); this layer draws the actual
|
||||
// cards at the same model coordinates and mirrors pan/zoom with a single
|
||||
// container transform. pointer-events stay off so Cytoscape owns input.
|
||||
export let cy: cytoscape.Core | null = null;
|
||||
export let nodes: GraphNodeElement[] = [];
|
||||
export let dimmedIds: ReadonlySet<string> = new Set();
|
||||
export let matchedIds: ReadonlySet<string> = new Set();
|
||||
export let activeId: string | null = null;
|
||||
|
||||
let viewport = { panX: 0, panY: 0, zoom: 1 };
|
||||
let positionById: Record<string, { x: number; y: number }> = {};
|
||||
let attachedCy: cytoscape.Core | null = null;
|
||||
let rafId: number | null = null;
|
||||
|
||||
function syncFromCy() {
|
||||
rafId = null;
|
||||
if (!attachedCy || attachedCy.destroyed()) {
|
||||
return;
|
||||
}
|
||||
const pan = attachedCy.pan();
|
||||
viewport = { panX: pan.x, panY: pan.y, zoom: attachedCy.zoom() };
|
||||
const next: Record<string, { x: number; y: number }> = {};
|
||||
for (const node of attachedCy.nodes().toArray()) {
|
||||
const position = node.position();
|
||||
next[node.id()] = { x: position.x, y: position.y };
|
||||
}
|
||||
positionById = next;
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(syncFromCy);
|
||||
}
|
||||
}
|
||||
|
||||
function attach(core: cytoscape.Core | null) {
|
||||
if (core === attachedCy) {
|
||||
return;
|
||||
}
|
||||
if (attachedCy && !attachedCy.destroyed()) {
|
||||
attachedCy.off('render', scheduleSync);
|
||||
}
|
||||
attachedCy = core;
|
||||
if (attachedCy) {
|
||||
// 'render' fires on pan, zoom, layout, add/remove and position
|
||||
// changes, so one rAF-throttled listener keeps cards in lockstep.
|
||||
attachedCy.on('render', scheduleSync);
|
||||
scheduleSync();
|
||||
}
|
||||
}
|
||||
|
||||
$: attach(cy);
|
||||
// Re-read positions whenever the visible node set changes (refreshGraph
|
||||
// re-adds elements and re-runs layout synchronously).
|
||||
$: if (nodes && attachedCy) {
|
||||
scheduleSync();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
attach(null);
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
});
|
||||
|
||||
function fallbackGlyph(kind: string, deviceClass: string | undefined): string {
|
||||
if (kind === 'device') {
|
||||
return deviceClass === 'infrastructure' ? '⇄' : '○';
|
||||
}
|
||||
if (kind === 'vm') {
|
||||
return '◫';
|
||||
}
|
||||
return '🖥';
|
||||
}
|
||||
|
||||
// position is passed in from the template (rather than read from
|
||||
// positionById here) so Svelte's legacy-mode invalidation sees the
|
||||
// dependency and re-renders cards when positions change.
|
||||
function cardStyle(
|
||||
node: GraphNodeElement,
|
||||
position: { x: number; y: number } | undefined
|
||||
): string {
|
||||
const { data } = node;
|
||||
if (!position) {
|
||||
return 'display: none;';
|
||||
}
|
||||
const parts = [
|
||||
`left: ${position.x}px`,
|
||||
`top: ${position.y}px`,
|
||||
`width: ${data.nodeWidth}px`,
|
||||
`height: ${data.nodeHeight}px`
|
||||
];
|
||||
if (typeof data.vlanIndex === 'number') {
|
||||
parts.push(`--card-vlan: ${vlanPaletteColor(data.vlanIndex)}`);
|
||||
}
|
||||
return `${parts.join('; ')};`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card-layer" aria-hidden="true">
|
||||
<div
|
||||
class="card-plane"
|
||||
style={`transform: translate(${viewport.panX}px, ${viewport.panY}px) scale(${viewport.zoom});`}
|
||||
>
|
||||
{#each nodes as node (node.data.id)}
|
||||
{@const data = node.data}
|
||||
{@const isDumb = data.kind === 'device' && data.deviceClass !== 'infrastructure'}
|
||||
<div
|
||||
class="node-card kind-{data.kind}"
|
||||
class:infra={data.kind === 'device' && data.deviceClass === 'infrastructure'}
|
||||
class:dumb={isDumb}
|
||||
class:has-vlan={typeof data.vlanIndex === 'number'}
|
||||
class:dimmed={dimmedIds.has(data.id)}
|
||||
class:match={matchedIds.has(data.id)}
|
||||
class:active={activeId === data.id}
|
||||
style={cardStyle(node, positionById[node.data.id])}
|
||||
>
|
||||
<div class="icon-tile" class:round={isDumb}>
|
||||
{#if data.iconUrl}
|
||||
<img src={data.iconUrl} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<span class="icon-fallback">{fallbackGlyph(data.kind, data.deviceClass)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<span class="card-name">{data.rawName ?? data.label}</span>
|
||||
{#if !isDumb && (data.meta || data.vmCount)}
|
||||
<span class="card-meta">
|
||||
{#if data.meta}{data.meta}{/if}{#if data.kind === 'machine' && (data.vmCount ?? 0) > 0}{#if data.meta} ·
|
||||
{/if}<span class="vm-count">{data.vmCount} {data.vmCount === 1 ? 'VM' : 'VMs'}</span
|
||||
>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.card-plane {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--graph-node-border);
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.07);
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.node-card.has-vlan {
|
||||
border-left: 4px solid var(--card-vlan);
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
.node-card.kind-vm {
|
||||
background: var(--surface-2);
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.node-card.infra {
|
||||
background: var(--graph-infra-fill);
|
||||
border-color: var(--graph-infra-border);
|
||||
}
|
||||
|
||||
.node-card.dumb {
|
||||
border: 1px dashed var(--graph-dumb-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 14px 0 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-card.dumb.has-vlan {
|
||||
border-left: 4px solid var(--card-vlan);
|
||||
}
|
||||
|
||||
.node-card.dimmed {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.node-card.match {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.node-card.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.icon-tile {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-tile.round {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.kind-vm .icon-tile {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.infra .icon-tile {
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .infra .icon-tile {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
}
|
||||
|
||||
.icon-tile img {
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.icon-fallback {
|
||||
font-size: 15px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.infra .icon-fallback {
|
||||
color: #e7ebf2;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.15;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.infra .card-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .infra .card-name {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dumb .card-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 550;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.kind-vm .card-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.infra .card-meta {
|
||||
color: #b6bfcc;
|
||||
}
|
||||
|
||||
.kind-vm .card-meta {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vm-count {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<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.unracked.length > 0}
|
||||
<details class="unracked">
|
||||
<summary>
|
||||
{layout.unracked.length}
|
||||
{layout.unracked.length === 1 ? 'entity is' : 'entities are'} not rack-mounted
|
||||
</summary>
|
||||
<div class="unracked-list">
|
||||
{#each layout.unracked as entity (`${entity.kind}:${entity.name}`)}
|
||||
<button
|
||||
type="button"
|
||||
class="unracked-item"
|
||||
title="Open the editor to place this in a rack"
|
||||
on:click={() => dispatch('select', { kind: entity.kind, name: entity.name })}
|
||||
>
|
||||
{#if resolveIconPath(entity.iconKey)}
|
||||
<img src={resolveIconPath(entity.iconKey)} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<span class="unracked-glyph" aria-hidden="true">
|
||||
{entity.kind === 'machine' ? '🖥' : '○'}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="unracked-name">{entity.name}</span>
|
||||
<span class="unracked-kind">{entity.subtitle || entity.kind}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rack-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
background: var(--bg-canvas, 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: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.rack-slot.device {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.rack-slot:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.unracked {
|
||||
max-width: 560px;
|
||||
margin: 1.25rem auto 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 0.5rem 0.8rem;
|
||||
}
|
||||
|
||||
.unracked summary {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unracked-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.unracked-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.4rem;
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.unracked-item:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.unracked-item img {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.unracked-glyph {
|
||||
width: 1.1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.unracked-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.unracked-kind {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</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';
|
||||
|
||||
@@ -61,7 +61,11 @@ export function cloneNetworkData(data: NetworkData): NetworkData {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function findOwnerByKindAndName(data: NetworkData, kind: OwnerKind, name: string): OwnerRef | undefined {
|
||||
function findOwnerByKindAndName(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
name: string
|
||||
): OwnerRef | undefined {
|
||||
if (kind === 'machine') {
|
||||
const machine = data.machines.find((item) => equalsIgnoreCase(item.machineName, name));
|
||||
if (!machine) {
|
||||
@@ -113,32 +117,49 @@ function ensureReciprocalConnections(data: NetworkData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPort = findPort(targetOwner, port.connectedTo.port);
|
||||
if (!targetPort) {
|
||||
delete port.connectedTo;
|
||||
continue;
|
||||
}
|
||||
const targetPort = findPort(targetOwner, port.connectedTo.port);
|
||||
if (!targetPort) {
|
||||
delete port.connectedTo;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedReciprocal = {
|
||||
device: owner.name,
|
||||
port: port.portName
|
||||
};
|
||||
if (!targetPort.connectedTo) {
|
||||
targetPort.connectedTo = expectedReciprocal;
|
||||
continue;
|
||||
}
|
||||
const expectedReciprocal: Port['connectedTo'] = {
|
||||
device: owner.name,
|
||||
port: port.portName,
|
||||
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
|
||||
};
|
||||
if (!targetPort.connectedTo) {
|
||||
targetPort.connectedTo = expectedReciprocal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasMatchingReciprocal =
|
||||
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
|
||||
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
|
||||
if (hasMatchingReciprocal) {
|
||||
continue;
|
||||
const hasMatchingReciprocal =
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearInboundReferences(data: NetworkData, predicate: (connection: Port['connectedTo']) => boolean) {
|
||||
function clearInboundReferences(
|
||||
data: NetworkData,
|
||||
predicate: (connection: Port['connectedTo']) => boolean
|
||||
) {
|
||||
for (const owner of allOwners(data)) {
|
||||
for (const port of owner.ports) {
|
||||
if (!port.connectedTo) {
|
||||
@@ -212,7 +233,9 @@ export function renameOwner(
|
||||
}
|
||||
|
||||
if (kind === 'machine') {
|
||||
const machine = cloned.machines.find((item) => equalsIgnoreCase(item.machineName, previousName));
|
||||
const machine = cloned.machines.find((item) =>
|
||||
equalsIgnoreCase(item.machineName, previousName)
|
||||
);
|
||||
if (machine) {
|
||||
machine.machineName = nextName;
|
||||
}
|
||||
@@ -278,14 +301,15 @@ export function deleteOwner(data: NetworkData, kind: OwnerKind, ownerName: strin
|
||||
const cloned = cloneNetworkData(data);
|
||||
|
||||
if (kind === 'machine') {
|
||||
cloned.machines = cloned.machines.filter((machine) => !equalsIgnoreCase(machine.machineName, ownerName));
|
||||
cloned.machines = cloned.machines.filter(
|
||||
(machine) => !equalsIgnoreCase(machine.machineName, ownerName)
|
||||
);
|
||||
} else {
|
||||
cloned.devices = cloned.devices.filter((device) => !equalsIgnoreCase(device.name, ownerName));
|
||||
}
|
||||
|
||||
clearInboundReferences(
|
||||
cloned,
|
||||
(connection) => Boolean(connection && equalsIgnoreCase(connection.device, ownerName))
|
||||
clearInboundReferences(cloned, (connection) =>
|
||||
Boolean(connection && equalsIgnoreCase(connection.device, ownerName))
|
||||
);
|
||||
ensureReciprocalConnections(cloned);
|
||||
return cloned;
|
||||
@@ -317,19 +341,94 @@ export function deletePort(
|
||||
}
|
||||
}
|
||||
|
||||
clearInboundReferences(
|
||||
cloned,
|
||||
(connection) =>
|
||||
Boolean(
|
||||
connection &&
|
||||
equalsIgnoreCase(connection.device, ownerName) &&
|
||||
equalsIgnoreCase(connection.port, portName)
|
||||
)
|
||||
clearInboundReferences(cloned, (connection) =>
|
||||
Boolean(
|
||||
connection &&
|
||||
equalsIgnoreCase(connection.device, ownerName) &&
|
||||
equalsIgnoreCase(connection.port, portName)
|
||||
)
|
||||
);
|
||||
ensureReciprocalConnections(cloned);
|
||||
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,
|
||||
@@ -350,7 +449,9 @@ export function setPortConnection(
|
||||
const previousConnection = port.connectedTo;
|
||||
if (previousConnection) {
|
||||
const previousOwner = findOwnerByName(cloned, previousConnection.device);
|
||||
const previousPort = previousOwner ? findPort(previousOwner, previousConnection.port) : undefined;
|
||||
const previousPort = previousOwner
|
||||
? findPort(previousOwner, previousConnection.port)
|
||||
: undefined;
|
||||
if (previousPort) {
|
||||
delete previousPort.connectedTo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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 UnrackedEntity {
|
||||
kind: 'machine' | 'device';
|
||||
name: string;
|
||||
iconKey?: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export interface RackLayout {
|
||||
racks: RackColumn[];
|
||||
warnings: string[];
|
||||
unrackedCount: number;
|
||||
unracked: UnrackedEntity[];
|
||||
}
|
||||
|
||||
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>();
|
||||
const unracked: UnrackedEntity[] = [];
|
||||
|
||||
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) {
|
||||
unracked.push({
|
||||
kind: 'machine',
|
||||
name: machine.machineName,
|
||||
iconKey: machine.iconKey,
|
||||
subtitle: machine.role
|
||||
});
|
||||
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) {
|
||||
unracked.push({
|
||||
kind: 'device',
|
||||
name: device.name,
|
||||
iconKey: device.iconKey,
|
||||
subtitle: device.type
|
||||
});
|
||||
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: unracked.length, unracked };
|
||||
}
|
||||
|
||||
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,37 @@
|
||||
// Reads the graph-facing design tokens off the document root so the
|
||||
// Cytoscape stylesheet (canvas-drawn edges) always matches the CSS theme.
|
||||
// The --graph-* custom properties are plain hex on purpose: Cytoscape's
|
||||
// colour parser doesn't understand oklch() or var().
|
||||
export interface CanvasTokens {
|
||||
bgCanvas: string;
|
||||
edge: string;
|
||||
border: string;
|
||||
chipBg: string;
|
||||
chipText: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const fallbacks: CanvasTokens = {
|
||||
bgCanvas: '#eef0f3',
|
||||
edge: '#8b94a3',
|
||||
border: '#dce0e6',
|
||||
chipBg: '#ffffff',
|
||||
chipText: '#3a4250',
|
||||
accent: '#3565c4'
|
||||
};
|
||||
|
||||
export function readCanvasTokens(): CanvasTokens {
|
||||
if (typeof document === 'undefined') {
|
||||
return fallbacks;
|
||||
}
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
|
||||
return {
|
||||
bgCanvas: read('--bg-canvas', fallbacks.bgCanvas),
|
||||
edge: read('--graph-edge', fallbacks.edge),
|
||||
border: read('--border', fallbacks.border),
|
||||
chipBg: read('--graph-edge-chip-bg', fallbacks.chipBg),
|
||||
chipText: read('--graph-edge-chip-text', fallbacks.chipText),
|
||||
accent: read('--accent', fallbacks.accent)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered variant used for Enter/Shift-Enter cycling: node IDs in graph
|
||||
* order, deduped, with VM hits mapped to their host so the camera can land
|
||||
* on something visible while the host's VMs are collapsed.
|
||||
*/
|
||||
export function computeSearchMatchList(
|
||||
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||
query: string
|
||||
): string[] {
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
for (const node of transformed.nodes) {
|
||||
const isMatch = collectSearchableText(node).some((text) =>
|
||||
text.toLowerCase().includes(normalized)
|
||||
);
|
||||
if (!isMatch) {
|
||||
continue;
|
||||
}
|
||||
const ids =
|
||||
node.data.kind === 'vm' && node.data.hostMachineId
|
||||
? [node.data.id, node.data.hostMachineId]
|
||||
: [node.data.id];
|
||||
for (const id of ids) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ordered.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
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 type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
|
||||
import { buildVlanIndexMap } from './vlanPalette';
|
||||
import {
|
||||
NODE_DIMENSIONS,
|
||||
estimatePillWidth,
|
||||
type DeviceClass,
|
||||
type GraphEdgeElement,
|
||||
type GraphNodeElement,
|
||||
type GraphTransformResult
|
||||
} from './types';
|
||||
|
||||
interface EndpointOwner {
|
||||
kind: 'machine' | 'device';
|
||||
@@ -55,6 +64,55 @@ 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));
|
||||
const vlanIndexById = buildVlanIndexMap(subnets);
|
||||
return (ip: string): { vlanId: number; vlanIndex: number } | undefined => {
|
||||
const home = vlanSubnets.find((subnet) => cidrContains(subnet.cidr, ip));
|
||||
if (!home || typeof home.vlanId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return { vlanId: home.vlanId, vlanIndex: vlanIndexById.get(home.vlanId) ?? 0 };
|
||||
};
|
||||
}
|
||||
|
||||
// Devices whose type reads as network infrastructure get the dark "infra"
|
||||
// treatment on the map; everything else (consoles, peripherals, UPSes) is an
|
||||
// unmanaged "dumb" device rendered as a dashed pill.
|
||||
const infrastructureTypePattern =
|
||||
/switch|router|firewall|gateway|access\s?point|\bap\b|wan|isp|modem|dns|patch\s?panel/i;
|
||||
|
||||
export function classifyDevice(type: string | undefined): DeviceClass {
|
||||
return type && infrastructureTypePattern.test(type) ? 'infrastructure' : 'dumb';
|
||||
}
|
||||
|
||||
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Math.min(a, b);
|
||||
@@ -82,6 +140,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,19 +169,23 @@ 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({
|
||||
data: {
|
||||
id: machineNodeId,
|
||||
label: machine.machineName,
|
||||
rawName: machine.machineName,
|
||||
kind: 'machine',
|
||||
id: machineNodeId,
|
||||
label: machine.machineName,
|
||||
rawName: machine.machineName,
|
||||
kind: 'machine',
|
||||
iconKey: machine.iconKey,
|
||||
iconUrl: resolveIconPath(machine.iconKey),
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 110,
|
||||
nodeWidth: NODE_DIMENSIONS.machine.width,
|
||||
nodeHeight: NODE_DIMENSIONS.machine.height,
|
||||
meta: machine.ipAddress,
|
||||
vmCount: sourceMachineVms.length,
|
||||
...resolveVlan(machine.ipAddress),
|
||||
details: {
|
||||
type: 'machine',
|
||||
name: machine.machineName,
|
||||
@@ -133,6 +196,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
|
||||
@@ -158,14 +222,17 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
hostMachineId: machineNodeId,
|
||||
iconKey: vm.iconKey,
|
||||
iconUrl: resolveIconPath(vm.iconKey),
|
||||
nodeWidth: 140,
|
||||
nodeHeight: 66,
|
||||
nodeWidth: NODE_DIMENSIONS.vm.width,
|
||||
nodeHeight: NODE_DIMENSIONS.vm.height,
|
||||
meta: vm.ipAddress,
|
||||
...resolveVlan(vm.ipAddress),
|
||||
details: {
|
||||
type: 'vm',
|
||||
name: vm.name,
|
||||
iconKey: vm.iconKey,
|
||||
ip: vm.ipAddress,
|
||||
role: vm.role,
|
||||
macAddress: vm.macAddress,
|
||||
hostName: machine.machineName
|
||||
}
|
||||
}
|
||||
@@ -178,8 +245,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
id: hostingEdgeId,
|
||||
source: machineNodeId,
|
||||
target: vmNodeId,
|
||||
kind: 'hosting',
|
||||
label: 'hosts'
|
||||
kind: 'hosting'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -203,6 +269,15 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
for (const device of data.devices) {
|
||||
const deviceNodeId = toDeviceNodeId(device.name);
|
||||
const devicePorts = [...(device.ports ?? [])];
|
||||
const deviceClass = classifyDevice(device.type);
|
||||
const portsInUse = devicePorts.filter((port) => parseConnectedTo(port.connectedTo)).length;
|
||||
let deviceMeta: string | undefined;
|
||||
if (deviceClass === 'infrastructure') {
|
||||
deviceMeta =
|
||||
devicePorts.length > 0
|
||||
? `${devicePorts.length} ${devicePorts.length === 1 ? 'port' : 'ports'} · ${portsInUse} in use`
|
||||
: device.ipAddress;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
data: {
|
||||
@@ -210,10 +285,19 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
label: device.name,
|
||||
rawName: device.name,
|
||||
kind: 'device',
|
||||
deviceClass,
|
||||
iconKey: device.iconKey,
|
||||
iconUrl: resolveIconPath(device.iconKey),
|
||||
nodeWidth: 176,
|
||||
nodeHeight: 116,
|
||||
nodeWidth:
|
||||
deviceClass === 'infrastructure'
|
||||
? NODE_DIMENSIONS.infrastructure.width
|
||||
: estimatePillWidth(device.name),
|
||||
nodeHeight:
|
||||
deviceClass === 'infrastructure'
|
||||
? NODE_DIMENSIONS.infrastructure.height
|
||||
: NODE_DIMENSIONS.dumbHeight,
|
||||
...(deviceMeta ? { meta: deviceMeta } : {}),
|
||||
...resolveVlan(device.ipAddress),
|
||||
details: {
|
||||
type: 'device',
|
||||
name: device.name,
|
||||
@@ -293,6 +377,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
|
||||
physicalEdgeCounter += 1;
|
||||
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
|
||||
const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable;
|
||||
|
||||
// One chip per cable: interface name (prefer the machine side —
|
||||
// "eth0" reads better than "port 3") plus speed, with the network
|
||||
// default of 1GbE omitted so only exceptions are informative.
|
||||
const ifname =
|
||||
owner.kind !== 'machine' && targetOwner.kind === 'machine'
|
||||
? connectedTo.port
|
||||
: port.portName;
|
||||
const chipLabelNoSpeed = ifname;
|
||||
const chipLabel =
|
||||
typeof speedGbps === 'number' && speedGbps !== 1 ? `${ifname} · ${speedGbps}GbE` : ifname;
|
||||
|
||||
edges.push({
|
||||
data: {
|
||||
@@ -300,11 +396,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
source: owner.nodeId,
|
||||
target: targetOwner.nodeId,
|
||||
kind: 'physical',
|
||||
label: speedGbps ? `${speedGbps}GbE` : undefined,
|
||||
chipLabel,
|
||||
chipLabelNoSpeed,
|
||||
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 } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
export type GraphNodeKind = 'machine' | 'device' | 'vm';
|
||||
export type GraphEdgeKind = 'physical' | 'hosting';
|
||||
export type DeviceClass = 'infrastructure' | 'dumb';
|
||||
|
||||
// Hit-target dimensions for the invisible Cytoscape nodes. The HTML card
|
||||
// layer sizes its cards from the same constants so edges anchor exactly at
|
||||
// card borders.
|
||||
export const NODE_DIMENSIONS = {
|
||||
machine: { width: 218, height: 56 },
|
||||
infrastructure: { width: 218, height: 56 },
|
||||
vm: { width: 170, height: 44 },
|
||||
dumbHeight: 36
|
||||
} as const;
|
||||
|
||||
// Content-hugging pills need a width so edges anchor at the card border:
|
||||
// 24px icon tile + gaps/padding ≈ 54px, plus the rendered text width.
|
||||
// Text is measured with a canvas context (matching the pill's 12.5px type);
|
||||
// the char-count estimate is only an SSR/test fallback.
|
||||
let pillMeasureContext: CanvasRenderingContext2D | null = null;
|
||||
|
||||
export function estimatePillWidth(name: string): number {
|
||||
let textWidth = name.length * 7.6;
|
||||
if (typeof document !== 'undefined') {
|
||||
pillMeasureContext ??= document.createElement('canvas').getContext('2d');
|
||||
if (pillMeasureContext) {
|
||||
pillMeasureContext.font =
|
||||
'600 12.5px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
||||
textWidth = pillMeasureContext.measureText(name).width;
|
||||
}
|
||||
}
|
||||
return Math.round(Math.min(300, Math.max(110, textWidth + 60)));
|
||||
}
|
||||
|
||||
export interface PortDetails {
|
||||
portName: string;
|
||||
speedGbps?: number;
|
||||
macAddress?: string;
|
||||
connectedTo?: {
|
||||
device: string;
|
||||
port: string;
|
||||
cable?: {
|
||||
type?: string;
|
||||
color?: string;
|
||||
lengthM?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,12 +56,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 +83,7 @@ export interface VmDetails {
|
||||
iconKey?: string;
|
||||
ip: string;
|
||||
role: string;
|
||||
macAddress?: string;
|
||||
hostName: string;
|
||||
}
|
||||
|
||||
@@ -54,12 +93,17 @@ export interface GraphNodeData {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: GraphNodeKind;
|
||||
deviceClass?: DeviceClass;
|
||||
rawName?: string;
|
||||
hostMachineId?: string;
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
iconUrl?: string;
|
||||
iconKey?: string;
|
||||
vlanId?: number;
|
||||
vlanIndex?: number;
|
||||
meta?: string;
|
||||
vmCount?: number;
|
||||
details?: GraphNodeDetails;
|
||||
}
|
||||
|
||||
@@ -69,10 +113,16 @@ export interface GraphEdgeData {
|
||||
target: string;
|
||||
kind: GraphEdgeKind;
|
||||
label?: string;
|
||||
chipLabel?: string;
|
||||
chipLabelNoSpeed?: string;
|
||||
sourcePort?: string;
|
||||
targetPort?: string;
|
||||
speedGbps?: number;
|
||||
reciprocal?: boolean;
|
||||
cableType?: string;
|
||||
cableColorName?: string;
|
||||
cableColor?: string;
|
||||
cableLengthM?: number;
|
||||
}
|
||||
|
||||
export interface GraphNodeElement {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Subnet } from '$lib/types';
|
||||
|
||||
// Categorical VLAN palette from the UX review: equal lightness/chroma
|
||||
// (oklch 0.55/0.13 light, 0.68/0.13 dark), hue varies, so no VLAN shouts
|
||||
// louder than another. The actual oklch values live in app.css as
|
||||
// --vlan-0..--vlan-5 (light + dark variants); consumers are all HTML/CSS,
|
||||
// so we hand out var() references and let the theme resolve them.
|
||||
export const VLAN_PALETTE_SIZE = 6;
|
||||
|
||||
export function vlanPaletteColor(index: number): string {
|
||||
const slot = ((Math.trunc(index) % VLAN_PALETTE_SIZE) + VLAN_PALETTE_SIZE) % VLAN_PALETTE_SIZE;
|
||||
return `var(--vlan-${slot})`;
|
||||
}
|
||||
|
||||
// VLANs are assigned palette slots in declaration order (first appearance in
|
||||
// data.subnets), not by vlanId, so the first VLAN a user declares always gets
|
||||
// the first hue.
|
||||
export function buildVlanIndexMap(subnets: Subnet[] | undefined): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const subnet of subnets ?? []) {
|
||||
const { vlanId } = subnet;
|
||||
if (typeof vlanId !== 'number' || !Number.isFinite(vlanId)) {
|
||||
continue;
|
||||
}
|
||||
if (!map.has(vlanId)) {
|
||||
map.set(vlanId, map.size);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Legacy hex palette, still consumed by the pre-redesign Cytoscape node
|
||||
// border styling; removed once the HTML card layer takes over VLAN colour.
|
||||
const legacyPalette = [
|
||||
'#e11d48',
|
||||
'#7c3aed',
|
||||
'#0891b2',
|
||||
'#ca8a04',
|
||||
'#15803d',
|
||||
'#c2410c',
|
||||
'#4f46e5',
|
||||
'#0d9488',
|
||||
'#be185d',
|
||||
'#57534e'
|
||||
];
|
||||
|
||||
export function vlanColor(vlanId: number): string {
|
||||
return legacyPalette[Math.abs(Math.trunc(vlanId)) % legacyPalette.length];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
checkWritableState as checkWritableStateCore,
|
||||
getWritableState as getWritableStateCore,
|
||||
isWriteEnabled as isWriteEnabledCore,
|
||||
readOrInitializeNetworkFile as readOrInitializeNetworkFileCore,
|
||||
readNetworkFile as readNetworkFileCore,
|
||||
writeNetworkFile as writeNetworkFileCore
|
||||
} from '../shared/networkPersistenceCore.mjs';
|
||||
@@ -18,6 +19,11 @@ export interface WritableState {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface NetworkFileBootstrapResult extends NetworkFileMetadata {
|
||||
data: NetworkData;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export function isWriteEnabled(): boolean {
|
||||
return isWriteEnabledCore();
|
||||
}
|
||||
@@ -26,6 +32,10 @@ export async function readNetworkFile(): Promise<{ data: NetworkData } & Network
|
||||
return (await readNetworkFileCore()) as { data: NetworkData } & NetworkFileMetadata;
|
||||
}
|
||||
|
||||
export async function readOrInitializeNetworkFile(): Promise<NetworkFileBootstrapResult> {
|
||||
return (await readOrInitializeNetworkFileCore()) as NetworkFileBootstrapResult;
|
||||
}
|
||||
|
||||
export async function writeNetworkFile(data: NetworkData): Promise<NetworkFileMetadata> {
|
||||
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,25 @@ import path from 'node:path';
|
||||
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
||||
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
||||
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
||||
* @typedef {{ status: 'ready'; data: NetworkData; source: string; updatedAt: string } | { status: 'missing' | 'blank'; source: string; updatedAt: string | null }} NetworkFileReadState
|
||||
* @typedef {NetworkFileReadResult & { initialized: boolean }} NetworkFileBootstrapResult
|
||||
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
||||
* @typedef {{ createBackup?: boolean; beforeCommit?: () => Promise<void> }} WriteNetworkFileOptions
|
||||
*/
|
||||
|
||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||
const DATA_FILE_DEFAULT = path.resolve(process.cwd(), 'data/network.json');
|
||||
const BACKUP_DIR_DEFAULT = path.resolve(process.cwd(), 'data/.backups');
|
||||
const EMPTY_NETWORK_DATA = Object.freeze({
|
||||
machines: Object.freeze([]),
|
||||
devices: Object.freeze([])
|
||||
});
|
||||
/** @type {Map<string, Promise<unknown>>} */
|
||||
const fileWriteOperations = new Map();
|
||||
/** @type {Map<string, Promise<NetworkFileBootstrapResult>>} */
|
||||
const initializationFlights = new Map();
|
||||
|
||||
class InitializationSupersededError extends Error {}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
@@ -86,20 +99,57 @@ async function readUpdatedAt(dataFilePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<NetworkFileReadResult>}
|
||||
* @returns {Promise<NetworkFileReadState>}
|
||||
*/
|
||||
export async function readNetworkFile() {
|
||||
export async function readNetworkFileState() {
|
||||
const source = resolveDataFilePath();
|
||||
const raw = await readFile(source, 'utf8');
|
||||
let raw;
|
||||
try {
|
||||
raw = await readFile(source, 'utf8');
|
||||
} catch (error) {
|
||||
if (formatFileSystemErrorCode(error) === 'ENOENT') {
|
||||
return {
|
||||
status: 'missing',
|
||||
source,
|
||||
updatedAt: null
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (raw.trim().length === 0) {
|
||||
return {
|
||||
status: 'blank',
|
||||
source,
|
||||
updatedAt: await readUpdatedAt(source)
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
const updatedAt = await readUpdatedAt(source);
|
||||
return {
|
||||
status: 'ready',
|
||||
data: parsed,
|
||||
source,
|
||||
updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<NetworkFileReadResult>}
|
||||
*/
|
||||
export async function readNetworkFile() {
|
||||
const state = await readNetworkFileState();
|
||||
if (state.status !== 'ready') {
|
||||
throw new Error(`Network data file "${state.source}" is ${state.status}.`);
|
||||
}
|
||||
return {
|
||||
data: state.data,
|
||||
source: state.source,
|
||||
updatedAt: state.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataFilePath
|
||||
* @param {string} backupDirectory
|
||||
@@ -149,14 +199,18 @@ export async function trimBackups(backupDirectory, baseName, keep = 5) {
|
||||
|
||||
/**
|
||||
* @param {NetworkData} data
|
||||
* @param {string} source
|
||||
* @param {WriteNetworkFileOptions} [options]
|
||||
* @returns {Promise<NetworkFileMetadata>}
|
||||
*/
|
||||
export async function writeNetworkFile(data) {
|
||||
const source = resolveDataFilePath();
|
||||
async function writeNetworkFileUnlocked(data, source, options) {
|
||||
if (!isWriteEnabled()) {
|
||||
throw new Error('Writes disabled by NETWORK_READ_ONLY=true.');
|
||||
}
|
||||
|
||||
const backupDirectory = resolveBackupDirectory();
|
||||
const directory = path.dirname(source);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await createBackupIfPresent(source, backupDirectory);
|
||||
|
||||
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
|
||||
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
|
||||
@@ -168,10 +222,14 @@ export async function writeNetworkFile(data) {
|
||||
await fileHandle.close();
|
||||
}
|
||||
try {
|
||||
await options?.beforeCommit?.();
|
||||
if (options?.createBackup ?? true) {
|
||||
await createBackupIfPresent(source, backupDirectory);
|
||||
}
|
||||
await rename(temporaryPath, source);
|
||||
} catch (renameError) {
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => undefined);
|
||||
throw renameError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await trimBackups(backupDirectory, path.basename(source), 5);
|
||||
@@ -183,6 +241,36 @@ export async function writeNetworkFile(data) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {string} source
|
||||
* @param {() => Promise<T>} operation
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
async function withFileWriteLock(source, operation) {
|
||||
const previousOperation = fileWriteOperations.get(source) ?? Promise.resolve();
|
||||
const currentOperation = previousOperation.catch(() => undefined).then(operation);
|
||||
fileWriteOperations.set(source, currentOperation);
|
||||
|
||||
try {
|
||||
return await currentOperation;
|
||||
} finally {
|
||||
if (fileWriteOperations.get(source) === currentOperation) {
|
||||
fileWriteOperations.delete(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NetworkData} data
|
||||
* @param {WriteNetworkFileOptions} [options]
|
||||
* @returns {Promise<NetworkFileMetadata>}
|
||||
*/
|
||||
export function writeNetworkFile(data, options) {
|
||||
const source = resolveDataFilePath();
|
||||
return withFileWriteLock(source, () => writeNetworkFileUnlocked(data, source, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<WritableState>}
|
||||
*/
|
||||
@@ -223,6 +311,7 @@ export async function getWritableState() {
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-bitwise -- Node fs access mode flags combine bitwise
|
||||
await access(source, constants.R_OK | constants.W_OK);
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -243,3 +332,87 @@ export async function getWritableState() {
|
||||
export async function checkWritableState() {
|
||||
return (await getWritableState()).writable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads configured network data, creating a valid blank dataset only when the
|
||||
* file is missing/blank and the deployment has confirmed writable storage.
|
||||
*
|
||||
* @returns {Promise<NetworkFileBootstrapResult>}
|
||||
*/
|
||||
async function initializeNetworkFile() {
|
||||
const state = await readNetworkFileState();
|
||||
if (state.status === 'ready') {
|
||||
return {
|
||||
data: state.data,
|
||||
source: state.source,
|
||||
updatedAt: state.updatedAt,
|
||||
initialized: false
|
||||
};
|
||||
}
|
||||
|
||||
const writableState = await getWritableState();
|
||||
if (!writableState.writable) {
|
||||
const suffix = writableState.reason ? ` ${writableState.reason}` : '';
|
||||
throw new Error(`Network data file "${state.source}" is ${state.status}.${suffix}`);
|
||||
}
|
||||
|
||||
const data = {
|
||||
machines: [...EMPTY_NETWORK_DATA.machines],
|
||||
devices: [...EMPTY_NETWORK_DATA.devices]
|
||||
};
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await withFileWriteLock(state.source, () =>
|
||||
writeNetworkFileUnlocked(data, state.source, {
|
||||
createBackup: false,
|
||||
beforeCommit: async () => {
|
||||
const currentState = await readNetworkFileState();
|
||||
if (currentState.status === 'ready') {
|
||||
throw new InitializationSupersededError();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof InitializationSupersededError)) {
|
||||
throw error;
|
||||
}
|
||||
const currentState = await readNetworkFileState();
|
||||
if (currentState.status !== 'ready') {
|
||||
throw new Error(`Network data file "${currentState.source}" is ${currentState.status}.`);
|
||||
}
|
||||
return {
|
||||
data: currentState.data,
|
||||
source: currentState.source,
|
||||
updatedAt: currentState.updatedAt,
|
||||
initialized: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
data,
|
||||
source: metadata.source,
|
||||
updatedAt: metadata.updatedAt,
|
||||
initialized: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<NetworkFileBootstrapResult>}
|
||||
*/
|
||||
export function readOrInitializeNetworkFile() {
|
||||
const source = resolveDataFilePath();
|
||||
const existingFlight = initializationFlights.get(source);
|
||||
if (existingFlight) {
|
||||
return existingFlight;
|
||||
}
|
||||
|
||||
const flight = initializeNetworkFile();
|
||||
initializationFlights.set(source, flight);
|
||||
const clearFlight = () => {
|
||||
if (initializationFlights.get(source) === flight) {
|
||||
initializationFlights.delete(source);
|
||||
}
|
||||
};
|
||||
flight.then(clearFlight, clearFlight);
|
||||
return flight;
|
||||
}
|
||||
|
||||
@@ -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,41 @@ 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 +167,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 +235,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 +248,8 @@ export function normalizeVm(issues, path, value) {
|
||||
name,
|
||||
role,
|
||||
ipAddress,
|
||||
...(iconKey ? { iconKey } : {})
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(macAddress ? { macAddress } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +270,11 @@ 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)) {
|
||||
@@ -233,8 +312,16 @@ function normalizeMachine(issues, path, value) {
|
||||
issues.push({ path: `${path}.hardware`, message: 'must be an object' });
|
||||
}
|
||||
const hardware = {
|
||||
cpu: readString(issues, `${path}.hardware.cpu`, isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined),
|
||||
ram: readString(issues, `${path}.hardware.ram`, isRecord(hardwareRaw) ? hardwareRaw.ram : undefined),
|
||||
cpu: readString(
|
||||
issues,
|
||||
`${path}.hardware.cpu`,
|
||||
isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined
|
||||
),
|
||||
ram: readString(
|
||||
issues,
|
||||
`${path}.hardware.ram`,
|
||||
isRecord(hardwareRaw) ? hardwareRaw.ram : undefined
|
||||
),
|
||||
networkPorts: readNumber(
|
||||
issues,
|
||||
`${path}.hardware.networkPorts`,
|
||||
@@ -247,10 +334,15 @@ function normalizeMachine(issues, path, value) {
|
||||
isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined,
|
||||
{ optional: true, min: 0 }
|
||||
),
|
||||
gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
})
|
||||
gpu: readString(
|
||||
issues,
|
||||
`${path}.hardware.gpu`,
|
||||
isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined,
|
||||
{
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
const portsRaw = value.ports;
|
||||
@@ -287,6 +379,8 @@ function normalizeMachine(issues, path, value) {
|
||||
role,
|
||||
operatingSystem,
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
...(rack ? { rack } : {}),
|
||||
software: { vms },
|
||||
hardware: {
|
||||
cpu: hardware.cpu,
|
||||
@@ -316,8 +410,12 @@ function normalizeDevice(issues, path, value) {
|
||||
const name = readString(issues, `${path}.name`, value.name);
|
||||
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||
const type = readString(issues, `${path}.type`, value.type);
|
||||
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
|
||||
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 +443,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}
|
||||
@@ -375,7 +547,10 @@ export function validateNetworkData(value) {
|
||||
|
||||
const machines = [];
|
||||
const machineEntries = [];
|
||||
for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw) ? machinesRaw : []).entries()) {
|
||||
for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw)
|
||||
? machinesRaw
|
||||
: []
|
||||
).entries()) {
|
||||
const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue);
|
||||
if (machine) {
|
||||
machines.push(machine);
|
||||
@@ -385,7 +560,10 @@ export function validateNetworkData(value) {
|
||||
|
||||
const devices = [];
|
||||
const deviceEntries = [];
|
||||
for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw) ? devicesRaw : []).entries()) {
|
||||
for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw)
|
||||
? devicesRaw
|
||||
: []
|
||||
).entries()) {
|
||||
const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue);
|
||||
if (device) {
|
||||
devices.push(device);
|
||||
@@ -426,9 +604,67 @@ 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[];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import { json } from '@sveltejs/kit';
|
||||
import { validateNetworkData } from '$lib/data/networkSchema';
|
||||
import {
|
||||
getWritableState,
|
||||
readNetworkFile,
|
||||
readOrInitializeNetworkFile,
|
||||
writeNetworkFile
|
||||
} from '$lib/server/networkPersistence';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const staticDemoMode = import.meta.env.OND_STATIC_DEMO;
|
||||
const staticDemoReason = 'Static demo deployment; changes are not persisted.';
|
||||
|
||||
function serializeValidationErrors(errors: Array<{ path: string; message: string }>) {
|
||||
return errors.map((issue) => ({
|
||||
path: issue.path,
|
||||
@@ -22,10 +25,35 @@ function resolveReadOnlyErrorMessage(reason: string | null): string {
|
||||
return `Write API unavailable: ${reason}`;
|
||||
}
|
||||
|
||||
async function resolveFailedReadPayload(error: unknown) {
|
||||
const writableState = await getWritableState().catch(() => ({
|
||||
writable: false,
|
||||
reason: null
|
||||
}));
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : 'Failed to read network data file';
|
||||
return {
|
||||
error: message,
|
||||
writable: false,
|
||||
writableReason: writableState.reason
|
||||
};
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
if (staticDemoMode) {
|
||||
return json(
|
||||
{
|
||||
error: staticDemoReason,
|
||||
writable: false,
|
||||
writableReason: staticDemoReason
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ data, source, updatedAt }, writableState] = await Promise.all([
|
||||
readNetworkFile(),
|
||||
readOrInitializeNetworkFile(),
|
||||
getWritableState()
|
||||
]);
|
||||
const validation = validateNetworkData(data);
|
||||
@@ -33,7 +61,9 @@ export const GET: RequestHandler = async () => {
|
||||
return json(
|
||||
{
|
||||
error: 'Stored network data is invalid',
|
||||
details: serializeValidationErrors(validation.errors)
|
||||
details: serializeValidationErrors(validation.errors),
|
||||
writable: false,
|
||||
writableReason: writableState.reason
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -47,13 +77,22 @@ export const GET: RequestHandler = async () => {
|
||||
updatedAt
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : 'Failed to read network data file';
|
||||
return json({ error: message }, { status: 500 });
|
||||
return json(await resolveFailedReadPayload(error), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ request }) => {
|
||||
if (staticDemoMode) {
|
||||
return json(
|
||||
{
|
||||
error: resolveReadOnlyErrorMessage(staticDemoReason),
|
||||
writable: false,
|
||||
writableReason: staticDemoReason
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const writableStateBeforeWrite = await getWritableState();
|
||||
if (!writableStateBeforeWrite.writable) {
|
||||
return json(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import staticAdapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const deployTarget = (process.env.DEPLOY_TARGET ?? 'docker').toLowerCase();
|
||||
const adapter = deployTarget === 'netlify'
|
||||
? netlifyAdapter()
|
||||
: staticAdapter({
|
||||
fallback: 'index.html',
|
||||
strict: false
|
||||
});
|
||||
const adapter =
|
||||
deployTarget === 'netlify'
|
||||
? netlifyAdapter()
|
||||
: staticAdapter({
|
||||
fallback: 'index.html',
|
||||
strict: false
|
||||
});
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
let moduleSequence = 0;
|
||||
|
||||
function restoreEnvironment(name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = value;
|
||||
}
|
||||
|
||||
async function withPersistenceEnvironment(run, options = {}) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'ond-persistence-'));
|
||||
const source = path.join(root, 'data', 'network.json');
|
||||
const backupDirectory = path.join(root, 'backups');
|
||||
const previousDataFile = process.env.NETWORK_DATA_FILE;
|
||||
const previousBackupDirectory = process.env.NETWORK_BACKUP_DIR;
|
||||
const previousReadOnly = process.env.NETWORK_READ_ONLY;
|
||||
|
||||
process.env.NETWORK_DATA_FILE = source;
|
||||
process.env.NETWORK_BACKUP_DIR = backupDirectory;
|
||||
process.env.NETWORK_READ_ONLY = options.readOnly ? 'true' : 'false';
|
||||
moduleSequence += 1;
|
||||
|
||||
try {
|
||||
const persistence = await import(
|
||||
`../src/lib/shared/networkPersistenceCore.mjs?test=${moduleSequence}`
|
||||
);
|
||||
await run({ persistence, root, source, backupDirectory });
|
||||
} finally {
|
||||
restoreEnvironment('NETWORK_DATA_FILE', previousDataFile);
|
||||
restoreEnvironment('NETWORK_BACKUP_DIR', previousBackupDirectory);
|
||||
restoreEnvironment('NETWORK_READ_ONLY', previousReadOnly);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('initializes a missing writable data file with a blank network', async () => {
|
||||
await withPersistenceEnvironment(async ({ persistence, source, backupDirectory }) => {
|
||||
const result = await persistence.readOrInitializeNetworkFile();
|
||||
|
||||
assert.equal(result.initialized, true);
|
||||
assert.deepEqual(result.data, { machines: [], devices: [] });
|
||||
assert.equal(result.source, source);
|
||||
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), {
|
||||
machines: [],
|
||||
devices: []
|
||||
});
|
||||
assert.deepEqual(await readdir(backupDirectory), []);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertInitializesBlankFile(contents) {
|
||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
||||
await writeFile(source, contents, 'utf8');
|
||||
|
||||
const result = await persistence.readOrInitializeNetworkFile();
|
||||
|
||||
assert.equal(result.initialized, true);
|
||||
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), {
|
||||
machines: [],
|
||||
devices: []
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('initializes zero-byte and whitespace-only writable data files', async () => {
|
||||
await assertInitializesBlankFile('');
|
||||
await assertInitializesBlankFile(' \n\t');
|
||||
});
|
||||
|
||||
test('does not rewrite an existing valid empty network', async () => {
|
||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
||||
const before = await stat(source);
|
||||
|
||||
const result = await persistence.readOrInitializeNetworkFile();
|
||||
const after = await stat(source);
|
||||
|
||||
assert.equal(result.initialized, false);
|
||||
assert.deepEqual(result.data, { machines: [], devices: [] });
|
||||
assert.equal(after.mtimeMs, before.mtimeMs);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not overwrite real data written while a missing file is being initialized', async () => {
|
||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||
const realData = {
|
||||
machines: [{ machineName: 'Router' }],
|
||||
devices: []
|
||||
};
|
||||
|
||||
const bootstrap = persistence.readOrInitializeNetworkFile();
|
||||
const write = persistence.writeNetworkFile(realData, { createBackup: false });
|
||||
const [bootstrapResult] = await Promise.all([bootstrap, write]);
|
||||
|
||||
assert.equal(bootstrapResult.initialized, false);
|
||||
assert.deepEqual(bootstrapResult.data, realData);
|
||||
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), realData);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not replace malformed or schema-invalid non-empty JSON', async () => {
|
||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
||||
await writeFile(source, '{not json', 'utf8');
|
||||
|
||||
await assert.rejects(persistence.readOrInitializeNetworkFile(), SyntaxError);
|
||||
assert.equal(await readFile(source, 'utf8'), '{not json');
|
||||
});
|
||||
|
||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
||||
await writeFile(source, '{}\n', 'utf8');
|
||||
|
||||
const result = await persistence.readOrInitializeNetworkFile();
|
||||
|
||||
assert.equal(result.initialized, false);
|
||||
assert.deepEqual(result.data, {});
|
||||
assert.equal(await readFile(source, 'utf8'), '{}\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('never initializes missing or blank files in explicit read-only mode', async () => {
|
||||
await withPersistenceEnvironment(
|
||||
async ({ persistence, source }) => {
|
||||
await assert.rejects(
|
||||
persistence.readOrInitializeNetworkFile(),
|
||||
/Writes disabled by NETWORK_READ_ONLY=true/
|
||||
);
|
||||
await assert.rejects(stat(source), { code: 'ENOENT' });
|
||||
},
|
||||
{ readOnly: true }
|
||||
);
|
||||
|
||||
await withPersistenceEnvironment(
|
||||
async ({ persistence, source }) => {
|
||||
await mkdir(path.dirname(source), { recursive: true });
|
||||
await writeFile(source, ' \n', 'utf8');
|
||||
|
||||
await assert.rejects(
|
||||
persistence.readOrInitializeNetworkFile(),
|
||||
/Writes disabled by NETWORK_READ_ONLY=true/
|
||||
);
|
||||
assert.equal(await readFile(source, 'utf8'), ' \n');
|
||||
},
|
||||
{ readOnly: true }
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'unwritable blank files remain untouched and report the permission failure',
|
||||
{ skip: process.platform === 'win32' },
|
||||
async () => {
|
||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
||||
await writeFile(source, '', 'utf8');
|
||||
await chmod(source, 0o400);
|
||||
|
||||
try {
|
||||
const writableState = await persistence.getWritableState();
|
||||
assert.equal(writableState.writable, false);
|
||||
assert.match(writableState.reason, /not readable and writable/);
|
||||
await assert.rejects(
|
||||
persistence.readOrInitializeNetworkFile(),
|
||||
/not readable and writable/
|
||||
);
|
||||
assert.equal(await readFile(source, 'utf8'), '');
|
||||
} finally {
|
||||
await chmod(source, 0o600);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -2,6 +2,11 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const staticDemoMode = process.env.OND_STATIC_DEMO === 'true';
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.env.OND_STATIC_DEMO': JSON.stringify(staticDemoMode)
|
||||
},
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
|
||||