Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94bc7085bf | |||
| 6517fcb373 | |||
| ee143e607a | |||
| fe1e3c2abf | |||
| ec7598e15c | |||
| b4f5f12731 | |||
| b4c7d1bce4 | |||
| 2144eda352 | |||
| acf63a9707 | |||
| 336ee5e91b | |||
| 9cea45945c | |||
| 72fd77b9c9 | |||
| b04bc0acc9 | |||
| d04536d09d | |||
| ad3dccbdd4 | |||
| 5bed9cafb8 | |||
| ca26ea9063 | |||
| b1a3f08d5e | |||
| 9ffd8c4750 | |||
| a192bcab06 | |||
| b107d5d357 | |||
| e6487f7d25 | |||
| 53059caf31 | |||
| 808fe5a440 | |||
| c2bffede3e | |||
| 2fd4291bb9 | |||
| a09c98ec46 | |||
| bf0eac88f4 | |||
| 129041368b | |||
| 3615c38794 | |||
| e7dd300098 | |||
| d2826f4cb3 |
@@ -16,14 +16,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: docker.io/${{ env.IMAGE_NAME }}
|
images: docker.io/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
- name: Build (PR validation only)
|
- name: Build (PR validation only)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Docker Hub Description
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- README.md
|
||||||
|
- .github/workflows/dockerhub-description.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dockerhub-description:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Preflight Docker Hub repository access
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
DOCKERHUB_REPOSITORY: jcreek23/open-network-diagram
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_PASSWORD}" ]; then
|
||||||
|
echo "Missing DOCKERHUB_USERNAME or DOCKERHUB_PASSWORD secret."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOGIN_RESPONSE="$(curl -fsS -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_PASSWORD}\"}" \
|
||||||
|
https://hub.docker.com/v2/users/login/)"
|
||||||
|
|
||||||
|
TOKEN="$(printf '%s' "${LOGIN_RESPONSE}" | ruby -rjson -e 'input = STDIN.read; data = JSON.parse(input); puts data.fetch("token", "")')"
|
||||||
|
|
||||||
|
if [ -z "${TOKEN}" ]; then
|
||||||
|
echo "Docker Hub login succeeded but token was missing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: JWT ${TOKEN}" \
|
||||||
|
"https://hub.docker.com/v2/repositories/${DOCKERHUB_REPOSITORY}/" >/dev/null
|
||||||
|
|
||||||
|
echo "Docker Hub preflight access check passed for ${DOCKERHUB_REPOSITORY}."
|
||||||
|
|
||||||
|
- name: Update Docker Hub repository overview
|
||||||
|
uses: peter-evans/dockerhub-description@v5
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
repository: jcreek23/open-network-diagram
|
||||||
|
readme-filepath: ./README.md
|
||||||
|
enable-url-completion: true
|
||||||
@@ -13,27 +13,6 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changed-files:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
readme: ${{ steps.filter.outputs.readme }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Detect changed files
|
|
||||||
id: filter
|
|
||||||
uses: dorny/paths-filter@v3
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
readme:
|
|
||||||
- README.md
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -48,18 +27,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Create Git tag + GitHub release
|
- name: Create Git tag + GitHub release
|
||||||
id: semantic
|
id: semantic
|
||||||
uses: cycjimmy/semantic-release-action@v5
|
uses: cycjimmy/semantic-release-action@v6
|
||||||
with:
|
with:
|
||||||
extra_plugins: |
|
extra_plugins: |
|
||||||
@semantic-release/commit-analyzer
|
@semantic-release/commit-analyzer
|
||||||
@@ -78,20 +57,20 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: docker.io/${{ env.IMAGE_NAME }}
|
images: docker.io/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -101,7 +80,7 @@ jobs:
|
|||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
@@ -110,23 +89,3 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
dockerhub-overview:
|
|
||||||
needs: changed-files
|
|
||||||
if: needs.changed-files.outputs.readme == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Update Docker Hub repository overview
|
|
||||||
uses: peter-evans/dockerhub-description@v5
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
repository: ${{ env.IMAGE_NAME }}
|
|
||||||
readme-filepath: ./README.md
|
|
||||||
enable-url-completion: true
|
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ yarn.lock
|
|||||||
bun.lock
|
bun.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
src/lib/config/vendorIconManifest.ts
|
src/lib/config/vendorIconManifest.ts
|
||||||
|
|
||||||
|
# Design handoff bundles (prototypes, not code)
|
||||||
|
new-design/
|
||||||
|
claude-design/
|
||||||
|
homelab-hub-main/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
|||||||
|
|
||||||
- Homelab-friendly: run it in minutes with Docker.
|
- Homelab-friendly: run it in minutes with Docker.
|
||||||
- Practical: edit in the UI and autosave to `network.json`.
|
- Practical: edit in the UI and autosave to `network.json`.
|
||||||
- Declarative: keep your topology in Git if you want.
|
- Declarative: keep your topology in Git if you wish.
|
||||||
|
|
||||||
[Docker Hub](https://hub.docker.com/r/jcreek23/open-network-diagram) | [Live Demo (Read-Only)](https://opennetworkdiagram.jcreek.co.uk) | [GitHub Releases](https://github.com/jcreek/OpenNetworkDiagram/releases)
|
[Docker Hub](https://hub.docker.com/r/jcreek23/open-network-diagram) | [Live Demo (Read-Only)](https://opennetworkdiagram.jcreek.co.uk) | [GitHub Releases](https://github.com/jcreek/OpenNetworkDiagram/releases)
|
||||||
|
|
||||||
@@ -22,8 +22,13 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
|
|||||||
|
|
||||||
- Network view with ethernet labels to make physical and logical links easy to read.
|
- Network view with ethernet labels to make physical and logical links easy to read.
|
||||||
- Non-network view for host-first inventory and service mapping.
|
- Non-network view for host-first inventory and service mapping.
|
||||||
|
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
|
||||||
|
- Node search that highlights matches by name, IP, role or OS and dims everything else.
|
||||||
|
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
|
||||||
|
- Subnet and VLAN declarations, with nodes colour-coded by VLAN and a clickable legend filter.
|
||||||
|
- Cable tracking on connections (type, colour, length) with colour-coded links and hover details.
|
||||||
- Expandable VM lists per machine for quick virtualization visibility.
|
- 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.
|
- JSON-backed persistence with autosave in self-hosted mode.
|
||||||
- Docker-first deployment with writable data volume support.
|
- Docker-first deployment with writable data volume support.
|
||||||
- Optional read-only mode for public demos and safe sharing.
|
- 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
|
## Why Home Lab Users Use It
|
||||||
|
|
||||||
- Keep an always-up-to-date map of machines, VMs, and devices.
|
- 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.
|
- Edit quickly through a modal UI instead of hand-editing large diagrams.
|
||||||
- Persist everything to JSON so backups and Git workflows stay simple.
|
- Persist everything to JSON so backups and Git workflows stay simple.
|
||||||
- Stay fully self-hosted with no runtime dependency on external APIs.
|
- Stay fully self-hosted with no runtime dependency on external APIs.
|
||||||
@@ -74,10 +81,14 @@ docker rm open-network-diagram
|
|||||||
|
|
||||||
## What It Looks Like
|
## What It Looks Like
|
||||||
|
|
||||||
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
|
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
|
||||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------- |
|
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
|
| IPAM panel with subnet utilization | Rack view with shelf items |
|
||||||
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
## Docker Compose Option
|
## Docker Compose Option
|
||||||
|
|
||||||
If you prefer compose:
|
If you prefer compose:
|
||||||
@@ -128,6 +139,7 @@ pnpm run icons:manifest # regenerate local vendor icon manifest
|
|||||||
|
|
||||||
- API endpoint: `GET/PUT /api/network-data`
|
- API endpoint: `GET/PUT /api/network-data`
|
||||||
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
||||||
|
- When writes are unavailable, API responses include `writableReason` for diagnostics.
|
||||||
- Writes are persisted atomically to the configured data file
|
- Writes are persisted atomically to the configured data file
|
||||||
- Rolling backups are kept in the backup directory (last 5)
|
- Rolling backups are kept in the backup directory (last 5)
|
||||||
|
|
||||||
@@ -150,25 +162,73 @@ Environment variables:
|
|||||||
"ipAddress": "10.0.0.3",
|
"ipAddress": "10.0.0.3",
|
||||||
"role": "Hypervisor",
|
"role": "Hypervisor",
|
||||||
"operatingSystem": "Proxmox",
|
"operatingSystem": "Proxmox",
|
||||||
|
"notes": "Primary router box.",
|
||||||
"software": {
|
"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": {
|
"hardware": {
|
||||||
"cpu": "Intel N100",
|
"cpu": "Intel N100",
|
||||||
"ram": "8GB",
|
"ram": "8GB",
|
||||||
"networkPorts": 4
|
"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
|
### Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
OpenNetworkDiagram/
|
OpenNetworkDiagram/
|
||||||
├── src/ # Svelte app source
|
├── src/ # Svelte app source
|
||||||
|
├── src/lib/shared/ # Schema + persistence core (shared with server.mjs)
|
||||||
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
|
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
|
||||||
├── static/data/network.json # Demo dataset (Netlify)
|
├── static/data/network.json # Demo dataset (Netlify)
|
||||||
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
|
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
"ipAddress": "192.168.1.10",
|
"ipAddress": "192.168.1.10",
|
||||||
"role": "Hypervisor",
|
"role": "Hypervisor",
|
||||||
"operatingSystem": "Linux",
|
"operatingSystem": "Linux",
|
||||||
|
"notes": "Optional notes",
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
|
"macAddress": "aa:bb:cc:dd:ee:01",
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Example Switch",
|
"device": "Example Switch",
|
||||||
"port": "1"
|
"port": "1"
|
||||||
@@ -20,7 +22,8 @@
|
|||||||
{
|
{
|
||||||
"name": "Example VM",
|
"name": "Example VM",
|
||||||
"role": "Service",
|
"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",
|
"ipAddress": "192.168.1.2",
|
||||||
"type": "Network Switch",
|
"type": "Network Switch",
|
||||||
"notes": "Optional notes",
|
"notes": "Optional notes",
|
||||||
|
"rack": {
|
||||||
|
"name": "Example Rack",
|
||||||
|
"unit": 12
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "1",
|
"portName": "1",
|
||||||
@@ -50,5 +57,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"subnets": [
|
||||||
|
{
|
||||||
|
"cidr": "192.168.1.0/24",
|
||||||
|
"name": "LAN",
|
||||||
|
"vlanId": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"racks": [
|
||||||
|
{
|
||||||
|
"name": "Example Rack",
|
||||||
|
"heightU": 24
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: open-network-diagram:local
|
image: open-network-diagram:local
|
||||||
ports:
|
ports:
|
||||||
- "8080:3000"
|
- '8080:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
NETWORK_DATA_FILE: "/app/data/network.json"
|
NETWORK_DATA_FILE: '/app/data/network.json'
|
||||||
NETWORK_BACKUP_DIR: "/app/data/.backups"
|
NETWORK_BACKUP_DIR: '/app/data/.backups'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cytoscape": "^3.31.2",
|
"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:
|
cytoscape-dagre:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0(cytoscape@3.33.1)
|
version: 2.5.0(cytoscape@3.33.1)
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.2.5
|
specifier: ^1.2.5
|
||||||
@@ -76,11 +79,11 @@ importers:
|
|||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
specifier: ^3.3.3
|
specifier: ^4.1.1
|
||||||
version: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
version: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||||
prettier-plugin-tailwindcss:
|
prettier-plugin-tailwindcss:
|
||||||
specifier: ^0.6.11
|
specifier: ^0.8.0
|
||||||
version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
version: 0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.25.8
|
version: 5.25.8
|
||||||
@@ -1447,6 +1450,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -1881,17 +1887,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
prettier-plugin-svelte@3.3.3:
|
prettier-plugin-svelte@4.1.1:
|
||||||
resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==}
|
resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prettier: ^3.0.0
|
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:
|
prettier-plugin-tailwindcss@0.8.0:
|
||||||
resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
|
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=20.19'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||||
|
'@prettier/plugin-hermes': '*'
|
||||||
|
'@prettier/plugin-oxc': '*'
|
||||||
'@prettier/plugin-pug': '*'
|
'@prettier/plugin-pug': '*'
|
||||||
'@shopify/prettier-plugin-liquid': '*'
|
'@shopify/prettier-plugin-liquid': '*'
|
||||||
'@trivago/prettier-plugin-sort-imports': '*'
|
'@trivago/prettier-plugin-sort-imports': '*'
|
||||||
@@ -1899,18 +1908,20 @@ packages:
|
|||||||
prettier: ^3.0
|
prettier: ^3.0
|
||||||
prettier-plugin-astro: '*'
|
prettier-plugin-astro: '*'
|
||||||
prettier-plugin-css-order: '*'
|
prettier-plugin-css-order: '*'
|
||||||
prettier-plugin-import-sort: '*'
|
|
||||||
prettier-plugin-jsdoc: '*'
|
prettier-plugin-jsdoc: '*'
|
||||||
prettier-plugin-marko: '*'
|
prettier-plugin-marko: '*'
|
||||||
prettier-plugin-multiline-arrays: '*'
|
prettier-plugin-multiline-arrays: '*'
|
||||||
prettier-plugin-organize-attributes: '*'
|
prettier-plugin-organize-attributes: '*'
|
||||||
prettier-plugin-organize-imports: '*'
|
prettier-plugin-organize-imports: '*'
|
||||||
prettier-plugin-sort-imports: '*'
|
prettier-plugin-sort-imports: '*'
|
||||||
prettier-plugin-style-order: '*'
|
|
||||||
prettier-plugin-svelte: '*'
|
prettier-plugin-svelte: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@ianvs/prettier-plugin-sort-imports':
|
'@ianvs/prettier-plugin-sort-imports':
|
||||||
optional: true
|
optional: true
|
||||||
|
'@prettier/plugin-hermes':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-oxc':
|
||||||
|
optional: true
|
||||||
'@prettier/plugin-pug':
|
'@prettier/plugin-pug':
|
||||||
optional: true
|
optional: true
|
||||||
'@shopify/prettier-plugin-liquid':
|
'@shopify/prettier-plugin-liquid':
|
||||||
@@ -1923,8 +1934,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-css-order:
|
prettier-plugin-css-order:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-import-sort:
|
|
||||||
optional: true
|
|
||||||
prettier-plugin-jsdoc:
|
prettier-plugin-jsdoc:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-marko:
|
prettier-plugin-marko:
|
||||||
@@ -1937,8 +1946,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-sort-imports:
|
prettier-plugin-sort-imports:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-style-order:
|
|
||||||
optional: true
|
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3631,6 +3638,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -4022,16 +4031,16 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
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:
|
dependencies:
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
svelte: 5.25.8
|
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:
|
dependencies:
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
optionalDependencies:
|
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: {}
|
prettier@3.8.1: {}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -12,181 +12,186 @@ const prettierIgnorePath = path.join(projectRoot, '.prettierignore');
|
|||||||
|
|
||||||
const allowedExtensions = new Set(['.svg', '.png', '.webp']);
|
const allowedExtensions = new Set(['.svg', '.png', '.webp']);
|
||||||
const extensionPriority = {
|
const extensionPriority = {
|
||||||
'.svg': 0,
|
'.svg': 0,
|
||||||
'.png': 1,
|
'.png': 1,
|
||||||
'.webp': 2
|
'.webp': 2
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @param {string} input */
|
/** @param {string} input */
|
||||||
function toPosixPath(input) {
|
function toPosixPath(input) {
|
||||||
return input.split(path.sep).join('/');
|
return input.split(path.sep).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} filename */
|
/** @param {string} filename */
|
||||||
function toSlug(filename) {
|
function toSlug(filename) {
|
||||||
return filename
|
return filename
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9._-]/g, '-')
|
.replace(/[^a-z0-9._-]/g, '-')
|
||||||
.replace(/[._]+/g, '-')
|
.replace(/[._]+/g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} slug */
|
/** @param {string} slug */
|
||||||
function toLabel(slug) {
|
function toLabel(slug) {
|
||||||
return slug
|
return slug
|
||||||
.split('-')
|
.split('-')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} dir */
|
/** @param {string} dir */
|
||||||
async function walk(dir) {
|
async function walk(dir) {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
const files = entries
|
const files = entries
|
||||||
.filter((entry) => entry.isFile())
|
.filter((entry) => entry.isFile())
|
||||||
.map((entry) => path.join(dir, entry.name));
|
.map((entry) => path.join(dir, entry.name));
|
||||||
const directories = entries
|
const directories = entries
|
||||||
.filter((entry) => entry.isDirectory())
|
.filter((entry) => entry.isDirectory())
|
||||||
.map((entry) => path.join(dir, entry.name));
|
.map((entry) => path.join(dir, entry.name));
|
||||||
const nestedFileLists = await Promise.all(directories.map((childDirectory) => walk(childDirectory)));
|
const nestedFileLists = await Promise.all(
|
||||||
|
directories.map((childDirectory) => walk(childDirectory))
|
||||||
|
);
|
||||||
|
|
||||||
for (const nestedFiles of nestedFileLists) {
|
for (const nestedFiles of nestedFileLists) {
|
||||||
files.push(...nestedFiles);
|
files.push(...nestedFiles);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareIconRecords(a, b) {
|
function compareIconRecords(a, b) {
|
||||||
const labelCompare = a.label.localeCompare(b.label);
|
const labelCompare = a.label.localeCompare(b.label);
|
||||||
if (labelCompare !== 0) {
|
if (labelCompare !== 0) {
|
||||||
return labelCompare;
|
return labelCompare;
|
||||||
}
|
}
|
||||||
return a.key.localeCompare(b.key);
|
return a.key.localeCompare(b.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} filePath @param {string} entry */
|
/** @param {string} filePath @param {string} entry */
|
||||||
async function ensureIgnoreEntry(filePath, entry) {
|
async function ensureIgnoreEntry(filePath, entry) {
|
||||||
const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
|
const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
|
||||||
let contents = '';
|
let contents = '';
|
||||||
try {
|
try {
|
||||||
contents = await fs.readFile(filePath, 'utf8');
|
contents = await fs.readFile(filePath, 'utf8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
||||||
const existingEntries = contents
|
const existingEntries = contents
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line && !line.startsWith('#'))
|
.filter((line) => line && !line.startsWith('#'))
|
||||||
.map((line) => toPosixPath(line).replace(/^\.?\//, ''));
|
.map((line) => toPosixPath(line).replace(/^\.?\//, ''));
|
||||||
if (existingEntries.includes(normalizedEntry)) {
|
if (existingEntries.includes(normalizedEntry)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
const suffix =
|
||||||
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
|
contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
||||||
await fs.writeFile(filePath, nextContents, 'utf8');
|
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
|
||||||
|
await fs.writeFile(filePath, nextContents, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const vendorExists = await fs
|
const vendorExists = await fs
|
||||||
.access(vendorRoot)
|
.access(vendorRoot)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
if (!vendorExists) {
|
if (!vendorExists) {
|
||||||
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
|
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allFiles = await walk(vendorRoot);
|
const allFiles = await walk(vendorRoot);
|
||||||
const dedupedBySlug = new Map();
|
const dedupedBySlug = new Map();
|
||||||
|
|
||||||
for (const absoluteFilePath of allFiles) {
|
for (const absoluteFilePath of allFiles) {
|
||||||
const extension = path.extname(absoluteFilePath).toLowerCase();
|
const extension = path.extname(absoluteFilePath).toLowerCase();
|
||||||
if (!allowedExtensions.has(extension)) {
|
if (!allowedExtensions.has(extension)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
|
const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
|
||||||
const basename = path.basename(relativePath, extension);
|
const basename = path.basename(relativePath, extension);
|
||||||
const slug = toSlug(basename);
|
const slug = toSlug(basename);
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `homarr:${slug}`;
|
const key = `homarr:${slug}`;
|
||||||
const candidate = {
|
const candidate = {
|
||||||
key,
|
key,
|
||||||
label: toLabel(slug),
|
label: toLabel(slug),
|
||||||
path: `/icons/vendor/homarr/${relativePath}`,
|
path: `/icons/vendor/homarr/${relativePath}`,
|
||||||
source: 'homarr-dashboard-icons',
|
source: 'homarr-dashboard-icons',
|
||||||
extension,
|
extension,
|
||||||
priority: extensionPriority[extension] ?? 99,
|
priority: extensionPriority[extension] ?? 99,
|
||||||
relativePath
|
relativePath
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = dedupedBySlug.get(slug);
|
const existing = dedupedBySlug.get(slug);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
dedupedBySlug.set(slug, candidate);
|
dedupedBySlug.set(slug, candidate);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidate.priority < existing.priority) {
|
if (candidate.priority < existing.priority) {
|
||||||
dedupedBySlug.set(slug, candidate);
|
dedupedBySlug.set(slug, candidate);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) {
|
if (
|
||||||
dedupedBySlug.set(slug, candidate);
|
candidate.priority === existing.priority &&
|
||||||
}
|
candidate.relativePath < existing.relativePath
|
||||||
}
|
) {
|
||||||
|
dedupedBySlug.set(slug, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const records = Array.from(dedupedBySlug.values())
|
const records = Array.from(dedupedBySlug.values())
|
||||||
.sort(compareIconRecords)
|
.sort(compareIconRecords)
|
||||||
.map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
|
.map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
|
||||||
|
|
||||||
const header = [
|
const header = [
|
||||||
'/* eslint-disable */',
|
'/* eslint-disable */',
|
||||||
'// prettier-ignore',
|
'// prettier-ignore',
|
||||||
'// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
|
'// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
|
||||||
'// Do not edit manually.',
|
'// Do not edit manually.',
|
||||||
'',
|
'',
|
||||||
'export interface VendorIconDefinition {',
|
'export interface VendorIconDefinition {',
|
||||||
' key: string;',
|
' key: string;',
|
||||||
' label: string;',
|
' label: string;',
|
||||||
' path: string;',
|
' path: string;',
|
||||||
" source: 'homarr-dashboard-icons';",
|
" source: 'homarr-dashboard-icons';",
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
|
'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const body = records
|
const body = records
|
||||||
.map((record) => {
|
.map((record) => {
|
||||||
const key = JSON.stringify(record.key);
|
const key = JSON.stringify(record.key);
|
||||||
const label = JSON.stringify(record.label);
|
const label = JSON.stringify(record.label);
|
||||||
const iconPath = JSON.stringify(record.path);
|
const iconPath = JSON.stringify(record.path);
|
||||||
return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
|
return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const footer = ['];', ''].join('\n');
|
const footer = ['];', ''].join('\n');
|
||||||
const output = `${header}\n${body}\n${footer}`;
|
const output = `${header}\n${body}\n${footer}`;
|
||||||
const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
|
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');
|
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
|
||||||
|
await fs.writeFile(outputPath, output, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
||||||
import {
|
import {
|
||||||
checkWritableState,
|
getWritableState,
|
||||||
isWriteEnabled,
|
|
||||||
readNetworkFile,
|
readNetworkFile,
|
||||||
writeNetworkFile
|
writeNetworkFile
|
||||||
} from './src/lib/shared/networkPersistenceCore.mjs';
|
} from './src/lib/shared/networkPersistenceCore.mjs';
|
||||||
@@ -50,10 +49,17 @@ function badRequest(response, message, details) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyErrorMessage(reason) {
|
||||||
|
if (!reason) {
|
||||||
|
return 'Write API unavailable in current deployment.';
|
||||||
|
}
|
||||||
|
return `Write API unavailable: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function serveApi(request, response) {
|
async function serveApi(request, response) {
|
||||||
if (request.method === 'GET') {
|
if (request.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const payload = await readNetworkFile();
|
const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]);
|
||||||
const validation = validateNetworkData(payload.data);
|
const validation = validateNetworkData(payload.data);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
sendJson(response, 500, {
|
sendJson(response, 500, {
|
||||||
@@ -63,11 +69,10 @@ async function serveApi(request, response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canWrite = await checkWritableState();
|
|
||||||
|
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable: canWrite,
|
writable: writableState.writable,
|
||||||
|
writableReason: writableState.reason,
|
||||||
source: payload.source,
|
source: payload.source,
|
||||||
updatedAt: payload.updatedAt
|
updatedAt: payload.updatedAt
|
||||||
});
|
});
|
||||||
@@ -80,9 +85,12 @@ async function serveApi(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === 'PUT') {
|
if (request.method === 'PUT') {
|
||||||
if (!isWriteEnabled()) {
|
const writableStateBeforeWrite = await getWritableState();
|
||||||
|
if (!writableStateBeforeWrite.writable) {
|
||||||
sendJson(response, 403, {
|
sendJson(response, 403, {
|
||||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableStateBeforeWrite.reason
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,9 +131,11 @@ async function serveApi(request, response) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await writeNetworkFile(validation.data);
|
const metadata = await writeNetworkFile(validation.data);
|
||||||
|
const writableStateAfterWrite = await getWritableState();
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable: true,
|
writable: writableStateAfterWrite.writable,
|
||||||
|
writableReason: writableStateAfterWrite.reason,
|
||||||
source: metadata.source,
|
source: metadata.source,
|
||||||
updatedAt: metadata.updatedAt
|
updatedAt: metadata.updatedAt
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,99 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
/* Design tokens from the UX review (new-design/project/UX Review.dc.html, panel 1b).
|
||||||
|
Principle: calm chrome, loud data. */
|
||||||
:root {
|
:root {
|
||||||
--app-bg: #e2e8f0;
|
/* Core colours */
|
||||||
--panel-bg: #f8fafc;
|
--bg-canvas: #eef0f3;
|
||||||
--panel-border: #cbd5e1;
|
--surface: #ffffff;
|
||||||
--panel-contrast: #0f172a;
|
--surface-2: #f2f4f7;
|
||||||
--muted-text: #334155;
|
--border: #dce0e6;
|
||||||
--chip-bg: #e2e8f0;
|
--text: #1b212b;
|
||||||
--chip-text: #0f172a;
|
--text-2: #616a78;
|
||||||
--modal-overlay: rgba(15, 23, 42, 0.58);
|
--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'] {
|
:root[data-theme='dark'] {
|
||||||
--app-bg: #0b1220;
|
--bg-canvas: #14171c;
|
||||||
--panel-bg: #111827;
|
--surface: #1d222a;
|
||||||
--panel-border: #334155;
|
--surface-2: #262d38;
|
||||||
--panel-contrast: #e2e8f0;
|
--border: #363f4d;
|
||||||
--muted-text: #94a3b8;
|
--text: #e7ebf2;
|
||||||
--chip-bg: #1e293b;
|
--text-2: #9aa4b2;
|
||||||
--chip-text: #e2e8f0;
|
--accent: #7da5e8;
|
||||||
--modal-overlay: rgba(2, 6, 23, 0.78);
|
--danger: #e0716c;
|
||||||
|
|
||||||
|
--graph-edge: #5b6575;
|
||||||
|
--graph-node-border: #363f4d;
|
||||||
|
--graph-infra-fill: #2c3440;
|
||||||
|
--graph-infra-border: #414c5d;
|
||||||
|
--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,
|
html,
|
||||||
body {
|
body {
|
||||||
background: var(--app-bg);
|
background: var(--app-bg);
|
||||||
color: var(--panel-contrast);
|
color: var(--panel-contrast);
|
||||||
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let isSampleData = false;
|
||||||
|
export let writable = false;
|
||||||
|
export let hasMachines = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
addmachine: void;
|
||||||
|
connectport: void;
|
||||||
|
openipam: void;
|
||||||
|
dismiss: void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="first-run">
|
||||||
|
<h2>Map your network</h2>
|
||||||
|
{#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-backdrop" on:click={onBackdropClick}>
|
||||||
<div class="modal-container" style={`max-width: ${maxWidth}`}>
|
<div class="modal-container" style={`max-width: ${maxWidth}`}>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
{#if title}
|
<slot name="header">
|
||||||
<h2>{title}</h2>
|
{#if title}
|
||||||
{/if}
|
<h2>{title}</h2>
|
||||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
|
{/if}
|
||||||
|
</slot>
|
||||||
|
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
|
||||||
|
>×</button
|
||||||
|
>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
{#if $$slots.footer}
|
||||||
|
<footer class="modal-footer-bar">
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -61,49 +70,64 @@
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--panel-bg);
|
background: var(--surface);
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-modal);
|
||||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
|
box-shadow: 0 24px 64px rgb(15 18 24 / 0.35);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 14px;
|
||||||
gap: 0.5rem;
|
padding: 16px 20px;
|
||||||
padding: 0.9rem 1rem;
|
border-bottom: 1px solid var(--border);
|
||||||
border-bottom: 1px solid var(--panel-border);
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.05rem;
|
flex: 1;
|
||||||
color: var(--panel-contrast);
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted-text);
|
color: var(--text-2);
|
||||||
font-size: 1.7rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
width: 2rem;
|
width: 30px;
|
||||||
height: 2rem;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-control);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
.close-button:hover {
|
||||||
background: var(--chip-bg);
|
background: var(--surface-2);
|
||||||
color: var(--panel-contrast);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 1rem;
|
padding: 20px;
|
||||||
overflow: auto;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
padding: 0;
|
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';
|
export type OwnerKind = 'machine' | 'device';
|
||||||
|
|
||||||
@@ -61,7 +61,11 @@ export function cloneNetworkData(data: NetworkData): NetworkData {
|
|||||||
return cloned;
|
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') {
|
if (kind === 'machine') {
|
||||||
const machine = data.machines.find((item) => equalsIgnoreCase(item.machineName, name));
|
const machine = data.machines.find((item) => equalsIgnoreCase(item.machineName, name));
|
||||||
if (!machine) {
|
if (!machine) {
|
||||||
@@ -113,32 +117,49 @@ function ensureReciprocalConnections(data: NetworkData) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPort = findPort(targetOwner, port.connectedTo.port);
|
const targetPort = findPort(targetOwner, port.connectedTo.port);
|
||||||
if (!targetPort) {
|
if (!targetPort) {
|
||||||
delete port.connectedTo;
|
delete port.connectedTo;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedReciprocal = {
|
const expectedReciprocal: Port['connectedTo'] = {
|
||||||
device: owner.name,
|
device: owner.name,
|
||||||
port: port.portName
|
port: port.portName,
|
||||||
};
|
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
|
||||||
if (!targetPort.connectedTo) {
|
};
|
||||||
targetPort.connectedTo = expectedReciprocal;
|
if (!targetPort.connectedTo) {
|
||||||
continue;
|
targetPort.connectedTo = expectedReciprocal;
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const hasMatchingReciprocal =
|
const hasMatchingReciprocal =
|
||||||
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
|
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
|
||||||
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
|
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
|
||||||
if (hasMatchingReciprocal) {
|
if (hasMatchingReciprocal) {
|
||||||
continue;
|
// 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 owner of allOwners(data)) {
|
||||||
for (const port of owner.ports) {
|
for (const port of owner.ports) {
|
||||||
if (!port.connectedTo) {
|
if (!port.connectedTo) {
|
||||||
@@ -212,7 +233,9 @@ export function renameOwner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'machine') {
|
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) {
|
if (machine) {
|
||||||
machine.machineName = nextName;
|
machine.machineName = nextName;
|
||||||
}
|
}
|
||||||
@@ -278,14 +301,15 @@ export function deleteOwner(data: NetworkData, kind: OwnerKind, ownerName: strin
|
|||||||
const cloned = cloneNetworkData(data);
|
const cloned = cloneNetworkData(data);
|
||||||
|
|
||||||
if (kind === 'machine') {
|
if (kind === 'machine') {
|
||||||
cloned.machines = cloned.machines.filter((machine) => !equalsIgnoreCase(machine.machineName, ownerName));
|
cloned.machines = cloned.machines.filter(
|
||||||
|
(machine) => !equalsIgnoreCase(machine.machineName, ownerName)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
cloned.devices = cloned.devices.filter((device) => !equalsIgnoreCase(device.name, ownerName));
|
cloned.devices = cloned.devices.filter((device) => !equalsIgnoreCase(device.name, ownerName));
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInboundReferences(
|
clearInboundReferences(cloned, (connection) =>
|
||||||
cloned,
|
Boolean(connection && equalsIgnoreCase(connection.device, ownerName))
|
||||||
(connection) => Boolean(connection && equalsIgnoreCase(connection.device, ownerName))
|
|
||||||
);
|
);
|
||||||
ensureReciprocalConnections(cloned);
|
ensureReciprocalConnections(cloned);
|
||||||
return cloned;
|
return cloned;
|
||||||
@@ -317,19 +341,94 @@ export function deletePort(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInboundReferences(
|
clearInboundReferences(cloned, (connection) =>
|
||||||
cloned,
|
Boolean(
|
||||||
(connection) =>
|
connection &&
|
||||||
Boolean(
|
equalsIgnoreCase(connection.device, ownerName) &&
|
||||||
connection &&
|
equalsIgnoreCase(connection.port, portName)
|
||||||
equalsIgnoreCase(connection.device, ownerName) &&
|
)
|
||||||
equalsIgnoreCase(connection.port, portName)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
ensureReciprocalConnections(cloned);
|
ensureReciprocalConnections(cloned);
|
||||||
return 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(
|
export function setPortConnection(
|
||||||
data: NetworkData,
|
data: NetworkData,
|
||||||
kind: OwnerKind,
|
kind: OwnerKind,
|
||||||
@@ -350,7 +449,9 @@ export function setPortConnection(
|
|||||||
const previousConnection = port.connectedTo;
|
const previousConnection = port.connectedTo;
|
||||||
if (previousConnection) {
|
if (previousConnection) {
|
||||||
const previousOwner = findOwnerByName(cloned, previousConnection.device);
|
const previousOwner = findOwnerByName(cloned, previousConnection.device);
|
||||||
const previousPort = previousOwner ? findPort(previousOwner, previousConnection.port) : undefined;
|
const previousPort = previousOwner
|
||||||
|
? findPort(previousOwner, previousConnection.port)
|
||||||
|
: undefined;
|
||||||
if (previousPort) {
|
if (previousPort) {
|
||||||
delete previousPort.connectedTo;
|
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 { 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 {
|
interface EndpointOwner {
|
||||||
kind: 'machine' | 'device';
|
kind: 'machine' | 'device';
|
||||||
@@ -55,6 +64,55 @@ function connectionKey(a: string, b: string): string {
|
|||||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
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 {
|
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
|
||||||
if (typeof a === 'number' && typeof b === 'number') {
|
if (typeof a === 'number' && typeof b === 'number') {
|
||||||
return Math.min(a, b);
|
return Math.min(a, b);
|
||||||
@@ -82,6 +140,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
> = {};
|
> = {};
|
||||||
const ownersByLookup = new Map<string, EndpointOwner>();
|
const ownersByLookup = new Map<string, EndpointOwner>();
|
||||||
const seenWarnings = new Set<string>();
|
const seenWarnings = new Set<string>();
|
||||||
|
const resolveVlan = buildVlanResolver(data.subnets);
|
||||||
|
|
||||||
const warn = (message: string) => {
|
const warn = (message: string) => {
|
||||||
if (!seenWarnings.has(message)) {
|
if (!seenWarnings.has(message)) {
|
||||||
@@ -110,19 +169,23 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
const machineVms = sourceMachineVms.map((vm) => ({
|
const machineVms = sourceMachineVms.map((vm) => ({
|
||||||
name: vm.name,
|
name: vm.name,
|
||||||
ip: vm.ipAddress,
|
ip: vm.ipAddress,
|
||||||
role: vm.role
|
role: vm.role,
|
||||||
|
macAddress: vm.macAddress
|
||||||
}));
|
}));
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
data: {
|
data: {
|
||||||
id: machineNodeId,
|
id: machineNodeId,
|
||||||
label: machine.machineName,
|
label: machine.machineName,
|
||||||
rawName: machine.machineName,
|
rawName: machine.machineName,
|
||||||
kind: 'machine',
|
kind: 'machine',
|
||||||
iconKey: machine.iconKey,
|
iconKey: machine.iconKey,
|
||||||
iconUrl: resolveIconPath(machine.iconKey),
|
iconUrl: resolveIconPath(machine.iconKey),
|
||||||
nodeWidth: 200,
|
nodeWidth: NODE_DIMENSIONS.machine.width,
|
||||||
nodeHeight: 110,
|
nodeHeight: NODE_DIMENSIONS.machine.height,
|
||||||
|
meta: machine.ipAddress,
|
||||||
|
vmCount: sourceMachineVms.length,
|
||||||
|
...resolveVlan(machine.ipAddress),
|
||||||
details: {
|
details: {
|
||||||
type: 'machine',
|
type: 'machine',
|
||||||
name: machine.machineName,
|
name: machine.machineName,
|
||||||
@@ -133,6 +196,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
cpu: machine.hardware?.cpu ?? 'Unknown',
|
cpu: machine.hardware?.cpu ?? 'Unknown',
|
||||||
ram: machine.hardware?.ram ?? 'Unknown',
|
ram: machine.hardware?.ram ?? 'Unknown',
|
||||||
gpu: machine.hardware?.gpu ?? undefined,
|
gpu: machine.hardware?.gpu ?? undefined,
|
||||||
|
notes: machine.notes,
|
||||||
ports: machinePortsList,
|
ports: machinePortsList,
|
||||||
vmCount: machineVms.length,
|
vmCount: machineVms.length,
|
||||||
vms: machineVms
|
vms: machineVms
|
||||||
@@ -158,14 +222,17 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
hostMachineId: machineNodeId,
|
hostMachineId: machineNodeId,
|
||||||
iconKey: vm.iconKey,
|
iconKey: vm.iconKey,
|
||||||
iconUrl: resolveIconPath(vm.iconKey),
|
iconUrl: resolveIconPath(vm.iconKey),
|
||||||
nodeWidth: 140,
|
nodeWidth: NODE_DIMENSIONS.vm.width,
|
||||||
nodeHeight: 66,
|
nodeHeight: NODE_DIMENSIONS.vm.height,
|
||||||
|
meta: vm.ipAddress,
|
||||||
|
...resolveVlan(vm.ipAddress),
|
||||||
details: {
|
details: {
|
||||||
type: 'vm',
|
type: 'vm',
|
||||||
name: vm.name,
|
name: vm.name,
|
||||||
iconKey: vm.iconKey,
|
iconKey: vm.iconKey,
|
||||||
ip: vm.ipAddress,
|
ip: vm.ipAddress,
|
||||||
role: vm.role,
|
role: vm.role,
|
||||||
|
macAddress: vm.macAddress,
|
||||||
hostName: machine.machineName
|
hostName: machine.machineName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,8 +245,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
id: hostingEdgeId,
|
id: hostingEdgeId,
|
||||||
source: machineNodeId,
|
source: machineNodeId,
|
||||||
target: vmNodeId,
|
target: vmNodeId,
|
||||||
kind: 'hosting',
|
kind: 'hosting'
|
||||||
label: 'hosts'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,6 +269,15 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
for (const device of data.devices) {
|
for (const device of data.devices) {
|
||||||
const deviceNodeId = toDeviceNodeId(device.name);
|
const deviceNodeId = toDeviceNodeId(device.name);
|
||||||
const devicePorts = [...(device.ports ?? [])];
|
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({
|
nodes.push({
|
||||||
data: {
|
data: {
|
||||||
@@ -210,10 +285,19 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
label: device.name,
|
label: device.name,
|
||||||
rawName: device.name,
|
rawName: device.name,
|
||||||
kind: 'device',
|
kind: 'device',
|
||||||
|
deviceClass,
|
||||||
iconKey: device.iconKey,
|
iconKey: device.iconKey,
|
||||||
iconUrl: resolveIconPath(device.iconKey),
|
iconUrl: resolveIconPath(device.iconKey),
|
||||||
nodeWidth: 176,
|
nodeWidth:
|
||||||
nodeHeight: 116,
|
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: {
|
details: {
|
||||||
type: 'device',
|
type: 'device',
|
||||||
name: device.name,
|
name: device.name,
|
||||||
@@ -293,6 +377,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
|
|
||||||
physicalEdgeCounter += 1;
|
physicalEdgeCounter += 1;
|
||||||
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
|
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({
|
edges.push({
|
||||||
data: {
|
data: {
|
||||||
@@ -300,11 +396,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
source: owner.nodeId,
|
source: owner.nodeId,
|
||||||
target: targetOwner.nodeId,
|
target: targetOwner.nodeId,
|
||||||
kind: 'physical',
|
kind: 'physical',
|
||||||
label: speedGbps ? `${speedGbps}GbE` : undefined,
|
chipLabel,
|
||||||
|
chipLabelNoSpeed,
|
||||||
sourcePort: port.portName,
|
sourcePort: port.portName,
|
||||||
targetPort: connectedTo.port,
|
targetPort: connectedTo.port,
|
||||||
speedGbps,
|
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 GraphNodeKind = 'machine' | 'device' | 'vm';
|
||||||
export type GraphEdgeKind = 'physical' | 'hosting';
|
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 {
|
export interface PortDetails {
|
||||||
portName: string;
|
portName: string;
|
||||||
speedGbps?: number;
|
speedGbps?: number;
|
||||||
|
macAddress?: string;
|
||||||
connectedTo?: {
|
connectedTo?: {
|
||||||
device: string;
|
device: string;
|
||||||
port: string;
|
port: string;
|
||||||
|
cable?: {
|
||||||
|
type?: string;
|
||||||
|
color?: string;
|
||||||
|
lengthM?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +56,14 @@ export interface MachineDetails {
|
|||||||
cpu: string;
|
cpu: string;
|
||||||
ram: string;
|
ram: string;
|
||||||
gpu?: string;
|
gpu?: string;
|
||||||
|
notes?: string;
|
||||||
ports: PortDetails[];
|
ports: PortDetails[];
|
||||||
vmCount: number;
|
vmCount: number;
|
||||||
vms: Array<{
|
vms: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
macAddress?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +83,7 @@ export interface VmDetails {
|
|||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
macAddress?: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +93,17 @@ export interface GraphNodeData {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
kind: GraphNodeKind;
|
kind: GraphNodeKind;
|
||||||
|
deviceClass?: DeviceClass;
|
||||||
rawName?: string;
|
rawName?: string;
|
||||||
hostMachineId?: string;
|
hostMachineId?: string;
|
||||||
nodeWidth?: number;
|
nodeWidth?: number;
|
||||||
nodeHeight?: number;
|
nodeHeight?: number;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
|
vlanId?: number;
|
||||||
|
vlanIndex?: number;
|
||||||
|
meta?: string;
|
||||||
|
vmCount?: number;
|
||||||
details?: GraphNodeDetails;
|
details?: GraphNodeDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +113,16 @@ export interface GraphEdgeData {
|
|||||||
target: string;
|
target: string;
|
||||||
kind: GraphEdgeKind;
|
kind: GraphEdgeKind;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
chipLabel?: string;
|
||||||
|
chipLabelNoSpeed?: string;
|
||||||
sourcePort?: string;
|
sourcePort?: string;
|
||||||
targetPort?: string;
|
targetPort?: string;
|
||||||
speedGbps?: number;
|
speedGbps?: number;
|
||||||
reciprocal?: boolean;
|
reciprocal?: boolean;
|
||||||
|
cableType?: string;
|
||||||
|
cableColorName?: string;
|
||||||
|
cableColor?: string;
|
||||||
|
cableLengthM?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphNodeElement {
|
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];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
checkWritableState as checkWritableStateCore,
|
checkWritableState as checkWritableStateCore,
|
||||||
|
getWritableState as getWritableStateCore,
|
||||||
isWriteEnabled as isWriteEnabledCore,
|
isWriteEnabled as isWriteEnabledCore,
|
||||||
readNetworkFile as readNetworkFileCore,
|
readNetworkFile as readNetworkFileCore,
|
||||||
writeNetworkFile as writeNetworkFileCore
|
writeNetworkFile as writeNetworkFileCore
|
||||||
@@ -12,6 +13,11 @@ export interface NetworkFileMetadata {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WritableState {
|
||||||
|
writable: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isWriteEnabled(): boolean {
|
export function isWriteEnabled(): boolean {
|
||||||
return isWriteEnabledCore();
|
return isWriteEnabledCore();
|
||||||
}
|
}
|
||||||
@@ -24,6 +30,10 @@ export async function writeNetworkFile(data: NetworkData): Promise<NetworkFileMe
|
|||||||
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWritableState(): Promise<WritableState> {
|
||||||
|
return (await getWritableStateCore()) as WritableState;
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkWritableState(): Promise<boolean> {
|
export async function checkWritableState(): Promise<boolean> {
|
||||||
return checkWritableStateCore();
|
return checkWritableStateCore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import path from 'node:path';
|
|||||||
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
||||||
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
||||||
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
||||||
|
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||||
@@ -43,6 +44,38 @@ export function isWriteEnabled() {
|
|||||||
return !NETWORK_READ_ONLY;
|
return !NETWORK_READ_ONLY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} error
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatFileSystemErrorCode(error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
return 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} directory
|
||||||
|
* @param {string} label
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
async function assertDirectoryWritable(directory, label) {
|
||||||
|
try {
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
return `${label} directory "${directory}" could not be created (${formatFileSystemErrorCode(error)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(directory, constants.W_OK);
|
||||||
|
} catch (error) {
|
||||||
|
return `${label} directory "${directory}" is not writable (${formatFileSystemErrorCode(error)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} dataFilePath
|
* @param {string} dataFilePath
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<string>}
|
||||||
@@ -151,20 +184,63 @@ export async function writeNetworkFile(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<WritableState>}
|
||||||
*/
|
*/
|
||||||
export async function checkWritableState() {
|
export async function getWritableState() {
|
||||||
if (!isWriteEnabled()) {
|
if (!isWriteEnabled()) {
|
||||||
return false;
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: 'Writes disabled by NETWORK_READ_ONLY=true.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = resolveDataFilePath();
|
const source = resolveDataFilePath();
|
||||||
const directory = path.dirname(source);
|
const directory = path.dirname(source);
|
||||||
try {
|
const dataDirectoryError = await assertDirectoryWritable(directory, 'Data');
|
||||||
await mkdir(directory, { recursive: true });
|
if (dataDirectoryError) {
|
||||||
await access(directory, constants.W_OK);
|
return {
|
||||||
} catch {
|
writable: false,
|
||||||
return false;
|
reason: dataDirectoryError
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
const backupDirectory = resolveBackupDirectory();
|
||||||
|
const backupDirectoryError = await assertDirectoryWritable(backupDirectory, 'Backup');
|
||||||
|
if (backupDirectoryError) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: backupDirectoryError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(source, constants.F_OK);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
writable: true,
|
||||||
|
reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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 {
|
||||||
|
writable: false,
|
||||||
|
reason: `Data file "${source}" exists but is not readable and writable (${formatFileSystemErrorCode(error)}).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
writable: true,
|
||||||
|
reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function checkWritableState() {
|
||||||
|
return (await getWritableState()).writable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {{ path: string; message: string }} ValidationIssue
|
* @typedef {{ path: string; message: string }} ValidationIssue
|
||||||
* @typedef {{ device: string; port: string }} ConnectedTo
|
* @typedef {{ type?: string; color?: string; lengthM?: number }} CableInfo
|
||||||
* @typedef {{ portName: string; speedGbps?: number; connectedTo?: ConnectedTo }} Port
|
* @typedef {{ device: string; port: string; cable?: CableInfo }} ConnectedTo
|
||||||
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string }} VM
|
* @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 {{ 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; unit: number; heightU?: number }} RackPlacement
|
||||||
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice
|
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[]; rack?: RackPlacement }} Machine
|
||||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[] }} NetworkData
|
* @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 {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
||||||
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
||||||
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
||||||
@@ -105,6 +109,10 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
|||||||
optional: true,
|
optional: true,
|
||||||
min: 0
|
min: 0
|
||||||
});
|
});
|
||||||
|
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
});
|
||||||
|
|
||||||
let connectedTo;
|
let connectedTo;
|
||||||
if (value.connectedTo !== undefined) {
|
if (value.connectedTo !== undefined) {
|
||||||
@@ -113,8 +121,41 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
|||||||
} else {
|
} else {
|
||||||
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
|
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
|
||||||
const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port);
|
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) {
|
if (device && port) {
|
||||||
connectedTo = { device, port };
|
connectedTo = { device, port, ...(cable ? { cable } : {}) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,10 +167,38 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
|||||||
return {
|
return {
|
||||||
portName,
|
portName,
|
||||||
...(typeof speedGbps === 'number' ? { speedGbps } : {}),
|
...(typeof speedGbps === 'number' ? { speedGbps } : {}),
|
||||||
|
...(macAddress ? { macAddress } : {}),
|
||||||
...(connectedTo ? { connectedTo } : {})
|
...(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 {ValidationIssue[]} issues
|
||||||
* @param {PortEntry[]} portEntries
|
* @param {PortEntry[]} portEntries
|
||||||
@@ -166,6 +235,10 @@ export function normalizeVm(issues, path, value) {
|
|||||||
const role = readString(issues, `${path}.role`, value.role);
|
const role = readString(issues, `${path}.role`, value.role);
|
||||||
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
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) {
|
if (!name || !role || !ipAddress) {
|
||||||
return null;
|
return null;
|
||||||
@@ -175,7 +248,8 @@ export function normalizeVm(issues, path, value) {
|
|||||||
name,
|
name,
|
||||||
role,
|
role,
|
||||||
ipAddress,
|
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 role = readString(issues, `${path}.role`, value.role);
|
||||||
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
|
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
|
||||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
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;
|
const softwareRaw = value.software;
|
||||||
if (!isRecord(softwareRaw)) {
|
if (!isRecord(softwareRaw)) {
|
||||||
@@ -233,8 +312,16 @@ function normalizeMachine(issues, path, value) {
|
|||||||
issues.push({ path: `${path}.hardware`, message: 'must be an object' });
|
issues.push({ path: `${path}.hardware`, message: 'must be an object' });
|
||||||
}
|
}
|
||||||
const hardware = {
|
const hardware = {
|
||||||
cpu: readString(issues, `${path}.hardware.cpu`, isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined),
|
cpu: readString(
|
||||||
ram: readString(issues, `${path}.hardware.ram`, isRecord(hardwareRaw) ? hardwareRaw.ram : undefined),
|
issues,
|
||||||
|
`${path}.hardware.cpu`,
|
||||||
|
isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined
|
||||||
|
),
|
||||||
|
ram: readString(
|
||||||
|
issues,
|
||||||
|
`${path}.hardware.ram`,
|
||||||
|
isRecord(hardwareRaw) ? hardwareRaw.ram : undefined
|
||||||
|
),
|
||||||
networkPorts: readNumber(
|
networkPorts: readNumber(
|
||||||
issues,
|
issues,
|
||||||
`${path}.hardware.networkPorts`,
|
`${path}.hardware.networkPorts`,
|
||||||
@@ -247,10 +334,15 @@ function normalizeMachine(issues, path, value) {
|
|||||||
isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined,
|
isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined,
|
||||||
{ optional: true, min: 0 }
|
{ optional: true, min: 0 }
|
||||||
),
|
),
|
||||||
gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, {
|
gpu: readString(
|
||||||
optional: true,
|
issues,
|
||||||
allowEmpty: true
|
`${path}.hardware.gpu`,
|
||||||
})
|
isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined,
|
||||||
|
{
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
}
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
const portsRaw = value.ports;
|
const portsRaw = value.ports;
|
||||||
@@ -287,6 +379,8 @@ function normalizeMachine(issues, path, value) {
|
|||||||
role,
|
role,
|
||||||
operatingSystem,
|
operatingSystem,
|
||||||
...(iconKey ? { iconKey } : {}),
|
...(iconKey ? { iconKey } : {}),
|
||||||
|
...(notes !== undefined ? { notes } : {}),
|
||||||
|
...(rack ? { rack } : {}),
|
||||||
software: { vms },
|
software: { vms },
|
||||||
hardware: {
|
hardware: {
|
||||||
cpu: hardware.cpu,
|
cpu: hardware.cpu,
|
||||||
@@ -316,8 +410,12 @@ function normalizeDevice(issues, path, value) {
|
|||||||
const name = readString(issues, `${path}.name`, value.name);
|
const name = readString(issues, `${path}.name`, value.name);
|
||||||
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||||
const type = readString(issues, `${path}.type`, value.type);
|
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 iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||||
|
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||||
|
|
||||||
const portsRaw = value.ports;
|
const portsRaw = value.ports;
|
||||||
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
|
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
|
||||||
@@ -345,10 +443,84 @@ function normalizeDevice(issues, path, value) {
|
|||||||
type,
|
type,
|
||||||
...(iconKey ? { iconKey } : {}),
|
...(iconKey ? { iconKey } : {}),
|
||||||
...(notes !== undefined ? { notes } : {}),
|
...(notes !== undefined ? { notes } : {}),
|
||||||
|
...(rack ? { rack } : {}),
|
||||||
ports
|
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
|
* @param {unknown} value
|
||||||
* @returns {ValidationResult}
|
* @returns {ValidationResult}
|
||||||
@@ -375,7 +547,10 @@ export function validateNetworkData(value) {
|
|||||||
|
|
||||||
const machines = [];
|
const machines = [];
|
||||||
const machineEntries = [];
|
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);
|
const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
machines.push(machine);
|
machines.push(machine);
|
||||||
@@ -385,7 +560,10 @@ export function validateNetworkData(value) {
|
|||||||
|
|
||||||
const devices = [];
|
const devices = [];
|
||||||
const deviceEntries = [];
|
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);
|
const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue);
|
||||||
if (device) {
|
if (device) {
|
||||||
devices.push(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 = {
|
const data = {
|
||||||
machines,
|
machines,
|
||||||
devices
|
devices,
|
||||||
|
...(subnetsProvided ? { subnets } : {}),
|
||||||
|
...(racksProvided ? { racks } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
|
export interface CableInfo {
|
||||||
|
type?: string;
|
||||||
|
color?: string;
|
||||||
|
lengthM?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectedPortRef {
|
export interface ConnectedPortRef {
|
||||||
device: string;
|
device: string;
|
||||||
port: string;
|
port: string;
|
||||||
|
cable?: CableInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Port {
|
export interface Port {
|
||||||
portName: string;
|
portName: string;
|
||||||
speedGbps?: number; // default to 1 if not provided
|
speedGbps?: number; // default to 1 if not provided
|
||||||
|
macAddress?: string;
|
||||||
connectedTo?: ConnectedPortRef;
|
connectedTo?: ConnectedPortRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RackPlacement {
|
||||||
|
name: string;
|
||||||
|
unit: number; // bottom U position, 1-based
|
||||||
|
heightU?: number; // defaults to 1
|
||||||
|
}
|
||||||
|
|
||||||
export interface Hardware {
|
export interface Hardware {
|
||||||
cpu: string;
|
cpu: string;
|
||||||
ram: string;
|
ram: string;
|
||||||
@@ -22,6 +36,7 @@ export interface VM {
|
|||||||
role: string;
|
role: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
|
macAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Software {
|
export interface Software {
|
||||||
@@ -34,9 +49,11 @@ export interface Machine {
|
|||||||
role: string;
|
role: string;
|
||||||
operatingSystem: string;
|
operatingSystem: string;
|
||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
|
notes?: string;
|
||||||
software: Software;
|
software: Software;
|
||||||
hardware: Hardware;
|
hardware: Hardware;
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
|
rack?: RackPlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkDevice {
|
export interface NetworkDevice {
|
||||||
@@ -46,9 +63,23 @@ export interface NetworkDevice {
|
|||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
|
rack?: RackPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subnet {
|
||||||
|
cidr: string;
|
||||||
|
name?: string;
|
||||||
|
vlanId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RackDefinition {
|
||||||
|
name: string;
|
||||||
|
heightU?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkData {
|
export interface NetworkData {
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
devices: NetworkDevice[];
|
devices: NetworkDevice[];
|
||||||
|
subnets?: Subnet[];
|
||||||
|
racks?: RackDefinition[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
|
|
||||||
import { validateNetworkData } from '$lib/data/networkSchema';
|
import { validateNetworkData } from '$lib/data/networkSchema';
|
||||||
import {
|
import {
|
||||||
checkWritableState,
|
getWritableState,
|
||||||
isWriteEnabled,
|
|
||||||
readNetworkFile,
|
readNetworkFile,
|
||||||
writeNetworkFile
|
writeNetworkFile
|
||||||
} from '$lib/server/networkPersistence';
|
} from '$lib/server/networkPersistence';
|
||||||
@@ -16,11 +15,18 @@ function serializeValidationErrors(errors: Array<{ path: string; message: string
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyErrorMessage(reason: string | null): string {
|
||||||
|
if (!reason) {
|
||||||
|
return 'Write API unavailable in current deployment.';
|
||||||
|
}
|
||||||
|
return `Write API unavailable: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
try {
|
try {
|
||||||
const [{ data, source, updatedAt }, writable] = await Promise.all([
|
const [{ data, source, updatedAt }, writableState] = await Promise.all([
|
||||||
readNetworkFile(),
|
readNetworkFile(),
|
||||||
checkWritableState()
|
getWritableState()
|
||||||
]);
|
]);
|
||||||
const validation = validateNetworkData(data);
|
const validation = validateNetworkData(data);
|
||||||
if (!validation.valid || !validation.data) {
|
if (!validation.valid || !validation.data) {
|
||||||
@@ -35,7 +41,8 @@ export const GET: RequestHandler = async () => {
|
|||||||
|
|
||||||
return json({
|
return json({
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable,
|
writable: writableState.writable,
|
||||||
|
writableReason: writableState.reason,
|
||||||
source,
|
source,
|
||||||
updatedAt
|
updatedAt
|
||||||
});
|
});
|
||||||
@@ -47,10 +54,13 @@ export const GET: RequestHandler = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ request }) => {
|
export const PUT: RequestHandler = async ({ request }) => {
|
||||||
if (!isWriteEnabled()) {
|
const writableStateBeforeWrite = await getWritableState();
|
||||||
|
if (!writableStateBeforeWrite.writable) {
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableStateBeforeWrite.reason
|
||||||
},
|
},
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
@@ -76,10 +86,11 @@ export const PUT: RequestHandler = async ({ request }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await writeNetworkFile(validation.data);
|
const metadata = await writeNetworkFile(validation.data);
|
||||||
const writable = await checkWritableState();
|
const writableStateAfterWrite = await getWritableState();
|
||||||
return json({
|
return json({
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable,
|
writable: writableStateAfterWrite.writable,
|
||||||
|
writableReason: writableStateAfterWrite.reason,
|
||||||
source: metadata.source,
|
source: metadata.source,
|
||||||
updatedAt: metadata.updatedAt
|
updatedAt: metadata.updatedAt
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
"role": "Hypervisor",
|
"role": "Hypervisor",
|
||||||
"operatingSystem": "Proxmox",
|
"operatingSystem": "Proxmox",
|
||||||
"iconKey": "homarr:proxmox",
|
"iconKey": "homarr:proxmox",
|
||||||
|
"notes": "Primary router box; do not power off without failover in place.",
|
||||||
"software": {
|
"software": {
|
||||||
"vms": [
|
"vms": [
|
||||||
{
|
{
|
||||||
"name": "OpnSense",
|
"name": "OpnSense",
|
||||||
"role": "Router/Firewall/Gateway (DHCP 10.0.0.100-254)",
|
"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",
|
"name": "TPLink Omada Controller",
|
||||||
@@ -50,9 +52,15 @@
|
|||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
|
"macAddress": "84:47:09:1c:aa:10",
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Gigabit Switch",
|
"device": "Gigabit Switch",
|
||||||
"port": "1"
|
"port": "1",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,7 +72,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Gigabit Switch",
|
"device": "Gigabit Switch",
|
||||||
"port": "2"
|
"port": "2",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -75,7 +88,11 @@
|
|||||||
"port": "wan"
|
"port": "wan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 10
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "Asustor NAS",
|
"machineName": "Asustor NAS",
|
||||||
@@ -98,7 +115,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Gigabit Switch",
|
"device": "Gigabit Switch",
|
||||||
"port": "3"
|
"port": "3",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat5e",
|
||||||
|
"color": "red",
|
||||||
|
"lengthM": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -254,7 +276,12 @@
|
|||||||
"port": "8"
|
"port": "8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 7,
|
||||||
|
"heightU": 2
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "AI Server",
|
"machineName": "AI Server",
|
||||||
@@ -292,7 +319,12 @@
|
|||||||
"port": "9"
|
"port": "9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 4,
|
||||||
|
"heightU": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "Immich Mini PC",
|
"machineName": "Immich Mini PC",
|
||||||
@@ -334,7 +366,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "ProxRouter",
|
"device": "ProxRouter",
|
||||||
"port": "eth0"
|
"port": "eth0",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -342,7 +379,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "ProxRouter",
|
"device": "ProxRouter",
|
||||||
"port": "eth2"
|
"port": "eth2",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -350,7 +392,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Asustor NAS",
|
"device": "Asustor NAS",
|
||||||
"port": "eth0"
|
"port": "eth0",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat5e",
|
||||||
|
"color": "red",
|
||||||
|
"lengthM": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -473,7 +520,11 @@
|
|||||||
"portName": "24",
|
"portName": "24",
|
||||||
"speedGbps": 1
|
"speedGbps": 1
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 12
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "WAN Uplink",
|
"name": "WAN Uplink",
|
||||||
@@ -505,7 +556,11 @@
|
|||||||
"port": "11"
|
"port": "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "3DS",
|
"name": "3DS",
|
||||||
@@ -547,7 +602,11 @@
|
|||||||
"port": "20"
|
"port": "20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "UPS Pi",
|
"name": "UPS Pi",
|
||||||
@@ -559,7 +618,12 @@
|
|||||||
"portName": "port0",
|
"portName": "port0",
|
||||||
"speedGbps": 1
|
"speedGbps": 1
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 1,
|
||||||
|
"heightU": 2
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Waveshare",
|
"name": "Waveshare",
|
||||||
@@ -568,5 +632,23 @@
|
|||||||
"notes": "Port link not yet modeled.",
|
"notes": "Port link not yet modeled.",
|
||||||
"ports": []
|
"ports": []
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"subnets": [
|
||||||
|
{
|
||||||
|
"cidr": "10.0.0.0/24",
|
||||||
|
"name": "LAN",
|
||||||
|
"vlanId": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
const deployTarget = (process.env.DEPLOY_TARGET ?? 'docker').toLowerCase();
|
const deployTarget = (process.env.DEPLOY_TARGET ?? 'docker').toLowerCase();
|
||||||
const adapter = deployTarget === 'netlify'
|
const adapter =
|
||||||
? netlifyAdapter()
|
deployTarget === 'netlify'
|
||||||
: staticAdapter({
|
? netlifyAdapter()
|
||||||
fallback: 'index.html',
|
: staticAdapter({
|
||||||
strict: false
|
fallback: 'index.html',
|
||||||
});
|
strict: false
|
||||||
|
});
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||