mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 11:33:43 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a067cc3231 | |||
| 68496ae904 | |||
| e58e427093 | |||
| 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 | |||
| 3ce0adfa0b | |||
| a7dbcf426f | |||
| e7dd300098 | |||
| d2826f4cb3 | |||
| b1fb4fc141 | |||
| 1d656ff120 | |||
| 45203a4c28 | |||
| 4cbec53394 |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -16,14 +16,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: docker.io/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build (PR validation only)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Docker Hub Description
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- README.md
|
||||
- .github/workflows/dockerhub-description.yml
|
||||
|
||||
jobs:
|
||||
dockerhub-description:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Preflight Docker Hub repository access
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_REPOSITORY: jcreek23/open-network-diagram
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_PASSWORD}" ]; then
|
||||
echo "Missing DOCKERHUB_USERNAME or DOCKERHUB_PASSWORD secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOGIN_RESPONSE="$(curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_PASSWORD}\"}" \
|
||||
https://hub.docker.com/v2/users/login/)"
|
||||
|
||||
TOKEN="$(printf '%s' "${LOGIN_RESPONSE}" | ruby -rjson -e 'input = STDIN.read; data = JSON.parse(input); puts data.fetch("token", "")')"
|
||||
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo "Docker Hub login succeeded but token was missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: JWT ${TOKEN}" \
|
||||
"https://hub.docker.com/v2/repositories/${DOCKERHUB_REPOSITORY}/" >/dev/null
|
||||
|
||||
echo "Docker Hub preflight access check passed for ${DOCKERHUB_REPOSITORY}."
|
||||
|
||||
- name: Update Docker Hub repository overview
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: jcreek23/open-network-diagram
|
||||
readme-filepath: ./README.md
|
||||
enable-url-completion: true
|
||||
@@ -27,18 +27,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Create Git tag + GitHub release
|
||||
id: semantic
|
||||
uses: cycjimmy/semantic-release-action@v5.0.2
|
||||
uses: cycjimmy/semantic-release-action@v6
|
||||
with:
|
||||
extra_plugins: |
|
||||
@semantic-release/commit-analyzer
|
||||
@@ -57,20 +57,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: docker.io/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push release image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
@@ -5,3 +5,8 @@ yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
src/lib/config/vendorIconManifest.ts
|
||||
|
||||
# Design handoff bundles (prototypes, not code)
|
||||
new-design/
|
||||
claude-design/
|
||||
homelab-hub-main/
|
||||
|
||||
@@ -1,177 +1,158 @@
|
||||
# **Open Network Diagram**
|
||||
# Open Network Diagram
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://hub.docker.com/r/jcreek23/open-network-diagram)
|
||||
[](https://github.com/jcreek/OpenNetworkDiagram/actions/workflows/release.yml)
|
||||
[](https://github.com/jcreek/OpenNetworkDiagram/actions/workflows/docker.yml)
|
||||
[](https://github.com/jcreek/OpenNetworkDiagram/releases)
|
||||
[](https://opennetworkdiagram.jcreek.co.uk)
|
||||
|
||||
**A declarative, self-hosted tool for visualising and managing home lab & network architecture diagrams.**
|
||||
**A declarative, self-hosted containerised tool for visualising and managing home lab & network architecture diagrams.**
|
||||
|
||||
---
|
||||
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can version, back up, and reuse.
|
||||
|
||||
## **📝 About**
|
||||
- Homelab-friendly: run it in minutes with Docker.
|
||||
- Practical: edit in the UI and autosave to `network.json`.
|
||||
- Declarative: keep your topology in Git if you wish.
|
||||
|
||||
**Open Network Diagram** is an **open-source, self-hosted tool** for creating **interactive network and infrastructure diagrams** using a **declarative JSON format**.
|
||||
[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)
|
||||
|
||||
✅ **Fully self-hostable via Docker**
|
||||
✅ **Docker-first deployment target** (Netlify optional for demo hosting)
|
||||
✅ **Interactive network visualisation**
|
||||
✅ **Single-page modal editor with live updates**
|
||||
✅ **Debounced autosave to JSON (self-hosted)**
|
||||
✅ **Local vendored icon catalog (offline runtime)**
|
||||
✅ **Lightweight Svelte**
|
||||

|
||||
|
||||
Use it to **document your home lab, office network, or cloud infrastructure** with an easy-to-use web interface.
|
||||
## Features
|
||||
|
||||
---
|
||||
- Network view with ethernet labels to make physical and logical links easy to read.
|
||||
- Hosts & VMs view for host-first inventory and service mapping.
|
||||
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
|
||||
- Node search that highlights matches by name, IP, role or OS and dims everything else.
|
||||
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
|
||||
- Subnet and VLAN declarations, with nodes colour-coded by VLAN and a clickable legend filter.
|
||||
- Cable tracking on connections (type, colour, length) with colour-coded links and hover details.
|
||||
- Expandable VM lists per machine for quick virtualization visibility.
|
||||
- Modal editor for machines/devices with live diagram updates, notes and MAC address fields.
|
||||
- JSON-backed persistence with autosave in self-hosted mode.
|
||||
- Docker-first deployment with writable data volume support.
|
||||
- Optional read-only mode for public demos and safe sharing.
|
||||
- Local vendored icon catalog for offline-friendly runtime behavior.
|
||||
|
||||
## **🚀 Quick Start (For Users)**
|
||||
## Why Home Lab Users Use It
|
||||
|
||||
### **1️⃣ Create Your Runtime Data File**
|
||||
- Keep an always-up-to-date map of machines, VMs, and devices.
|
||||
- Find a free IP and spot duplicate assignments without a spreadsheet.
|
||||
- Know exactly what's in your rack and where, down to the U.
|
||||
- Edit quickly through a modal UI instead of hand-editing large diagrams.
|
||||
- Persist everything to JSON so backups and Git workflows stay simple.
|
||||
- Stay fully self-hosted with no runtime dependency on external APIs.
|
||||
|
||||
Copy the template and edit your own network data:
|
||||
## 2-Minute Docker Quick Start
|
||||
|
||||
This is the fastest way to run Open Network Diagram for a home lab.
|
||||
|
||||
1. Create a local data folder and seed your first `network.json`:
|
||||
|
||||
```bash
|
||||
cp data/network.json.example data/network.json
|
||||
mkdir -p ond-data
|
||||
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example -o ond-data/network.json
|
||||
```
|
||||
|
||||
### **2️⃣ Run Open Network Diagram via Docker**
|
||||
|
||||
From this repo:
|
||||
2. Run the published Docker image:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Or pull and run directly:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:3000 \
|
||||
docker run -d \
|
||||
--name open-network-diagram \
|
||||
--restart unless-stopped \
|
||||
-p 8080:3000 \
|
||||
-e NETWORK_DATA_FILE=/app/data/network.json \
|
||||
-e NETWORK_BACKUP_DIR=/app/data/.backups \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
jcreek23/open-network-diagram
|
||||
-v "$(pwd)/ond-data:/app/data" \
|
||||
jcreek23/open-network-diagram:latest
|
||||
```
|
||||
|
||||
- **`-p 8080:3000`** → Maps the app to `http://localhost:8080`
|
||||
- **`-v .../data:/app/data`** → Uses your local writable `data/network.json`
|
||||
3. Open the app at `http://localhost:8080`.
|
||||
|
||||
### **3️⃣ Open the Web UI**
|
||||
4. Edit your topology in the UI. Changes persist to `ond-data/network.json`.
|
||||
|
||||
Visit **`http://localhost:8080`** to view your network diagram.
|
||||
Useful follow-up commands:
|
||||
|
||||
### **4️⃣ Modify Your Network (Modal UI + JSON Persistence)**
|
||||
```bash
|
||||
docker logs -f open-network-diagram
|
||||
docker stop open-network-diagram
|
||||
docker rm open-network-diagram
|
||||
```
|
||||
|
||||
- Edit machines/devices/VMs/ports directly in the modal UI.
|
||||
- Diagram updates immediately as you edit.
|
||||
- In self-hosted Docker/local mode, changes autosave to mounted **`data/network.json`**.
|
||||
- Netlify/demo is intentionally read-only; edits are in-memory only.
|
||||
## What It Looks Like
|
||||
|
||||
---
|
||||
| Network view with ethernet labels | Hosts & VMs view with VMs expanded | Modal editing a machine |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
## **👩💻 Development Setup**
|
||||
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|
||||
### **1️⃣ Clone the Repository**
|
||||
## Docker Compose Option
|
||||
|
||||
If you prefer compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-network-diagram:
|
||||
image: jcreek23/open-network-diagram:latest
|
||||
ports:
|
||||
- '8080:3000'
|
||||
volumes:
|
||||
- ./ond-data:/app/data
|
||||
environment:
|
||||
NETWORK_DATA_FILE: /app/data/network.json
|
||||
NETWORK_BACKUP_DIR: /app/data/.backups
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Start it with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## For Developers
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jcreek/OpenNetworkDiagram.git
|
||||
cd open-network-diagram
|
||||
```
|
||||
|
||||
### **2️⃣ Install Dependencies**
|
||||
|
||||
```bash
|
||||
cd OpenNetworkDiagram
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### **3️⃣ Run in Development Mode**
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
- Runs at `http://localhost:5173`
|
||||
App URL: `http://localhost:5173`
|
||||
|
||||
### **4️⃣ Build Targets**
|
||||
### Build Targets
|
||||
|
||||
```bash
|
||||
pnpm run build # default (Docker/static target)
|
||||
pnpm run build:docker # explicit Docker/static target
|
||||
pnpm run build # default build
|
||||
pnpm run build:docker # Docker/static target
|
||||
pnpm run build:netlify # Netlify target (read-only mode)
|
||||
pnpm run icons:manifest # regenerate local vendor icon manifest
|
||||
```
|
||||
|
||||
---
|
||||
### Runtime and Persistence
|
||||
|
||||
## **🛠️ Project Structure**
|
||||
- API endpoint: `GET/PUT /api/network-data`
|
||||
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
||||
- When writes are unavailable, API responses include `writableReason` for diagnostics.
|
||||
- Writes are persisted atomically to the configured data file
|
||||
- Rolling backups are kept in the backup directory (last 5)
|
||||
|
||||
```text
|
||||
open-network-diagram/
|
||||
├── src/ # Svelte app source
|
||||
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
|
||||
├── static/data/network.json # Demo dataset (Netlify/demo)
|
||||
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
|
||||
├── data/network.json.example # User data template (Docker)
|
||||
├── third_party/ # Third-party license/provenance notes
|
||||
├── Dockerfile # Docker build/runtime
|
||||
├── server.mjs # Node runtime server (static + /api/network-data)
|
||||
├── docker-compose.yml # Local Docker run with mounted data
|
||||
├── netlify.toml # Netlify build config
|
||||
├── .github/workflows/ # CI workflows (PR build + automated release/publish)
|
||||
└── README.md # Documentation
|
||||
```
|
||||
Environment variables:
|
||||
|
||||
---
|
||||
- `NETWORK_READ_ONLY` (default: `false`)
|
||||
- Set to `true` to disable writes and force read-only mode.
|
||||
- `NETWORK_DATA_FILE` (default: `data/network.json`)
|
||||
- JSON file path to read/write.
|
||||
- `NETWORK_BACKUP_DIR` (default: `data/.backups`)
|
||||
- Directory for backup files.
|
||||
|
||||
## **📦 Docker Build & Deployment**
|
||||
|
||||
### **Build the Docker Image Locally**
|
||||
|
||||
```bash
|
||||
docker build -t open-network-diagram .
|
||||
```
|
||||
|
||||
### **Run Locally**
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:3000 \
|
||||
-e NETWORK_DATA_FILE=/app/data/network.json \
|
||||
-e NETWORK_BACKUP_DIR=/app/data/.backups \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
open-network-diagram
|
||||
```
|
||||
|
||||
### **Write API Environment Variables**
|
||||
|
||||
- **`NETWORK_READ_ONLY`** (default: `false`)
|
||||
Set to `true` to disable `PUT /api/network-data` and force read-only mode.
|
||||
- **`NETWORK_DATA_FILE`** (default: `data/network.json`)
|
||||
JSON file path to read/write.
|
||||
- **`NETWORK_BACKUP_DIR`** (default: sibling `.backups`)
|
||||
Backup directory for rolling save backups (last 5 retained).
|
||||
|
||||
### **Local Icon Catalog (No Runtime Network Dependency)**
|
||||
|
||||
- Icons are vendored locally under **`static/icons/vendor/homarr/`**.
|
||||
- The searchable catalog is generated into **`src/lib/config/vendorIconManifest.ts`**.
|
||||
- Third-party provenance and licensing are documented in:
|
||||
- **`third_party/homarr-dashboard-icons/SOURCE.txt`**
|
||||
- **`third_party/homarr-dashboard-icons/LICENSE`**
|
||||
- **`third_party/homarr-dashboard-icons/NOTICE.txt`**
|
||||
- Runtime icon search/rendering does not call external APIs.
|
||||
|
||||
### **CI/CD (GitHub Actions + Netlify)**
|
||||
|
||||
- GitHub Actions workflow (`.github/workflows/docker.yml`) builds Docker on PRs (validation only).
|
||||
- GitHub Actions workflow (`.github/workflows/release.yml`) runs on `main`, creates semantic version tags/releases, and publishes Docker images to **Docker Hub** (`jcreek23/open-network-diagram`).
|
||||
- Netlify uses its own CI/CD pipeline with `netlify.toml` (`pnpm run build:netlify`).
|
||||
|
||||
---
|
||||
|
||||
## **📝 JSON Network Configuration Example**
|
||||
|
||||
Define your network using **`network.json`**:
|
||||
### JSON Example
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -181,41 +162,99 @@ Define your network using **`network.json`**:
|
||||
"ipAddress": "10.0.0.3",
|
||||
"role": "Hypervisor",
|
||||
"operatingSystem": "Proxmox",
|
||||
"notes": "Primary router box.",
|
||||
"software": {
|
||||
"vms": [{ "name": "OpnSense", "role": "Router", "ipAddress": "10.0.0.4" }]
|
||||
"vms": [
|
||||
{
|
||||
"name": "OpnSense",
|
||||
"role": "Router",
|
||||
"ipAddress": "10.0.0.4",
|
||||
"macAddress": "bc:24:11:5a:2e:01"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Intel N100",
|
||||
"ram": "8GB",
|
||||
"networkPorts": 4
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Switch",
|
||||
"port": "1",
|
||||
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"devices": [{ "name": "Switch", "ipAddress": "10.0.0.2", "type": "Nintendo Switch" }]
|
||||
"rack": { "name": "Lab Rack", "unit": 10 }
|
||||
}
|
||||
],
|
||||
"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:
|
||||
|
||||
## **🤝 Contributing**
|
||||
- `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.
|
||||
|
||||
We welcome contributions! To contribute:
|
||||
### Project Structure
|
||||
|
||||
1. **Fork the repository**.
|
||||
2. **Create a feature branch** (`git checkout -b feature-name`).
|
||||
3. **Commit your changes** (`git commit -m "Add feature X"`).
|
||||
4. **Push to your fork** (`git push origin feature-name`).
|
||||
5. **Submit a Pull Request**.
|
||||
```text
|
||||
OpenNetworkDiagram/
|
||||
├── src/ # Svelte app source
|
||||
├── src/lib/shared/ # Schema + persistence core (shared with server.mjs)
|
||||
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
|
||||
├── static/data/network.json # Demo dataset (Netlify)
|
||||
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
|
||||
├── data/network.json.example # Starter data template for Docker users
|
||||
├── third_party/ # Third-party provenance + licensing
|
||||
├── Dockerfile # Docker build/runtime image
|
||||
├── server.mjs # Node runtime server (static + API)
|
||||
├── docker-compose.yml # Local compose example (build from repo)
|
||||
├── netlify.toml # Netlify build config
|
||||
└── .github/workflows/ # CI workflows
|
||||
```
|
||||
|
||||
---
|
||||
### CI/CD
|
||||
|
||||
## **📜 License**
|
||||
- `docker.yml`: validates Docker build on pull requests.
|
||||
- `release.yml`: semantic release on `main` and Docker Hub publish for tagged releases.
|
||||
- Docker Hub image: [`jcreek23/open-network-diagram`](https://hub.docker.com/r/jcreek23/open-network-diagram)
|
||||
|
||||
[GNU GPL v3 License](LICENSE) – Free to use, modify, and distribute, except distributing closed source versions.
|
||||
## Contributing
|
||||
|
||||
---
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch.
|
||||
3. Commit your changes.
|
||||
4. Push your branch.
|
||||
5. Open a pull request.
|
||||
|
||||
## **📬 Contact**
|
||||
## License
|
||||
|
||||
**Author:** [Joshua Creek](https://github.com/jcreek)
|
||||
**Project Repo:** [GitHub](https://github.com/jcreek/OpenNetworkDiagram)
|
||||
[GNU GPL v3](LICENSE)
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"ipAddress": "192.168.1.10",
|
||||
"role": "Hypervisor",
|
||||
"operatingSystem": "Linux",
|
||||
"notes": "Optional notes",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"macAddress": "aa:bb:cc:dd:ee:01",
|
||||
"connectedTo": {
|
||||
"device": "Example Switch",
|
||||
"port": "1"
|
||||
@@ -20,7 +22,8 @@
|
||||
{
|
||||
"name": "Example VM",
|
||||
"role": "Service",
|
||||
"ipAddress": "192.168.1.20"
|
||||
"ipAddress": "192.168.1.20",
|
||||
"macAddress": "aa:bb:cc:dd:ee:02"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -39,6 +42,10 @@
|
||||
"ipAddress": "192.168.1.2",
|
||||
"type": "Network Switch",
|
||||
"notes": "Optional notes",
|
||||
"rack": {
|
||||
"name": "Example Rack",
|
||||
"unit": 12
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "1",
|
||||
@@ -50,5 +57,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"subnets": [
|
||||
{
|
||||
"cidr": "192.168.1.0/24",
|
||||
"name": "LAN",
|
||||
"vlanId": 1
|
||||
}
|
||||
],
|
||||
"racks": [
|
||||
{
|
||||
"name": "Example Rack",
|
||||
"heightU": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,10 +5,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: open-network-diagram:local
|
||||
ports:
|
||||
- "8080:3000"
|
||||
- '8080:3000'
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
NETWORK_DATA_FILE: "/app/data/network.json"
|
||||
NETWORK_BACKUP_DIR: "/app/data/.backups"
|
||||
NETWORK_DATA_FILE: '/app/data/network.json'
|
||||
NETWORK_BACKUP_DIR: '/app/data/.backups'
|
||||
restart: unless-stopped
|
||||
|
||||
+4
-3
@@ -38,8 +38,8 @@
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
@@ -55,6 +55,7 @@
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||
"dependencies": {
|
||||
"cytoscape": "^3.31.2",
|
||||
"cytoscape-dagre": "^2.5.0"
|
||||
"cytoscape-dagre": "^2.5.0",
|
||||
"html-to-image": "^1.11.13"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+28
-19
@@ -14,6 +14,9 @@ importers:
|
||||
cytoscape-dagre:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(cytoscape@3.33.1)
|
||||
html-to-image:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
devDependencies:
|
||||
'@eslint/compat':
|
||||
specifier: ^1.2.5
|
||||
@@ -76,11 +79,11 @@ importers:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.11
|
||||
version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
||||
svelte:
|
||||
specifier: ^5.0.0
|
||||
version: 5.25.8
|
||||
@@ -1447,6 +1450,9 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1881,17 +1887,20 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier-plugin-svelte@3.3.3:
|
||||
resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==}
|
||||
prettier-plugin-svelte@4.1.1:
|
||||
resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
prettier: ^3.0.0
|
||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
||||
svelte: ^5.0.0
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.11:
|
||||
resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
prettier-plugin-tailwindcss@0.8.0:
|
||||
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||
'@prettier/plugin-hermes': '*'
|
||||
'@prettier/plugin-oxc': '*'
|
||||
'@prettier/plugin-pug': '*'
|
||||
'@shopify/prettier-plugin-liquid': '*'
|
||||
'@trivago/prettier-plugin-sort-imports': '*'
|
||||
@@ -1899,18 +1908,20 @@ packages:
|
||||
prettier: ^3.0
|
||||
prettier-plugin-astro: '*'
|
||||
prettier-plugin-css-order: '*'
|
||||
prettier-plugin-import-sort: '*'
|
||||
prettier-plugin-jsdoc: '*'
|
||||
prettier-plugin-marko: '*'
|
||||
prettier-plugin-multiline-arrays: '*'
|
||||
prettier-plugin-organize-attributes: '*'
|
||||
prettier-plugin-organize-imports: '*'
|
||||
prettier-plugin-sort-imports: '*'
|
||||
prettier-plugin-style-order: '*'
|
||||
prettier-plugin-svelte: '*'
|
||||
peerDependenciesMeta:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
optional: true
|
||||
'@prettier/plugin-hermes':
|
||||
optional: true
|
||||
'@prettier/plugin-oxc':
|
||||
optional: true
|
||||
'@prettier/plugin-pug':
|
||||
optional: true
|
||||
'@shopify/prettier-plugin-liquid':
|
||||
@@ -1923,8 +1934,6 @@ packages:
|
||||
optional: true
|
||||
prettier-plugin-css-order:
|
||||
optional: true
|
||||
prettier-plugin-import-sort:
|
||||
optional: true
|
||||
prettier-plugin-jsdoc:
|
||||
optional: true
|
||||
prettier-plugin-marko:
|
||||
@@ -1937,8 +1946,6 @@ packages:
|
||||
optional: true
|
||||
prettier-plugin-sort-imports:
|
||||
optional: true
|
||||
prettier-plugin-style-order:
|
||||
optional: true
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
|
||||
@@ -3631,6 +3638,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -4022,16 +4031,16 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8):
|
||||
prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8):
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
svelte: 5.25.8
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
|
||||
prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
optionalDependencies:
|
||||
prettier-plugin-svelte: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
||||
prettier-plugin-svelte: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||
|
||||
prettier@3.8.1: {}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -50,7 +50,9 @@ async function walk(dir) {
|
||||
const directories = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
const nestedFileLists = await Promise.all(directories.map((childDirectory) => walk(childDirectory)));
|
||||
const nestedFileLists = await Promise.all(
|
||||
directories.map((childDirectory) => walk(childDirectory))
|
||||
);
|
||||
|
||||
for (const nestedFiles of nestedFileLists) {
|
||||
files.push(...nestedFiles);
|
||||
@@ -88,7 +90,8 @@ async function ensureIgnoreEntry(filePath, entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
||||
const suffix =
|
||||
contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
|
||||
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
|
||||
await fs.writeFile(filePath, nextContents, 'utf8');
|
||||
}
|
||||
@@ -141,7 +144,10 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) {
|
||||
if (
|
||||
candidate.priority === existing.priority &&
|
||||
candidate.relativePath < existing.relativePath
|
||||
) {
|
||||
dedupedBySlug.set(slug, candidate);
|
||||
}
|
||||
}
|
||||
@@ -182,11 +188,10 @@ async function main() {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
|
||||
await fs.writeFile(outputPath, output, 'utf8');
|
||||
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
+19
-9
@@ -4,8 +4,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
||||
import {
|
||||
checkWritableState,
|
||||
isWriteEnabled,
|
||||
getWritableState,
|
||||
readNetworkFile,
|
||||
writeNetworkFile
|
||||
} from './src/lib/shared/networkPersistenceCore.mjs';
|
||||
@@ -50,10 +49,17 @@ function badRequest(response, message, details) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveReadOnlyErrorMessage(reason) {
|
||||
if (!reason) {
|
||||
return 'Write API unavailable in current deployment.';
|
||||
}
|
||||
return `Write API unavailable: ${reason}`;
|
||||
}
|
||||
|
||||
async function serveApi(request, response) {
|
||||
if (request.method === 'GET') {
|
||||
try {
|
||||
const payload = await readNetworkFile();
|
||||
const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]);
|
||||
const validation = validateNetworkData(payload.data);
|
||||
if (!validation.valid) {
|
||||
sendJson(response, 500, {
|
||||
@@ -63,11 +69,10 @@ async function serveApi(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canWrite = await checkWritableState();
|
||||
|
||||
sendJson(response, 200, {
|
||||
data: validation.data,
|
||||
writable: canWrite,
|
||||
writable: writableState.writable,
|
||||
writableReason: writableState.reason,
|
||||
source: payload.source,
|
||||
updatedAt: payload.updatedAt
|
||||
});
|
||||
@@ -80,9 +85,12 @@ async function serveApi(request, response) {
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
if (!isWriteEnabled()) {
|
||||
const writableStateBeforeWrite = await getWritableState();
|
||||
if (!writableStateBeforeWrite.writable) {
|
||||
sendJson(response, 403, {
|
||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
||||
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||
writable: false,
|
||||
writableReason: writableStateBeforeWrite.reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -123,9 +131,11 @@ async function serveApi(request, response) {
|
||||
|
||||
try {
|
||||
const metadata = await writeNetworkFile(validation.data);
|
||||
const writableStateAfterWrite = await getWritableState();
|
||||
sendJson(response, 200, {
|
||||
data: validation.data,
|
||||
writable: true,
|
||||
writable: writableStateAfterWrite.writable,
|
||||
writableReason: writableStateAfterWrite.reason,
|
||||
source: metadata.source,
|
||||
updatedAt: metadata.updatedAt
|
||||
});
|
||||
|
||||
+85
-16
@@ -1,30 +1,99 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
/* Design tokens from the UX review (new-design/project/UX Review.dc.html, panel 1b).
|
||||
Principle: calm chrome, loud data. */
|
||||
:root {
|
||||
--app-bg: #e2e8f0;
|
||||
--panel-bg: #f8fafc;
|
||||
--panel-border: #cbd5e1;
|
||||
--panel-contrast: #0f172a;
|
||||
--muted-text: #334155;
|
||||
--chip-bg: #e2e8f0;
|
||||
--chip-text: #0f172a;
|
||||
--modal-overlay: rgba(15, 23, 42, 0.58);
|
||||
/* Core colours */
|
||||
--bg-canvas: #eef0f3;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f2f4f7;
|
||||
--border: #dce0e6;
|
||||
--text: #1b212b;
|
||||
--text-2: #616a78;
|
||||
--accent: #3565c4;
|
||||
--danger: #c03d38;
|
||||
|
||||
/* Graph canvas tokens (plain hex — read via getComputedStyle for Cytoscape) */
|
||||
--graph-edge: #8b94a3;
|
||||
--graph-node-border: #c6ccd4;
|
||||
--graph-infra-fill: #3a4250;
|
||||
--graph-infra-border: #2b323d;
|
||||
--graph-dumb-border: #b3bac4;
|
||||
--graph-edge-chip-bg: #ffffff;
|
||||
--graph-edge-chip-text: #3a4250;
|
||||
|
||||
/* VLAN categorical set — equal lightness/chroma so no VLAN shouts louder */
|
||||
--vlan-0: oklch(0.55 0.13 290);
|
||||
--vlan-1: oklch(0.55 0.13 15);
|
||||
--vlan-2: oklch(0.55 0.13 175);
|
||||
--vlan-3: oklch(0.55 0.13 75);
|
||||
--vlan-4: oklch(0.55 0.13 240);
|
||||
--vlan-5: oklch(0.55 0.13 125);
|
||||
|
||||
/* Type */
|
||||
--font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Shape & elevation */
|
||||
--radius-control: 6px;
|
||||
--radius-panel: 10px;
|
||||
--radius-modal: 14px;
|
||||
--control-h: 32px;
|
||||
--shadow-panel: 0 4px 16px rgb(0 0 0 / 0.08);
|
||||
--shadow-popover: 0 8px 24px rgb(0 0 0 / 0.12);
|
||||
--modal-overlay: rgb(15 18 24 / 0.45);
|
||||
|
||||
/* Status */
|
||||
--status-ok: #2e7d4f;
|
||||
--status-warn: #b0782f;
|
||||
|
||||
/* Legacy aliases (older selectors still reference these) */
|
||||
--app-bg: var(--bg-canvas);
|
||||
--panel-bg: var(--surface);
|
||||
--panel-border: var(--border);
|
||||
--panel-contrast: var(--text);
|
||||
--muted-text: var(--text-2);
|
||||
--chip-bg: var(--surface-2);
|
||||
--chip-text: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--app-bg: #0b1220;
|
||||
--panel-bg: #111827;
|
||||
--panel-border: #334155;
|
||||
--panel-contrast: #e2e8f0;
|
||||
--muted-text: #94a3b8;
|
||||
--chip-bg: #1e293b;
|
||||
--chip-text: #e2e8f0;
|
||||
--modal-overlay: rgba(2, 6, 23, 0.78);
|
||||
--bg-canvas: #14171c;
|
||||
--surface: #1d222a;
|
||||
--surface-2: #262d38;
|
||||
--border: #363f4d;
|
||||
--text: #e7ebf2;
|
||||
--text-2: #9aa4b2;
|
||||
--accent: #7da5e8;
|
||||
--danger: #e0716c;
|
||||
|
||||
--graph-edge: #5b6575;
|
||||
--graph-node-border: #363f4d;
|
||||
--graph-infra-fill: #3e4c61;
|
||||
--graph-infra-border: #5b6c86;
|
||||
--graph-dumb-border: #414c5d;
|
||||
--graph-edge-chip-bg: #262d38;
|
||||
--graph-edge-chip-text: #c3cad4;
|
||||
|
||||
--vlan-0: oklch(0.68 0.13 290);
|
||||
--vlan-1: oklch(0.68 0.13 15);
|
||||
--vlan-2: oklch(0.68 0.13 175);
|
||||
--vlan-3: oklch(0.68 0.13 75);
|
||||
--vlan-4: oklch(0.68 0.13 240);
|
||||
--vlan-5: oklch(0.68 0.13 125);
|
||||
|
||||
--shadow-panel: 0 4px 16px rgb(0 0 0 / 0.35);
|
||||
--shadow-popover: 0 8px 24px rgb(0 0 0 / 0.45);
|
||||
--modal-overlay: rgb(8 10 14 / 0.6);
|
||||
|
||||
--status-ok: #5cb884;
|
||||
--status-warn: #f0b429;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--app-bg);
|
||||
color: var(--panel-contrast);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
@@ -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-container" style={`max-width: ${maxWidth}`}>
|
||||
<header class="modal-header">
|
||||
<slot name="header">
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
|
||||
</slot>
|
||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
|
||||
>×</button
|
||||
>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
{#if $$slots.footer}
|
||||
<footer class="modal-footer-bar">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -61,49 +70,64 @@
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-modal);
|
||||
box-shadow: 0 24px 64px rgb(15 18 24 / 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--panel-contrast);
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
border: 1px solid transparent;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted-text);
|
||||
font-size: 1.7rem;
|
||||
color: var(--text-2);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: var(--chip-bg);
|
||||
color: var(--panel-contrast);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-footer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.modal-backdrop {
|
||||
padding: 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
+116
-15
@@ -1,4 +1,4 @@
|
||||
import type { Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
|
||||
import type { CableInfo, Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
|
||||
|
||||
export type OwnerKind = 'machine' | 'device';
|
||||
|
||||
@@ -61,7 +61,11 @@ export function cloneNetworkData(data: NetworkData): NetworkData {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function findOwnerByKindAndName(data: NetworkData, kind: OwnerKind, name: string): OwnerRef | undefined {
|
||||
function findOwnerByKindAndName(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
name: string
|
||||
): OwnerRef | undefined {
|
||||
if (kind === 'machine') {
|
||||
const machine = data.machines.find((item) => equalsIgnoreCase(item.machineName, name));
|
||||
if (!machine) {
|
||||
@@ -119,9 +123,10 @@ function ensureReciprocalConnections(data: NetworkData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedReciprocal = {
|
||||
const expectedReciprocal: Port['connectedTo'] = {
|
||||
device: owner.name,
|
||||
port: port.portName
|
||||
port: port.portName,
|
||||
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
|
||||
};
|
||||
if (!targetPort.connectedTo) {
|
||||
targetPort.connectedTo = expectedReciprocal;
|
||||
@@ -132,13 +137,29 @@ function ensureReciprocalConnections(data: NetworkData) {
|
||||
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
|
||||
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
|
||||
if (hasMatchingReciprocal) {
|
||||
// keep cable metadata identical on both ends; the lexicographically
|
||||
// smaller owner:port key is the canonical copy so the sync is deterministic
|
||||
const ownKey = `${owner.name}:${port.portName}`.toLowerCase();
|
||||
const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase();
|
||||
const [from, to] =
|
||||
ownKey <= targetKey
|
||||
? [port.connectedTo, targetPort.connectedTo]
|
||||
: [targetPort.connectedTo, port.connectedTo];
|
||||
if (from.cable) {
|
||||
to.cable = deepClone(from.cable);
|
||||
} else {
|
||||
delete to.cable;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearInboundReferences(data: NetworkData, predicate: (connection: Port['connectedTo']) => boolean) {
|
||||
function clearInboundReferences(
|
||||
data: NetworkData,
|
||||
predicate: (connection: Port['connectedTo']) => boolean
|
||||
) {
|
||||
for (const owner of allOwners(data)) {
|
||||
for (const port of owner.ports) {
|
||||
if (!port.connectedTo) {
|
||||
@@ -212,7 +233,9 @@ export function renameOwner(
|
||||
}
|
||||
|
||||
if (kind === 'machine') {
|
||||
const machine = cloned.machines.find((item) => equalsIgnoreCase(item.machineName, previousName));
|
||||
const machine = cloned.machines.find((item) =>
|
||||
equalsIgnoreCase(item.machineName, previousName)
|
||||
);
|
||||
if (machine) {
|
||||
machine.machineName = nextName;
|
||||
}
|
||||
@@ -278,14 +301,15 @@ export function deleteOwner(data: NetworkData, kind: OwnerKind, ownerName: strin
|
||||
const cloned = cloneNetworkData(data);
|
||||
|
||||
if (kind === 'machine') {
|
||||
cloned.machines = cloned.machines.filter((machine) => !equalsIgnoreCase(machine.machineName, ownerName));
|
||||
cloned.machines = cloned.machines.filter(
|
||||
(machine) => !equalsIgnoreCase(machine.machineName, ownerName)
|
||||
);
|
||||
} else {
|
||||
cloned.devices = cloned.devices.filter((device) => !equalsIgnoreCase(device.name, ownerName));
|
||||
}
|
||||
|
||||
clearInboundReferences(
|
||||
cloned,
|
||||
(connection) => Boolean(connection && equalsIgnoreCase(connection.device, ownerName))
|
||||
clearInboundReferences(cloned, (connection) =>
|
||||
Boolean(connection && equalsIgnoreCase(connection.device, ownerName))
|
||||
);
|
||||
ensureReciprocalConnections(cloned);
|
||||
return cloned;
|
||||
@@ -317,9 +341,7 @@ export function deletePort(
|
||||
}
|
||||
}
|
||||
|
||||
clearInboundReferences(
|
||||
cloned,
|
||||
(connection) =>
|
||||
clearInboundReferences(cloned, (connection) =>
|
||||
Boolean(
|
||||
connection &&
|
||||
equalsIgnoreCase(connection.device, ownerName) &&
|
||||
@@ -330,6 +352,83 @@ export function deletePort(
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function renameRack(data: NetworkData, previousName: string, nextName: string): NetworkData {
|
||||
const cloned = cloneNetworkData(data);
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || equalsIgnoreCase(previousName, trimmed)) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
for (const machine of cloned.machines) {
|
||||
if (machine.rack && equalsIgnoreCase(machine.rack.name, previousName)) {
|
||||
machine.rack.name = trimmed;
|
||||
}
|
||||
}
|
||||
for (const device of cloned.devices) {
|
||||
if (device.rack && equalsIgnoreCase(device.rack.name, previousName)) {
|
||||
device.rack.name = trimmed;
|
||||
}
|
||||
}
|
||||
const definition = cloned.racks?.find((rack) => equalsIgnoreCase(rack.name, previousName));
|
||||
if (definition) {
|
||||
definition.name = trimmed;
|
||||
}
|
||||
// renaming onto an existing rack merges them; keep the first definition
|
||||
if (cloned.racks) {
|
||||
const seen = new Set<string>();
|
||||
cloned.racks = cloned.racks.filter((rack) => {
|
||||
const key = rack.name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function setPortCable(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
ownerName: string,
|
||||
portName: string,
|
||||
cable: CableInfo | undefined
|
||||
): NetworkData {
|
||||
const cloned = cloneNetworkData(data);
|
||||
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
|
||||
const port = owner ? findPort(owner, portName) : undefined;
|
||||
if (!owner || !port?.connectedTo) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const entries = Object.entries(cable ?? {}).filter(([, value]) =>
|
||||
typeof value === 'number' ? Number.isFinite(value) : Boolean(value)
|
||||
);
|
||||
const normalized = entries.length > 0 ? (Object.fromEntries(entries) as CableInfo) : undefined;
|
||||
|
||||
if (normalized) {
|
||||
port.connectedTo.cable = deepClone(normalized);
|
||||
} else {
|
||||
delete port.connectedTo.cable;
|
||||
}
|
||||
|
||||
const peerOwner = findOwnerByName(cloned, port.connectedTo.device);
|
||||
const peerPort = peerOwner ? findPort(peerOwner, port.connectedTo.port) : undefined;
|
||||
if (
|
||||
peerPort?.connectedTo &&
|
||||
equalsIgnoreCase(peerPort.connectedTo.device, owner.name) &&
|
||||
equalsIgnoreCase(peerPort.connectedTo.port, port.portName)
|
||||
) {
|
||||
if (normalized) {
|
||||
peerPort.connectedTo.cable = deepClone(normalized);
|
||||
} else {
|
||||
delete peerPort.connectedTo.cable;
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function setPortConnection(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
@@ -350,7 +449,9 @@ export function setPortConnection(
|
||||
const previousConnection = port.connectedTo;
|
||||
if (previousConnection) {
|
||||
const previousOwner = findOwnerByName(cloned, previousConnection.device);
|
||||
const previousPort = previousOwner ? findPort(previousOwner, previousConnection.port) : undefined;
|
||||
const previousPort = previousOwner
|
||||
? findPort(previousOwner, previousConnection.port)
|
||||
: undefined;
|
||||
if (previousPort) {
|
||||
delete previousPort.connectedTo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { NetworkData } from '../types';
|
||||
|
||||
export interface RackSlot {
|
||||
kind: 'machine' | 'device';
|
||||
name: string;
|
||||
index: number;
|
||||
unit: number;
|
||||
heightU: number;
|
||||
iconKey?: string;
|
||||
subtitle: string;
|
||||
/** Horizontal lane when several items share the same U (e.g. on a shelf). */
|
||||
laneIndex: number;
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
export interface RackColumn {
|
||||
name: string;
|
||||
heightU: number;
|
||||
declared: boolean;
|
||||
declaredHeightU?: number;
|
||||
slots: RackSlot[];
|
||||
}
|
||||
|
||||
export interface UnrackedEntity {
|
||||
kind: 'machine' | 'device';
|
||||
name: string;
|
||||
iconKey?: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export interface RackLayout {
|
||||
racks: RackColumn[];
|
||||
warnings: string[];
|
||||
unrackedCount: number;
|
||||
unracked: UnrackedEntity[];
|
||||
}
|
||||
|
||||
const minimumRackHeightU = 12;
|
||||
|
||||
function slotsOverlap(a: RackSlot, b: RackSlot): boolean {
|
||||
const aTop = a.unit + a.heightU - 1;
|
||||
const bTop = b.unit + b.heightU - 1;
|
||||
return a.unit <= bTop && b.unit <= aTop;
|
||||
}
|
||||
|
||||
function sameRange(a: RackSlot, b: RackSlot): boolean {
|
||||
return a.unit === b.unit && a.heightU === b.heightU;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns horizontal lanes so overlapping items render side by side.
|
||||
* Items occupying the exact same U range are treated as sharing a shelf and
|
||||
* do not warn; partially overlapping ranges are reported as conflicts.
|
||||
*/
|
||||
function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) {
|
||||
// connected components of the overlap graph
|
||||
const componentOf = new Map<RackSlot, number>();
|
||||
let componentCount = 0;
|
||||
for (const slot of slots) {
|
||||
const touching = slots.filter((other) => componentOf.has(other) && slotsOverlap(slot, other));
|
||||
const componentIds = new Set(touching.map((other) => componentOf.get(other) ?? 0));
|
||||
if (componentIds.size === 0) {
|
||||
componentOf.set(slot, componentCount);
|
||||
componentCount += 1;
|
||||
continue;
|
||||
}
|
||||
const [target] = [...componentIds].sort((a, b) => a - b);
|
||||
componentOf.set(slot, target);
|
||||
for (const [other, id] of componentOf.entries()) {
|
||||
if (componentIds.has(id)) {
|
||||
componentOf.set(other, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentSlots = new Map<number, RackSlot[]>();
|
||||
for (const slot of slots) {
|
||||
const id = componentOf.get(slot) ?? 0;
|
||||
const members = componentSlots.get(id) ?? [];
|
||||
members.push(slot);
|
||||
componentSlots.set(id, members);
|
||||
}
|
||||
|
||||
for (const members of componentSlots.values()) {
|
||||
const ordered = [...members].sort((a, b) => a.unit - b.unit || a.name.localeCompare(b.name));
|
||||
const lanes: RackSlot[][] = [];
|
||||
for (const slot of ordered) {
|
||||
let placed = false;
|
||||
for (const [laneIndex, lane] of lanes.entries()) {
|
||||
if (!lane.some((other) => slotsOverlap(slot, other))) {
|
||||
lane.push(slot);
|
||||
slot.laneIndex = laneIndex;
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
slot.laneIndex = lanes.length;
|
||||
lanes.push([slot]);
|
||||
}
|
||||
}
|
||||
for (const slot of members) {
|
||||
slot.laneCount = lanes.length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
for (let j = i + 1; j < ordered.length; j += 1) {
|
||||
const a = ordered[i];
|
||||
const b = ordered[j];
|
||||
if (slotsOverlap(a, b) && !sameRange(a, b)) {
|
||||
warnings.push(
|
||||
`Rack "${rackName}": "${a.name}" (U${a.unit}–U${a.unit + a.heightU - 1}) partially overlaps "${b.name}" (U${b.unit}–U${b.unit + b.heightU - 1}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRackLayout(data: NetworkData): RackLayout {
|
||||
const slotsByRack = new Map<string, RackSlot[]>();
|
||||
const canonicalNames = new Map<string, string>();
|
||||
const unracked: UnrackedEntity[] = [];
|
||||
|
||||
const canonical = (rawName: string): string => {
|
||||
const trimmed = rawName.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
const existing = canonicalNames.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
canonicalNames.set(key, trimmed);
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
for (const definition of data.racks ?? []) {
|
||||
const name = canonical(definition.name);
|
||||
if (!slotsByRack.has(name)) {
|
||||
slotsByRack.set(name, []);
|
||||
}
|
||||
}
|
||||
|
||||
const place = (
|
||||
slot: Omit<RackSlot, 'heightU' | 'laneIndex' | 'laneCount'> & { heightU?: number },
|
||||
rackName: string
|
||||
) => {
|
||||
const name = canonical(rackName);
|
||||
const slots = slotsByRack.get(name) ?? [];
|
||||
slots.push({
|
||||
...slot,
|
||||
heightU: Math.max(1, Math.round(slot.heightU ?? 1)),
|
||||
laneIndex: 0,
|
||||
laneCount: 1
|
||||
});
|
||||
slotsByRack.set(name, slots);
|
||||
};
|
||||
|
||||
for (const [index, machine] of data.machines.entries()) {
|
||||
if (!machine.rack?.name) {
|
||||
unracked.push({
|
||||
kind: 'machine',
|
||||
name: machine.machineName,
|
||||
iconKey: machine.iconKey,
|
||||
subtitle: machine.role
|
||||
});
|
||||
continue;
|
||||
}
|
||||
place(
|
||||
{
|
||||
kind: 'machine',
|
||||
name: machine.machineName,
|
||||
index,
|
||||
unit: machine.rack.unit,
|
||||
heightU: machine.rack.heightU,
|
||||
iconKey: machine.iconKey,
|
||||
subtitle: machine.role
|
||||
},
|
||||
machine.rack.name
|
||||
);
|
||||
}
|
||||
for (const [index, device] of data.devices.entries()) {
|
||||
if (!device.rack?.name) {
|
||||
unracked.push({
|
||||
kind: 'device',
|
||||
name: device.name,
|
||||
iconKey: device.iconKey,
|
||||
subtitle: device.type
|
||||
});
|
||||
continue;
|
||||
}
|
||||
place(
|
||||
{
|
||||
kind: 'device',
|
||||
name: device.name,
|
||||
index,
|
||||
unit: device.rack.unit,
|
||||
heightU: device.rack.heightU,
|
||||
iconKey: device.iconKey,
|
||||
subtitle: device.type
|
||||
},
|
||||
device.rack.name
|
||||
);
|
||||
}
|
||||
|
||||
const declaredByName = new Map(
|
||||
(data.racks ?? []).map((definition) => [definition.name.trim().toLowerCase(), definition])
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const racks: RackColumn[] = [...slotsByRack.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, slots]) => {
|
||||
const sorted = [...slots].sort((a, b) => b.unit - a.unit || a.name.localeCompare(b.name));
|
||||
assignLanes(sorted, name, warnings);
|
||||
const topUnit =
|
||||
sorted.length > 0 ? Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)) : 0;
|
||||
const definition = declaredByName.get(name.toLowerCase());
|
||||
const declaredHeightU = definition?.heightU;
|
||||
if (typeof declaredHeightU === 'number' && topUnit > declaredHeightU) {
|
||||
warnings.push(
|
||||
`Rack "${name}" is declared as ${declaredHeightU}U but items reach U${topUnit}.`
|
||||
);
|
||||
}
|
||||
const heightU =
|
||||
typeof declaredHeightU === 'number'
|
||||
? Math.max(declaredHeightU, topUnit)
|
||||
: Math.max(minimumRackHeightU, topUnit);
|
||||
return {
|
||||
name,
|
||||
heightU,
|
||||
declared: Boolean(definition),
|
||||
declaredHeightU,
|
||||
slots: sorted
|
||||
};
|
||||
});
|
||||
|
||||
return { racks, warnings, unrackedCount: unracked.length, unracked };
|
||||
}
|
||||
|
||||
export function knownRackNames(data: NetworkData): string[] {
|
||||
const names = new Map<string, string>();
|
||||
const add = (name: string | undefined) => {
|
||||
const trimmed = name?.trim();
|
||||
if (trimmed && !names.has(trimmed.toLowerCase())) {
|
||||
names.set(trimmed.toLowerCase(), trimmed);
|
||||
}
|
||||
};
|
||||
for (const definition of data.racks ?? []) {
|
||||
add(definition.name);
|
||||
}
|
||||
for (const machine of data.machines) {
|
||||
add(machine.rack?.name);
|
||||
}
|
||||
for (const device of data.devices) {
|
||||
add(device.rack?.name);
|
||||
}
|
||||
return [...names.values()].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Reads the graph-facing design tokens off the document root so the
|
||||
// Cytoscape stylesheet (canvas-drawn edges) always matches the CSS theme.
|
||||
// The --graph-* custom properties are plain hex on purpose: Cytoscape's
|
||||
// colour parser doesn't understand oklch() or var().
|
||||
export interface CanvasTokens {
|
||||
bgCanvas: string;
|
||||
edge: string;
|
||||
border: string;
|
||||
chipBg: string;
|
||||
chipText: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const fallbacks: CanvasTokens = {
|
||||
bgCanvas: '#eef0f3',
|
||||
edge: '#8b94a3',
|
||||
border: '#dce0e6',
|
||||
chipBg: '#ffffff',
|
||||
chipText: '#3a4250',
|
||||
accent: '#3565c4'
|
||||
};
|
||||
|
||||
export function readCanvasTokens(): CanvasTokens {
|
||||
if (typeof document === 'undefined') {
|
||||
return fallbacks;
|
||||
}
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
|
||||
return {
|
||||
bgCanvas: read('--bg-canvas', fallbacks.bgCanvas),
|
||||
edge: read('--graph-edge', fallbacks.edge),
|
||||
border: read('--border', fallbacks.border),
|
||||
chipBg: read('--graph-edge-chip-bg', fallbacks.chipBg),
|
||||
chipText: read('--graph-edge-chip-text', fallbacks.chipText),
|
||||
accent: read('--accent', fallbacks.accent)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { GraphNodeDetails, GraphNodeElement, GraphTransformResult } from './types';
|
||||
|
||||
function collectSearchableText(node: GraphNodeElement): string[] {
|
||||
const texts: string[] = [node.data.rawName ?? node.data.label];
|
||||
const details = node.data.details as GraphNodeDetails | undefined;
|
||||
if (!details) {
|
||||
return texts;
|
||||
}
|
||||
|
||||
texts.push(details.ip);
|
||||
if (details.type === 'machine') {
|
||||
texts.push(details.role, details.os);
|
||||
for (const vm of details.vms) {
|
||||
texts.push(vm.name, vm.ip, vm.role);
|
||||
}
|
||||
} else if (details.type === 'device') {
|
||||
texts.push(details.deviceType);
|
||||
} else {
|
||||
texts.push(details.role);
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of nodes matching the query (case-insensitive substring on
|
||||
* name, IP, role, OS and device type). When a VM matches, its host machine ID
|
||||
* is included too so hits remain visible while the host's VMs are collapsed.
|
||||
*/
|
||||
export function computeSearchMatches(
|
||||
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||
query: string
|
||||
): Set<string> {
|
||||
const matches = new Set<string>();
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return matches;
|
||||
}
|
||||
|
||||
for (const node of transformed.nodes) {
|
||||
const isMatch = collectSearchableText(node).some((text) =>
|
||||
text.toLowerCase().includes(normalized)
|
||||
);
|
||||
if (!isMatch) {
|
||||
continue;
|
||||
}
|
||||
matches.add(node.data.id);
|
||||
if (node.data.kind === 'vm' && node.data.hostMachineId) {
|
||||
matches.add(node.data.hostMachineId);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered variant used for Enter/Shift-Enter cycling: node IDs in graph
|
||||
* order, deduped, with VM hits mapped to their host so the camera can land
|
||||
* on something visible while the host's VMs are collapsed.
|
||||
*/
|
||||
export function computeSearchMatchList(
|
||||
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||
query: string
|
||||
): string[] {
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
for (const node of transformed.nodes) {
|
||||
const isMatch = collectSearchableText(node).some((text) =>
|
||||
text.toLowerCase().includes(normalized)
|
||||
);
|
||||
if (!isMatch) {
|
||||
continue;
|
||||
}
|
||||
const ids =
|
||||
node.data.kind === 'vm' && node.data.hostMachineId
|
||||
? [node.data.id, node.data.hostMachineId]
|
||||
: [node.data.id];
|
||||
for (const id of ids) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ordered.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { ConnectedPortRef, NetworkData, Port } from '../types';
|
||||
import type { ConnectedPortRef, NetworkData, Port, Subnet } from '../types';
|
||||
import { cidrContains, parseCidr } from '../data/ipam';
|
||||
import { resolveIconPath } from '../config/iconRegistry';
|
||||
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
|
||||
import { buildVlanIndexMap } from './vlanPalette';
|
||||
import {
|
||||
NODE_DIMENSIONS,
|
||||
estimatePillWidth,
|
||||
type DeviceClass,
|
||||
type GraphEdgeElement,
|
||||
type GraphNodeElement,
|
||||
type GraphTransformResult
|
||||
} from './types';
|
||||
|
||||
interface EndpointOwner {
|
||||
kind: 'machine' | 'device';
|
||||
@@ -55,6 +64,55 @@ function connectionKey(a: string, b: string): string {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
}
|
||||
|
||||
const cableColorHex: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
purple: '#a855f7',
|
||||
pink: '#ec4899',
|
||||
white: '#e2e8f0',
|
||||
grey: '#94a3b8',
|
||||
gray: '#94a3b8',
|
||||
black: '#334155'
|
||||
};
|
||||
|
||||
function resolveCableColor(name: string | undefined): string | undefined {
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
return (
|
||||
cableColorHex[trimmed.toLowerCase()] ??
|
||||
(/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function buildVlanResolver(subnets: Subnet[] | undefined) {
|
||||
const vlanSubnets = (subnets ?? [])
|
||||
.filter((subnet) => typeof subnet.vlanId === 'number')
|
||||
.sort((a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0));
|
||||
const vlanIndexById = buildVlanIndexMap(subnets);
|
||||
return (ip: string): { vlanId: number; vlanIndex: number } | undefined => {
|
||||
const home = vlanSubnets.find((subnet) => cidrContains(subnet.cidr, ip));
|
||||
if (!home || typeof home.vlanId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return { vlanId: home.vlanId, vlanIndex: vlanIndexById.get(home.vlanId) ?? 0 };
|
||||
};
|
||||
}
|
||||
|
||||
// Devices whose type reads as network infrastructure get the dark "infra"
|
||||
// treatment on the map; everything else (consoles, peripherals, UPSes) is an
|
||||
// unmanaged "dumb" device rendered as a dashed pill.
|
||||
const infrastructureTypePattern =
|
||||
/switch|router|firewall|gateway|access\s?point|\bap\b|wan|isp|modem|dns|patch\s?panel/i;
|
||||
|
||||
export function classifyDevice(type: string | undefined): DeviceClass {
|
||||
return type && infrastructureTypePattern.test(type) ? 'infrastructure' : 'dumb';
|
||||
}
|
||||
|
||||
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Math.min(a, b);
|
||||
@@ -82,6 +140,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
> = {};
|
||||
const ownersByLookup = new Map<string, EndpointOwner>();
|
||||
const seenWarnings = new Set<string>();
|
||||
const resolveVlan = buildVlanResolver(data.subnets);
|
||||
|
||||
const warn = (message: string) => {
|
||||
if (!seenWarnings.has(message)) {
|
||||
@@ -110,7 +169,8 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
const machineVms = sourceMachineVms.map((vm) => ({
|
||||
name: vm.name,
|
||||
ip: vm.ipAddress,
|
||||
role: vm.role
|
||||
role: vm.role,
|
||||
macAddress: vm.macAddress
|
||||
}));
|
||||
|
||||
nodes.push({
|
||||
@@ -121,8 +181,11 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
kind: 'machine',
|
||||
iconKey: machine.iconKey,
|
||||
iconUrl: resolveIconPath(machine.iconKey),
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 110,
|
||||
nodeWidth: NODE_DIMENSIONS.machine.width,
|
||||
nodeHeight: NODE_DIMENSIONS.machine.height,
|
||||
meta: machine.ipAddress,
|
||||
vmCount: sourceMachineVms.length,
|
||||
...resolveVlan(machine.ipAddress),
|
||||
details: {
|
||||
type: 'machine',
|
||||
name: machine.machineName,
|
||||
@@ -133,6 +196,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
cpu: machine.hardware?.cpu ?? 'Unknown',
|
||||
ram: machine.hardware?.ram ?? 'Unknown',
|
||||
gpu: machine.hardware?.gpu ?? undefined,
|
||||
notes: machine.notes,
|
||||
ports: machinePortsList,
|
||||
vmCount: machineVms.length,
|
||||
vms: machineVms
|
||||
@@ -158,14 +222,17 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
hostMachineId: machineNodeId,
|
||||
iconKey: vm.iconKey,
|
||||
iconUrl: resolveIconPath(vm.iconKey),
|
||||
nodeWidth: 140,
|
||||
nodeHeight: 66,
|
||||
nodeWidth: NODE_DIMENSIONS.vm.width,
|
||||
nodeHeight: NODE_DIMENSIONS.vm.height,
|
||||
meta: vm.ipAddress,
|
||||
...resolveVlan(vm.ipAddress),
|
||||
details: {
|
||||
type: 'vm',
|
||||
name: vm.name,
|
||||
iconKey: vm.iconKey,
|
||||
ip: vm.ipAddress,
|
||||
role: vm.role,
|
||||
macAddress: vm.macAddress,
|
||||
hostName: machine.machineName
|
||||
}
|
||||
}
|
||||
@@ -178,8 +245,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
id: hostingEdgeId,
|
||||
source: machineNodeId,
|
||||
target: vmNodeId,
|
||||
kind: 'hosting',
|
||||
label: 'hosts'
|
||||
kind: 'hosting'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -203,6 +269,15 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
for (const device of data.devices) {
|
||||
const deviceNodeId = toDeviceNodeId(device.name);
|
||||
const devicePorts = [...(device.ports ?? [])];
|
||||
const deviceClass = classifyDevice(device.type);
|
||||
const portsInUse = devicePorts.filter((port) => parseConnectedTo(port.connectedTo)).length;
|
||||
let deviceMeta: string | undefined;
|
||||
if (deviceClass === 'infrastructure') {
|
||||
deviceMeta =
|
||||
devicePorts.length > 0
|
||||
? `${devicePorts.length} ${devicePorts.length === 1 ? 'port' : 'ports'} · ${portsInUse} in use`
|
||||
: device.ipAddress;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
data: {
|
||||
@@ -210,10 +285,19 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
label: device.name,
|
||||
rawName: device.name,
|
||||
kind: 'device',
|
||||
deviceClass,
|
||||
iconKey: device.iconKey,
|
||||
iconUrl: resolveIconPath(device.iconKey),
|
||||
nodeWidth: 176,
|
||||
nodeHeight: 116,
|
||||
nodeWidth:
|
||||
deviceClass === 'infrastructure'
|
||||
? NODE_DIMENSIONS.infrastructure.width
|
||||
: estimatePillWidth(device.name),
|
||||
nodeHeight:
|
||||
deviceClass === 'infrastructure'
|
||||
? NODE_DIMENSIONS.infrastructure.height
|
||||
: NODE_DIMENSIONS.dumbHeight,
|
||||
...(deviceMeta ? { meta: deviceMeta } : {}),
|
||||
...resolveVlan(device.ipAddress),
|
||||
details: {
|
||||
type: 'device',
|
||||
name: device.name,
|
||||
@@ -293,6 +377,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
|
||||
physicalEdgeCounter += 1;
|
||||
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
|
||||
const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable;
|
||||
|
||||
// One chip per cable: interface name (prefer the machine side —
|
||||
// "eth0" reads better than "port 3") plus speed, with the network
|
||||
// default of 1GbE omitted so only exceptions are informative.
|
||||
const ifname =
|
||||
owner.kind !== 'machine' && targetOwner.kind === 'machine'
|
||||
? connectedTo.port
|
||||
: port.portName;
|
||||
const chipLabelNoSpeed = ifname;
|
||||
const chipLabel =
|
||||
typeof speedGbps === 'number' && speedGbps !== 1 ? `${ifname} · ${speedGbps}GbE` : ifname;
|
||||
|
||||
edges.push({
|
||||
data: {
|
||||
@@ -300,11 +396,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
||||
source: owner.nodeId,
|
||||
target: targetOwner.nodeId,
|
||||
kind: 'physical',
|
||||
label: speedGbps ? `${speedGbps}GbE` : undefined,
|
||||
chipLabel,
|
||||
chipLabelNoSpeed,
|
||||
sourcePort: port.portName,
|
||||
targetPort: connectedTo.port,
|
||||
speedGbps,
|
||||
reciprocal
|
||||
reciprocal,
|
||||
...(cable?.type ? { cableType: cable.type } : {}),
|
||||
...(cable?.color ? { cableColorName: cable.color } : {}),
|
||||
...(resolveCableColor(cable?.color)
|
||||
? { cableColor: resolveCableColor(cable?.color) }
|
||||
: {}),
|
||||
...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
export type GraphNodeKind = 'machine' | 'device' | 'vm';
|
||||
export type GraphEdgeKind = 'physical' | 'hosting';
|
||||
export type DeviceClass = 'infrastructure' | 'dumb';
|
||||
|
||||
// Hit-target dimensions for the invisible Cytoscape nodes. The HTML card
|
||||
// layer sizes its cards from the same constants so edges anchor exactly at
|
||||
// card borders.
|
||||
export const NODE_DIMENSIONS = {
|
||||
machine: { width: 218, height: 56 },
|
||||
infrastructure: { width: 218, height: 56 },
|
||||
vm: { width: 170, height: 44 },
|
||||
dumbHeight: 36
|
||||
} as const;
|
||||
|
||||
// Content-hugging pills need a width so edges anchor at the card border:
|
||||
// 24px icon tile + gaps/padding ≈ 54px, plus the rendered text width.
|
||||
// Text is measured with a canvas context (matching the pill's 12.5px type);
|
||||
// the char-count estimate is only an SSR/test fallback.
|
||||
let pillMeasureContext: CanvasRenderingContext2D | null = null;
|
||||
|
||||
export function estimatePillWidth(name: string): number {
|
||||
let textWidth = name.length * 7.6;
|
||||
if (typeof document !== 'undefined') {
|
||||
pillMeasureContext ??= document.createElement('canvas').getContext('2d');
|
||||
if (pillMeasureContext) {
|
||||
pillMeasureContext.font =
|
||||
'600 12.5px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
||||
textWidth = pillMeasureContext.measureText(name).width;
|
||||
}
|
||||
}
|
||||
return Math.round(Math.min(300, Math.max(110, textWidth + 60)));
|
||||
}
|
||||
|
||||
export interface PortDetails {
|
||||
portName: string;
|
||||
speedGbps?: number;
|
||||
macAddress?: string;
|
||||
connectedTo?: {
|
||||
device: string;
|
||||
port: string;
|
||||
cable?: {
|
||||
type?: string;
|
||||
color?: string;
|
||||
lengthM?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,12 +56,14 @@ export interface MachineDetails {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
gpu?: string;
|
||||
notes?: string;
|
||||
ports: PortDetails[];
|
||||
vmCount: number;
|
||||
vms: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
role: string;
|
||||
macAddress?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -45,6 +83,7 @@ export interface VmDetails {
|
||||
iconKey?: string;
|
||||
ip: string;
|
||||
role: string;
|
||||
macAddress?: string;
|
||||
hostName: string;
|
||||
}
|
||||
|
||||
@@ -54,12 +93,17 @@ export interface GraphNodeData {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: GraphNodeKind;
|
||||
deviceClass?: DeviceClass;
|
||||
rawName?: string;
|
||||
hostMachineId?: string;
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
iconUrl?: string;
|
||||
iconKey?: string;
|
||||
vlanId?: number;
|
||||
vlanIndex?: number;
|
||||
meta?: string;
|
||||
vmCount?: number;
|
||||
details?: GraphNodeDetails;
|
||||
}
|
||||
|
||||
@@ -69,10 +113,16 @@ export interface GraphEdgeData {
|
||||
target: string;
|
||||
kind: GraphEdgeKind;
|
||||
label?: string;
|
||||
chipLabel?: string;
|
||||
chipLabelNoSpeed?: string;
|
||||
sourcePort?: string;
|
||||
targetPort?: string;
|
||||
speedGbps?: number;
|
||||
reciprocal?: boolean;
|
||||
cableType?: string;
|
||||
cableColorName?: string;
|
||||
cableColor?: string;
|
||||
cableLengthM?: number;
|
||||
}
|
||||
|
||||
export interface GraphNodeElement {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Subnet } from '$lib/types';
|
||||
|
||||
// Categorical VLAN palette from the UX review: equal lightness/chroma
|
||||
// (oklch 0.55/0.13 light, 0.68/0.13 dark), hue varies, so no VLAN shouts
|
||||
// louder than another. The actual oklch values live in app.css as
|
||||
// --vlan-0..--vlan-5 (light + dark variants); consumers are all HTML/CSS,
|
||||
// so we hand out var() references and let the theme resolve them.
|
||||
export const VLAN_PALETTE_SIZE = 6;
|
||||
|
||||
export function vlanPaletteColor(index: number): string {
|
||||
const slot = ((Math.trunc(index) % VLAN_PALETTE_SIZE) + VLAN_PALETTE_SIZE) % VLAN_PALETTE_SIZE;
|
||||
return `var(--vlan-${slot})`;
|
||||
}
|
||||
|
||||
// VLANs are assigned palette slots in declaration order (first appearance in
|
||||
// data.subnets), not by vlanId, so the first VLAN a user declares always gets
|
||||
// the first hue.
|
||||
export function buildVlanIndexMap(subnets: Subnet[] | undefined): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const subnet of subnets ?? []) {
|
||||
const { vlanId } = subnet;
|
||||
if (typeof vlanId !== 'number' || !Number.isFinite(vlanId)) {
|
||||
continue;
|
||||
}
|
||||
if (!map.has(vlanId)) {
|
||||
map.set(vlanId, map.size);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Legacy hex palette, still consumed by the pre-redesign Cytoscape node
|
||||
// border styling; removed once the HTML card layer takes over VLAN colour.
|
||||
const legacyPalette = [
|
||||
'#e11d48',
|
||||
'#7c3aed',
|
||||
'#0891b2',
|
||||
'#ca8a04',
|
||||
'#15803d',
|
||||
'#c2410c',
|
||||
'#4f46e5',
|
||||
'#0d9488',
|
||||
'#be185d',
|
||||
'#57534e'
|
||||
];
|
||||
|
||||
export function vlanColor(vlanId: number): string {
|
||||
return legacyPalette[Math.abs(Math.trunc(vlanId)) % legacyPalette.length];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
checkWritableState as checkWritableStateCore,
|
||||
getWritableState as getWritableStateCore,
|
||||
isWriteEnabled as isWriteEnabledCore,
|
||||
readNetworkFile as readNetworkFileCore,
|
||||
writeNetworkFile as writeNetworkFileCore
|
||||
@@ -12,6 +13,11 @@ export interface NetworkFileMetadata {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WritableState {
|
||||
writable: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export function isWriteEnabled(): boolean {
|
||||
return isWriteEnabledCore();
|
||||
}
|
||||
@@ -24,6 +30,10 @@ export async function writeNetworkFile(data: NetworkData): Promise<NetworkFileMe
|
||||
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
||||
}
|
||||
|
||||
export async function getWritableState(): Promise<WritableState> {
|
||||
return (await getWritableStateCore()) as WritableState;
|
||||
}
|
||||
|
||||
export async function checkWritableState(): Promise<boolean> {
|
||||
return checkWritableStateCore();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import path from 'node:path';
|
||||
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
||||
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
||||
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
||||
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
||||
*/
|
||||
|
||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||
@@ -43,6 +44,38 @@ export function isWriteEnabled() {
|
||||
return !NETWORK_READ_ONLY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} error
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatFileSystemErrorCode(error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
|
||||
return error.code;
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} directory
|
||||
* @param {string} label
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function assertDirectoryWritable(directory, label) {
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
} catch (error) {
|
||||
return `${label} directory "${directory}" could not be created (${formatFileSystemErrorCode(error)}).`;
|
||||
}
|
||||
|
||||
try {
|
||||
await access(directory, constants.W_OK);
|
||||
} catch (error) {
|
||||
return `${label} directory "${directory}" is not writable (${formatFileSystemErrorCode(error)}).`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataFilePath
|
||||
* @returns {Promise<string>}
|
||||
@@ -151,20 +184,63 @@ export async function writeNetworkFile(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
* @returns {Promise<WritableState>}
|
||||
*/
|
||||
export async function checkWritableState() {
|
||||
export async function getWritableState() {
|
||||
if (!isWriteEnabled()) {
|
||||
return false;
|
||||
return {
|
||||
writable: false,
|
||||
reason: 'Writes disabled by NETWORK_READ_ONLY=true.'
|
||||
};
|
||||
}
|
||||
|
||||
const source = resolveDataFilePath();
|
||||
const directory = path.dirname(source);
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await access(directory, constants.W_OK);
|
||||
} catch {
|
||||
return false;
|
||||
const dataDirectoryError = await assertDirectoryWritable(directory, 'Data');
|
||||
if (dataDirectoryError) {
|
||||
return {
|
||||
writable: false,
|
||||
reason: dataDirectoryError
|
||||
};
|
||||
}
|
||||
return true;
|
||||
|
||||
const backupDirectory = resolveBackupDirectory();
|
||||
const backupDirectoryError = await assertDirectoryWritable(backupDirectory, 'Backup');
|
||||
if (backupDirectoryError) {
|
||||
return {
|
||||
writable: false,
|
||||
reason: backupDirectoryError
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await access(source, constants.F_OK);
|
||||
} catch {
|
||||
return {
|
||||
writable: true,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 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 {{ device: string; port: string }} ConnectedTo
|
||||
* @typedef {{ portName: string; speedGbps?: number; connectedTo?: ConnectedTo }} Port
|
||||
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string }} VM
|
||||
* @typedef {{ type?: string; color?: string; lengthM?: number }} CableInfo
|
||||
* @typedef {{ device: string; port: string; cable?: CableInfo }} ConnectedTo
|
||||
* @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port
|
||||
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM
|
||||
* @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware
|
||||
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine
|
||||
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice
|
||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[] }} NetworkData
|
||||
* @typedef {{ name: string; unit: number; heightU?: number }} RackPlacement
|
||||
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[]; rack?: RackPlacement }} Machine
|
||||
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[]; rack?: RackPlacement }} NetworkDevice
|
||||
* @typedef {{ cidr: string; name?: string; vlanId?: number }} Subnet
|
||||
* @typedef {{ name: string; heightU?: number }} RackDefinition
|
||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[]; racks?: RackDefinition[] }} NetworkData
|
||||
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
||||
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
||||
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
||||
@@ -105,6 +109,10 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
||||
optional: true,
|
||||
min: 0
|
||||
});
|
||||
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
|
||||
let connectedTo;
|
||||
if (value.connectedTo !== undefined) {
|
||||
@@ -113,8 +121,41 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
||||
} else {
|
||||
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
|
||||
const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port);
|
||||
let cable;
|
||||
const cableRaw = value.connectedTo.cable;
|
||||
if (cableRaw !== undefined) {
|
||||
if (!isRecord(cableRaw)) {
|
||||
issues.push({ path: `${path}.connectedTo.cable`, message: 'must be an object' });
|
||||
} else {
|
||||
const cableType = readString(issues, `${path}.connectedTo.cable.type`, cableRaw.type, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
const cableColor = readString(issues, `${path}.connectedTo.cable.color`, cableRaw.color, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
const lengthM = readNumber(
|
||||
issues,
|
||||
`${path}.connectedTo.cable.lengthM`,
|
||||
cableRaw.lengthM,
|
||||
{
|
||||
optional: true,
|
||||
min: 0
|
||||
}
|
||||
);
|
||||
const candidate = {
|
||||
...(cableType ? { type: cableType } : {}),
|
||||
...(cableColor ? { color: cableColor } : {}),
|
||||
...(typeof lengthM === 'number' ? { lengthM } : {})
|
||||
};
|
||||
if (Object.keys(candidate).length > 0) {
|
||||
cable = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (device && port) {
|
||||
connectedTo = { device, port };
|
||||
connectedTo = { device, port, ...(cable ? { cable } : {}) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,10 +167,38 @@ export function normalizePort(issues, path, value, ownerLabel) {
|
||||
return {
|
||||
portName,
|
||||
...(typeof speedGbps === 'number' ? { speedGbps } : {}),
|
||||
...(macAddress ? { macAddress } : {}),
|
||||
...(connectedTo ? { connectedTo } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ValidationIssue[]} issues
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {RackPlacement | undefined}
|
||||
*/
|
||||
function normalizeRack(issues, path, value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
issues.push({ path, message: 'must be an object (rack placement)' });
|
||||
return undefined;
|
||||
}
|
||||
const name = readString(issues, `${path}.name`, value.name);
|
||||
const unit = readNumber(issues, `${path}.unit`, value.unit, { min: 1 });
|
||||
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||
if (!name || typeof unit !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
unit,
|
||||
...(typeof heightU === 'number' ? { heightU } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ValidationIssue[]} issues
|
||||
* @param {PortEntry[]} portEntries
|
||||
@@ -166,6 +235,10 @@ export function normalizeVm(issues, path, value) {
|
||||
const role = readString(issues, `${path}.role`, value.role);
|
||||
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
|
||||
if (!name || !role || !ipAddress) {
|
||||
return null;
|
||||
@@ -175,7 +248,8 @@ export function normalizeVm(issues, path, value) {
|
||||
name,
|
||||
role,
|
||||
ipAddress,
|
||||
...(iconKey ? { iconKey } : {})
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(macAddress ? { macAddress } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +270,11 @@ function normalizeMachine(issues, path, value) {
|
||||
const role = readString(issues, `${path}.role`, value.role);
|
||||
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
|
||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||
const notes = readString(issues, `${path}.notes`, value.notes, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||
|
||||
const softwareRaw = value.software;
|
||||
if (!isRecord(softwareRaw)) {
|
||||
@@ -233,8 +312,16 @@ function normalizeMachine(issues, path, value) {
|
||||
issues.push({ path: `${path}.hardware`, message: 'must be an object' });
|
||||
}
|
||||
const hardware = {
|
||||
cpu: readString(issues, `${path}.hardware.cpu`, isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined),
|
||||
ram: readString(issues, `${path}.hardware.ram`, isRecord(hardwareRaw) ? hardwareRaw.ram : undefined),
|
||||
cpu: readString(
|
||||
issues,
|
||||
`${path}.hardware.cpu`,
|
||||
isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined
|
||||
),
|
||||
ram: readString(
|
||||
issues,
|
||||
`${path}.hardware.ram`,
|
||||
isRecord(hardwareRaw) ? hardwareRaw.ram : undefined
|
||||
),
|
||||
networkPorts: readNumber(
|
||||
issues,
|
||||
`${path}.hardware.networkPorts`,
|
||||
@@ -247,10 +334,15 @@ function normalizeMachine(issues, path, value) {
|
||||
isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined,
|
||||
{ optional: true, min: 0 }
|
||||
),
|
||||
gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, {
|
||||
gpu: readString(
|
||||
issues,
|
||||
`${path}.hardware.gpu`,
|
||||
isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined,
|
||||
{
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
})
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
const portsRaw = value.ports;
|
||||
@@ -287,6 +379,8 @@ function normalizeMachine(issues, path, value) {
|
||||
role,
|
||||
operatingSystem,
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
...(rack ? { rack } : {}),
|
||||
software: { vms },
|
||||
hardware: {
|
||||
cpu: hardware.cpu,
|
||||
@@ -316,8 +410,12 @@ function normalizeDevice(issues, path, value) {
|
||||
const name = readString(issues, `${path}.name`, value.name);
|
||||
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||
const type = readString(issues, `${path}.type`, value.type);
|
||||
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
|
||||
const notes = readString(issues, `${path}.notes`, value.notes, {
|
||||
optional: true,
|
||||
allowEmpty: true
|
||||
});
|
||||
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||
|
||||
const portsRaw = value.ports;
|
||||
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
|
||||
@@ -345,10 +443,84 @@ function normalizeDevice(issues, path, value) {
|
||||
type,
|
||||
...(iconKey ? { iconKey } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
...(rack ? { rack } : {}),
|
||||
ports
|
||||
};
|
||||
}
|
||||
|
||||
const ipv4CidrPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/;
|
||||
|
||||
/**
|
||||
* @param {string} cidr
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidIpv4Cidr(cidr) {
|
||||
const match = ipv4CidrPattern.exec(cidr);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const octets = match.slice(1, 5).map(Number);
|
||||
const prefix = Number(match[5]);
|
||||
return octets.every((octet) => octet <= 255) && prefix <= 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ValidationIssue[]} issues
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {Subnet | null}
|
||||
*/
|
||||
function normalizeSubnet(issues, path, value) {
|
||||
if (!isRecord(value)) {
|
||||
issues.push({ path, message: 'must be an object (subnet)' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const cidr = readString(issues, `${path}.cidr`, value.cidr);
|
||||
if (cidr && !isValidIpv4Cidr(cidr)) {
|
||||
issues.push({ path: `${path}.cidr`, message: 'must be an IPv4 CIDR like "192.168.1.0/24"' });
|
||||
return null;
|
||||
}
|
||||
const name = readString(issues, `${path}.name`, value.name, { optional: true, allowEmpty: true });
|
||||
const vlanId = readNumber(issues, `${path}.vlanId`, value.vlanId, { optional: true, min: 1 });
|
||||
if (typeof vlanId === 'number' && vlanId > 4094) {
|
||||
issues.push({ path: `${path}.vlanId`, message: 'must be <= 4094' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!cidr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cidr,
|
||||
...(name ? { name } : {}),
|
||||
...(typeof vlanId === 'number' ? { vlanId } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ValidationIssue[]} issues
|
||||
* @param {string} path
|
||||
* @param {unknown} value
|
||||
* @returns {RackDefinition | null}
|
||||
*/
|
||||
function normalizeRackDefinition(issues, path, value) {
|
||||
if (!isRecord(value)) {
|
||||
issues.push({ path, message: 'must be an object (rack)' });
|
||||
return null;
|
||||
}
|
||||
const name = readString(issues, `${path}.name`, value.name);
|
||||
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
...(typeof heightU === 'number' ? { heightU } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {ValidationResult}
|
||||
@@ -375,7 +547,10 @@ export function validateNetworkData(value) {
|
||||
|
||||
const machines = [];
|
||||
const machineEntries = [];
|
||||
for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw) ? machinesRaw : []).entries()) {
|
||||
for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw)
|
||||
? machinesRaw
|
||||
: []
|
||||
).entries()) {
|
||||
const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue);
|
||||
if (machine) {
|
||||
machines.push(machine);
|
||||
@@ -385,7 +560,10 @@ export function validateNetworkData(value) {
|
||||
|
||||
const devices = [];
|
||||
const deviceEntries = [];
|
||||
for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw) ? devicesRaw : []).entries()) {
|
||||
for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw)
|
||||
? devicesRaw
|
||||
: []
|
||||
).entries()) {
|
||||
const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue);
|
||||
if (device) {
|
||||
devices.push(device);
|
||||
@@ -426,9 +604,67 @@ export function validateNetworkData(value) {
|
||||
}
|
||||
}
|
||||
|
||||
const subnetsRaw = value.subnets;
|
||||
const subnetsProvided = subnetsRaw !== undefined;
|
||||
if (subnetsProvided && !Array.isArray(subnetsRaw)) {
|
||||
issues.push({ path: '$.subnets', message: 'must be an array when provided' });
|
||||
}
|
||||
const subnets = [];
|
||||
const subnetEntries = [];
|
||||
for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw)
|
||||
? subnetsRaw
|
||||
: []
|
||||
).entries()) {
|
||||
const subnet = normalizeSubnet(issues, `$.subnets[${subnetIndex}]`, subnetValue);
|
||||
if (subnet) {
|
||||
subnets.push(subnet);
|
||||
subnetEntries.push({ subnet, originalIndex: subnetIndex });
|
||||
}
|
||||
}
|
||||
|
||||
const seenCidrs = new Set();
|
||||
for (const { subnet, originalIndex } of subnetEntries) {
|
||||
if (seenCidrs.has(subnet.cidr)) {
|
||||
issues.push({
|
||||
path: `$.subnets[${originalIndex}].cidr`,
|
||||
message: `duplicate subnet CIDR "${subnet.cidr}"`
|
||||
});
|
||||
}
|
||||
seenCidrs.add(subnet.cidr);
|
||||
}
|
||||
|
||||
const racksRaw = value.racks;
|
||||
const racksProvided = racksRaw !== undefined;
|
||||
if (racksProvided && !Array.isArray(racksRaw)) {
|
||||
issues.push({ path: '$.racks', message: 'must be an array when provided' });
|
||||
}
|
||||
const racks = [];
|
||||
const rackEntries = [];
|
||||
for (const [rackIndex, rackValue] of (Array.isArray(racksRaw) ? racksRaw : []).entries()) {
|
||||
const rack = normalizeRackDefinition(issues, `$.racks[${rackIndex}]`, rackValue);
|
||||
if (rack) {
|
||||
racks.push(rack);
|
||||
rackEntries.push({ rack, originalIndex: rackIndex });
|
||||
}
|
||||
}
|
||||
|
||||
const seenRackNames = new Set();
|
||||
for (const { rack, originalIndex } of rackEntries) {
|
||||
const key = rack.name.toLowerCase();
|
||||
if (seenRackNames.has(key)) {
|
||||
issues.push({
|
||||
path: `$.racks[${originalIndex}].name`,
|
||||
message: `duplicate rack name "${rack.name}"`
|
||||
});
|
||||
}
|
||||
seenRackNames.add(key);
|
||||
}
|
||||
|
||||
const data = {
|
||||
machines,
|
||||
devices
|
||||
devices,
|
||||
...(subnetsProvided ? { subnets } : {}),
|
||||
...(racksProvided ? { racks } : {})
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
export interface CableInfo {
|
||||
type?: string;
|
||||
color?: string;
|
||||
lengthM?: number;
|
||||
}
|
||||
|
||||
export interface ConnectedPortRef {
|
||||
device: string;
|
||||
port: string;
|
||||
cable?: CableInfo;
|
||||
}
|
||||
|
||||
export interface Port {
|
||||
portName: string;
|
||||
speedGbps?: number; // default to 1 if not provided
|
||||
macAddress?: string;
|
||||
connectedTo?: ConnectedPortRef;
|
||||
}
|
||||
|
||||
export interface RackPlacement {
|
||||
name: string;
|
||||
unit: number; // bottom U position, 1-based
|
||||
heightU?: number; // defaults to 1
|
||||
}
|
||||
|
||||
export interface Hardware {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
@@ -22,6 +36,7 @@ export interface VM {
|
||||
role: string;
|
||||
ipAddress: string;
|
||||
iconKey?: string;
|
||||
macAddress?: string;
|
||||
}
|
||||
|
||||
export interface Software {
|
||||
@@ -34,9 +49,11 @@ export interface Machine {
|
||||
role: string;
|
||||
operatingSystem: string;
|
||||
iconKey?: string;
|
||||
notes?: string;
|
||||
software: Software;
|
||||
hardware: Hardware;
|
||||
ports?: Port[];
|
||||
rack?: RackPlacement;
|
||||
}
|
||||
|
||||
export interface NetworkDevice {
|
||||
@@ -46,9 +63,23 @@ export interface NetworkDevice {
|
||||
iconKey?: string;
|
||||
notes?: string;
|
||||
ports?: Port[];
|
||||
rack?: RackPlacement;
|
||||
}
|
||||
|
||||
export interface Subnet {
|
||||
cidr: string;
|
||||
name?: string;
|
||||
vlanId?: number;
|
||||
}
|
||||
|
||||
export interface RackDefinition {
|
||||
name: string;
|
||||
heightU?: number;
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
machines: Machine[];
|
||||
devices: NetworkDevice[];
|
||||
subnets?: Subnet[];
|
||||
racks?: RackDefinition[];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
|
||||
import { validateNetworkData } from '$lib/data/networkSchema';
|
||||
import {
|
||||
checkWritableState,
|
||||
isWriteEnabled,
|
||||
getWritableState,
|
||||
readNetworkFile,
|
||||
writeNetworkFile
|
||||
} from '$lib/server/networkPersistence';
|
||||
@@ -16,11 +15,18 @@ function serializeValidationErrors(errors: Array<{ path: string; message: string
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveReadOnlyErrorMessage(reason: string | null): string {
|
||||
if (!reason) {
|
||||
return 'Write API unavailable in current deployment.';
|
||||
}
|
||||
return `Write API unavailable: ${reason}`;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const [{ data, source, updatedAt }, writable] = await Promise.all([
|
||||
const [{ data, source, updatedAt }, writableState] = await Promise.all([
|
||||
readNetworkFile(),
|
||||
checkWritableState()
|
||||
getWritableState()
|
||||
]);
|
||||
const validation = validateNetworkData(data);
|
||||
if (!validation.valid || !validation.data) {
|
||||
@@ -35,7 +41,8 @@ export const GET: RequestHandler = async () => {
|
||||
|
||||
return json({
|
||||
data: validation.data,
|
||||
writable,
|
||||
writable: writableState.writable,
|
||||
writableReason: writableState.reason,
|
||||
source,
|
||||
updatedAt
|
||||
});
|
||||
@@ -47,10 +54,13 @@ export const GET: RequestHandler = async () => {
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ request }) => {
|
||||
if (!isWriteEnabled()) {
|
||||
const writableStateBeforeWrite = await getWritableState();
|
||||
if (!writableStateBeforeWrite.writable) {
|
||||
return json(
|
||||
{
|
||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
||||
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||
writable: false,
|
||||
writableReason: writableStateBeforeWrite.reason
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
@@ -76,10 +86,11 @@ export const PUT: RequestHandler = async ({ request }) => {
|
||||
|
||||
try {
|
||||
const metadata = await writeNetworkFile(validation.data);
|
||||
const writable = await checkWritableState();
|
||||
const writableStateAfterWrite = await getWritableState();
|
||||
return json({
|
||||
data: validation.data,
|
||||
writable,
|
||||
writable: writableStateAfterWrite.writable,
|
||||
writableReason: writableStateAfterWrite.reason,
|
||||
source: metadata.source,
|
||||
updatedAt: metadata.updatedAt
|
||||
});
|
||||
|
||||
+281
-121
@@ -5,55 +5,36 @@
|
||||
"ipAddress": "10.0.0.3",
|
||||
"role": "Hypervisor",
|
||||
"operatingSystem": "Proxmox",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"portName": "eth1",
|
||||
"speedGbps": 1
|
||||
},
|
||||
{
|
||||
"portName": "eth2",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"portName": "eth3",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "WAN Uplink",
|
||||
"port": "wan"
|
||||
}
|
||||
}
|
||||
],
|
||||
"iconKey": "homarr:proxmox",
|
||||
"notes": "Primary router box; do not power off without failover in place.",
|
||||
"software": {
|
||||
"vms": [
|
||||
{
|
||||
"name": "OpnSense",
|
||||
"role": "Router/Firewall/Gateway (DHCP 10.0.0.100-254)",
|
||||
"ipAddress": "10.0.0.1"
|
||||
"ipAddress": "10.0.0.1",
|
||||
"macAddress": "bc:24:11:5a:2e:01"
|
||||
},
|
||||
{
|
||||
"name": "TPLink Omada Controller",
|
||||
"role": "Network Controller (:8043)",
|
||||
"ipAddress": "10.0.0.4"
|
||||
},
|
||||
{ "name": "PiVPN (WireGuard)", "role": "VPN Server", "ipAddress": "10.0.0.5" },
|
||||
{
|
||||
"name": "PiVPN (WireGuard)",
|
||||
"role": "VPN Server",
|
||||
"ipAddress": "10.0.0.5"
|
||||
},
|
||||
{
|
||||
"name": "PiHole",
|
||||
"role": "DNS Ad-blocker (installed, not in active use)",
|
||||
"ipAddress": "10.0.0.6"
|
||||
},
|
||||
{ "name": "Dashy", "role": "Dashboard", "ipAddress": "10.0.0.12" },
|
||||
{
|
||||
"name": "Dashy",
|
||||
"role": "Dashboard",
|
||||
"ipAddress": "10.0.0.12"
|
||||
},
|
||||
{
|
||||
"name": "ProxRouter-Docker",
|
||||
"role": "Docker host (Nginx reverse proxy, RustDesk, TwinGate)",
|
||||
@@ -66,6 +47,51 @@
|
||||
"ram": "8GB",
|
||||
"networkPorts": 4,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"macAddress": "84:47:09:1c:aa:10",
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "1",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"portName": "eth1",
|
||||
"speedGbps": 1
|
||||
},
|
||||
{
|
||||
"portName": "eth2",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "2",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"portName": "eth3",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "WAN Uplink",
|
||||
"port": "wan"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -73,29 +99,47 @@
|
||||
"ipAddress": "10.0.0.9",
|
||||
"role": "NAS",
|
||||
"operatingSystem": "Asustor ADM",
|
||||
"iconKey": "homarr:asustor",
|
||||
"software": {
|
||||
"vms": []
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Realtek RTD1296 Quad Core 1.4GHz",
|
||||
"ram": "2GB",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "3"
|
||||
"port": "3",
|
||||
"cable": {
|
||||
"type": "Cat5e",
|
||||
"color": "red",
|
||||
"lengthM": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": { "vms": [] },
|
||||
"hardware": {
|
||||
"cpu": "Realtek RTD1296 Quad Core 1.4GHz",
|
||||
"ram": "2GB",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"machineName": "Home Assistant Green",
|
||||
"ipAddress": "10.0.0.13",
|
||||
"role": "Smart Home Controller",
|
||||
"operatingSystem": "Home Assistant OS",
|
||||
"iconKey": "homarr:home-assistant",
|
||||
"software": {
|
||||
"vms": []
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Home Assistant Custom SoC",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -105,20 +149,23 @@
|
||||
"port": "4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": { "vms": [] },
|
||||
"hardware": {
|
||||
"cpu": "Home Assistant Custom SoC",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"machineName": "Plex Server",
|
||||
"ipAddress": "10.0.0.11",
|
||||
"role": "Media Server",
|
||||
"operatingSystem": "Ubuntu Server",
|
||||
"iconKey": "homarr:plex",
|
||||
"software": {
|
||||
"vms": []
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Intel N100",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -128,20 +175,23 @@
|
||||
"port": "5"
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": { "vms": [] },
|
||||
"hardware": {
|
||||
"cpu": "Intel N100",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"machineName": "Win11 N100",
|
||||
"ipAddress": "10.0.0.8",
|
||||
"role": "Media Downloader",
|
||||
"operatingSystem": "Windows 11",
|
||||
"iconKey": "homarr:windows-11",
|
||||
"software": {
|
||||
"vms": []
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Intel N100",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -151,20 +201,40 @@
|
||||
"port": "6"
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": { "vms": [] },
|
||||
"hardware": {
|
||||
"cpu": "Intel N100",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"machineName": "Win11 Backblaze NAS",
|
||||
"ipAddress": "10.0.0.7",
|
||||
"role": "Backup NAS + Hypervisor",
|
||||
"operatingSystem": "TrueNAS Scale",
|
||||
"iconKey": "homarr:backblaze",
|
||||
"software": {
|
||||
"vms": [
|
||||
{
|
||||
"name": "Win11NAS",
|
||||
"role": "Backblaze Backup VM",
|
||||
"ipAddress": "10.0.0.24"
|
||||
},
|
||||
{
|
||||
"name": "MakeMKV",
|
||||
"role": "Blu-ray Ripper",
|
||||
"ipAddress": "10.0.0.14"
|
||||
},
|
||||
{
|
||||
"name": "Handbrake",
|
||||
"role": "Video Transcoder",
|
||||
"ipAddress": "10.0.0.15"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "AMD Ryzen 5 4600G",
|
||||
"ram": "16GB",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1,
|
||||
"gpu": "EVGA GeForce GTX 1050 Ti"
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -174,37 +244,14 @@
|
||||
"port": "7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": {
|
||||
"vms": [
|
||||
{ "name": "Win11NAS", "role": "Backblaze Backup VM", "ipAddress": "10.0.0.24" },
|
||||
{ "name": "MakeMKV", "role": "Blu-ray Ripper", "ipAddress": "10.0.0.14" },
|
||||
{ "name": "Handbrake", "role": "Video Transcoder", "ipAddress": "10.0.0.15" }
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "AMD Ryzen 5 4600G",
|
||||
"ram": "16GB",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1,
|
||||
"gpu": "EVGA GeForce GTX 1050 Ti"
|
||||
}
|
||||
},
|
||||
{
|
||||
"machineName": "TrueNAS Host",
|
||||
"ipAddress": "10.0.0.10",
|
||||
"role": "Storage + VM Host",
|
||||
"operatingSystem": "TrueNAS Scale",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"iconKey": "homarr:truenas",
|
||||
"software": {
|
||||
"vms": [
|
||||
{
|
||||
@@ -219,6 +266,21 @@
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 7,
|
||||
"heightU": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -226,6 +288,28 @@
|
||||
"ipAddress": "10.0.0.20",
|
||||
"role": "AI Dev/Inference",
|
||||
"operatingSystem": "Pop!_OS",
|
||||
"iconKey": "homarr:ollama-dark",
|
||||
"software": {
|
||||
"vms": [
|
||||
{
|
||||
"name": "Ollama",
|
||||
"role": "LLM Inference",
|
||||
"ipAddress": "10.0.0.21"
|
||||
},
|
||||
{
|
||||
"name": "Bot Training",
|
||||
"role": "AI Training",
|
||||
"ipAddress": "10.0.0.22"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "AMD Ryzen 5 3600",
|
||||
"ram": "32GB",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1,
|
||||
"gpu": "Gigabyte GeForce GTX 1080"
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -236,18 +320,10 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": {
|
||||
"vms": [
|
||||
{ "name": "Ollama", "role": "LLM Inference", "ipAddress": "10.0.0.21" },
|
||||
{ "name": "Bot Training", "role": "AI Training", "ipAddress": "10.0.0.22" }
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "AMD Ryzen 5 3600",
|
||||
"ram": "32GB",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1,
|
||||
"gpu": "Gigabyte GeForce GTX 1080"
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 4,
|
||||
"heightU": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -255,6 +331,16 @@
|
||||
"ipAddress": "10.0.0.30",
|
||||
"role": "Photo Server",
|
||||
"operatingSystem": "Linux",
|
||||
"iconKey": "homarr:immich",
|
||||
"software": {
|
||||
"vms": []
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Unknown",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -264,14 +350,7 @@
|
||||
"port": "10"
|
||||
}
|
||||
}
|
||||
],
|
||||
"software": { "vms": [] },
|
||||
"hardware": {
|
||||
"cpu": "Unknown",
|
||||
"ram": "Unknown",
|
||||
"networkPorts": 1,
|
||||
"networkPortSpeedGbps": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
@@ -279,6 +358,7 @@
|
||||
"name": "Gigabit Switch",
|
||||
"ipAddress": "unknown",
|
||||
"type": "Network Switch",
|
||||
"iconKey": "switch",
|
||||
"notes": "Main 24-port switch. Wireless access point uplinks through this switch.",
|
||||
"ports": [
|
||||
{
|
||||
@@ -286,7 +366,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "ProxRouter",
|
||||
"port": "eth0"
|
||||
"port": "eth0",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -294,7 +379,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "ProxRouter",
|
||||
"port": "eth2"
|
||||
"port": "eth2",
|
||||
"cable": {
|
||||
"type": "Cat6",
|
||||
"color": "blue",
|
||||
"lengthM": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -302,7 +392,12 @@
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Asustor NAS",
|
||||
"port": "eth0"
|
||||
"port": "eth0",
|
||||
"cable": {
|
||||
"type": "Cat5e",
|
||||
"color": "red",
|
||||
"lengthM": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -403,7 +498,11 @@
|
||||
},
|
||||
{
|
||||
"portName": "20",
|
||||
"speedGbps": 1
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "NanoKVM Lite",
|
||||
"port": "port0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"portName": "21",
|
||||
@@ -421,7 +520,11 @@
|
||||
"portName": "24",
|
||||
"speedGbps": 1
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "WAN Uplink",
|
||||
@@ -443,6 +546,7 @@
|
||||
"name": "HDHomeRun",
|
||||
"ipAddress": "10.0.0.2",
|
||||
"type": "TV Tuner",
|
||||
"iconKey": "homarr:hdhomerun",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "eth0",
|
||||
@@ -452,43 +556,99 @@
|
||||
"port": "11"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3DS",
|
||||
"ipAddress": "10.0.0.17",
|
||||
"type": "Handheld Console",
|
||||
"notes": "Wi-Fi only."
|
||||
"notes": "Wi-Fi only.",
|
||||
"ports": []
|
||||
},
|
||||
{
|
||||
"name": "2DS",
|
||||
"ipAddress": "10.0.0.18",
|
||||
"type": "Handheld Console",
|
||||
"notes": "Wi-Fi only."
|
||||
"notes": "Wi-Fi only.",
|
||||
"ports": []
|
||||
},
|
||||
{
|
||||
"name": "Nintendo Switch",
|
||||
"ipAddress": "10.0.0.19",
|
||||
"type": "Gaming Console",
|
||||
"notes": "Wi-Fi only."
|
||||
"notes": "Wi-Fi only.",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "port0",
|
||||
"speedGbps": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "NanoKVM Lite",
|
||||
"ipAddress": "10.0.0.26",
|
||||
"type": "KVM Device",
|
||||
"notes": "Port link not yet modeled."
|
||||
"notes": "Port link not yet modeled.",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "port0",
|
||||
"speedGbps": 1,
|
||||
"connectedTo": {
|
||||
"device": "Gigabit Switch",
|
||||
"port": "20"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "UPS Pi",
|
||||
"ipAddress": "10.0.0.27",
|
||||
"type": "Power Monitoring Device",
|
||||
"notes": "Port link not yet modeled."
|
||||
"notes": "Port link not yet modeled.",
|
||||
"ports": [
|
||||
{
|
||||
"portName": "port0",
|
||||
"speedGbps": 1
|
||||
}
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 1,
|
||||
"heightU": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Waveshare",
|
||||
"ipAddress": "10.0.0.28",
|
||||
"type": "Peripheral Device",
|
||||
"notes": "Port link not yet modeled."
|
||||
"notes": "Port link not yet modeled.",
|
||||
"ports": []
|
||||
}
|
||||
],
|
||||
"subnets": [
|
||||
{
|
||||
"cidr": "10.0.0.0/24",
|
||||
"name": "LAN",
|
||||
"vlanId": 1
|
||||
},
|
||||
{
|
||||
"cidr": "10.0.20.0/24",
|
||||
"name": "IoT",
|
||||
"vlanId": 20
|
||||
}
|
||||
],
|
||||
"racks": [
|
||||
{
|
||||
"name": "Lab Rack",
|
||||
"heightU": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@ import staticAdapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const deployTarget = (process.env.DEPLOY_TARGET ?? 'docker').toLowerCase();
|
||||
const adapter = deployTarget === 'netlify'
|
||||
const adapter =
|
||||
deployTarget === 'netlify'
|
||||
? netlifyAdapter()
|
||||
: staticAdapter({
|
||||
fallback: 'index.html',
|
||||
|
||||
Reference in New Issue
Block a user