12 Commits

Author SHA1 Message Date
Josh Creek 7c7118bbf8 Merge pull request #25 from jcreek/14-read-only-mode
feat(#14): Add zero-setup Docker data initialization
2026-07-24 22:51:39 +01:00
Josh Creek 7f1bad009b refactor(#14): Address PR feedback 2026-07-24 22:43:44 +01:00
Josh Creek 0b1d647aec feat(#14): Add zero-setup Docker data initialization 2026-07-24 22:24:45 +01:00
Josh Creek 5976b46cad Merge pull request #24 from jcreek/23-add-arm64-platform-to-published-container-images
build(#23): Publish multi-architecture container images
2026-07-24 21:36:08 +01:00
Josh Creek 6a1c8f870a build(#23): Publish multi-architecture container images 2026-07-24 21:21:32 +01:00
Josh Creek cab77a1e48 Merge pull request #22 from jcreek/dependabot/github_actions/actions/setup-node-7 2026-07-15 14:36:16 +01:00
dependabot[bot] d0a631de8f build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-15 13:33:29 +00:00
Josh Creek a067cc3231 Merge pull request #21 from jcreek/feat/dark-mode
Feat/dark mode
2026-07-13 22:07:06 +01:00
Josh Creek 68496ae904 feat(*): Improve dark mode contrast 2026-07-13 22:01:28 +01:00
Josh Creek e58e427093 docs(*): Update screenshots 2026-07-13 22:01:13 +01:00
Josh Creek 94bc7085bf Merge pull request #20 from jcreek/feat/redesign
feat(*): Redesign UI to improve UX
2026-07-13 21:29:42 +01:00
Josh Creek 6517fcb373 feat(*): Redesign UI to improve UX 2026-07-13 21:20:04 +01:00
41 changed files with 5546 additions and 2435 deletions
+4
View File
@@ -18,6 +18,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -36,6 +39,7 @@ jobs:
context: .
file: Dockerfile
push: false
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
+5 -1
View File
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 20
@@ -59,6 +59,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -85,6 +88,7 @@ jobs:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
+5
View File
@@ -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/
+94 -20
View File
@@ -8,7 +8,7 @@
**A declarative, self-hosted containerised tool for visualising and managing home lab & network architecture diagrams.**
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can version, back up, and reuse.
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can back up, reuse, and optionally version after reviewing it for sensitive information.
- Homelab-friendly: run it in minutes with Docker.
- Practical: edit in the UI and autosave to `network.json`.
@@ -21,7 +21,7 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
## Features
- Network view with ethernet labels to make physical and logical links easy to read.
- Non-network view for host-first inventory and service mapping.
- Hosts & VMs view for host-first inventory and service mapping.
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
- Node search that highlights matches by name, IP, role or OS and dims everything else.
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
@@ -47,11 +47,10 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
This is the fastest way to run Open Network Diagram for a home lab.
1. Create a local data folder and seed your first `network.json`:
1. Create a local data folder:
```bash
mkdir -p ond-data
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example -o ond-data/network.json
mkdir -p data
```
2. Run the published Docker image:
@@ -61,15 +60,18 @@ docker run -d \
--name open-network-diagram \
--restart unless-stopped \
-p 8080:3000 \
--user "$(id -u):$(id -g)" \
-e NETWORK_DATA_FILE=/app/data/network.json \
-e NETWORK_BACKUP_DIR=/app/data/.backups \
-v "$(pwd)/ond-data:/app/data" \
-v "$(pwd)/data:/app/data" \
jcreek23/open-network-diagram:latest
```
On first access, Open Network Diagram creates a valid blank `data/network.json` and shows the getting-started card.
3. Open the app at `http://localhost:8080`.
4. Edit your topology in the UI. Changes persist to `ond-data/network.json`.
4. Add your first machine. Changes and rolling backups remain directly accessible in the local `data` directory across container updates and restarts.
Useful follow-up commands:
@@ -79,15 +81,42 @@ docker stop open-network-diagram
docker rm open-network-diagram
```
The `--user` option runs the container with your host UID and GID so files in the bind mount remain writable and owned by your account. Docker Desktop commonly handles bind-mount permissions without this mapping, so macOS users can omit `--user` if their Docker Desktop configuration requires the image's built-in user. If storage is not writable, the app leaves it untouched, loads the bundled read-only demo, and reports the exact permission problem in the UI.
### Optional Sample Data
The application starts with a blank network by default. To explore the sample topology instead, download it before starting the container:
```bash
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example \
-o data/network.json
```
The sample is optional and is never required for startup.
## Deployment Security
Open Network Diagram does not include authentication or TLS. The quick-start port mapping `-p 8080:3000` listens on all host interfaces, so any device that can reach the host can view the topology and, in writable mode, change it.
Do not expose a writable deployment directly to the internet. Restrict access with a firewall, LAN or VPN, or place the application behind an authenticated reverse proxy that terminates TLS.
To make the service reachable only from the Docker host, replace the quick-start mapping with `-p 127.0.0.1:8080:3000`.
For a public demonstration, add `-e NETWORK_READ_ONLY=true` to `docker run`, or add `NETWORK_READ_ONLY: 'true'` under Compose's `environment`. Read-only mode prevents changes and uses the bundled demo if the configured data file is missing, but it does not hide a configured topology or replace authentication.
## What It Looks Like
| Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
| Network view with ethernet labels | Hosts & VMs view with VMs expanded | Modal editing a machine |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram non-network view with VMs expanded](screenshot2.png) | ![Open Network Diagram modal editing a machine](screenshot3.png) |
| ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram Hosts & VMs view with VMs expanded](screenshot2.png) | ![Open Network Diagram modal editing a machine](screenshot3.png) |
| IPAM panel with subnet utilization | Rack view with shelf items |
| ------------------------------------------------------------------ | -------------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) |
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) | ![Open Network Diagram dark mode](screenshot6.png) |
| Zero-setup first run | Read-only live demo |
| ------------------------------------------------------------------------ | ------------------------------------------------------------ |
| ![Open Network Diagram zero-setup first-run onboarding](screenshot7.png) | ![Open Network Diagram read-only live demo](screenshot8.png) |
## Docker Compose Option
@@ -97,10 +126,11 @@ If you prefer compose:
services:
open-network-diagram:
image: jcreek23/open-network-diagram:latest
user: '${OND_UID:?Set OND_UID to your host user ID}:${OND_GID:?Set OND_GID to your host group ID}'
ports:
- '8080:3000'
volumes:
- ./ond-data:/app/data
- ./data:/app/data
environment:
NETWORK_DATA_FILE: /app/data/network.json
NETWORK_BACKUP_DIR: /app/data/.backups
@@ -110,9 +140,40 @@ services:
Start it with:
```bash
docker compose up -d
mkdir -p data
OND_UID="$(id -u)" OND_GID="$(id -g)" docker compose up -d
```
`OND_UID` and `OND_GID` are required Compose interpolation values supplied by the command above; they are not application environment variables. Docker Desktop users can remove the `user` line if their file-sharing configuration requires the image's built-in user.
### Using a Docker Named Volume
If host-directory permissions are inconvenient, Docker can manage the storage volume instead:
```bash
docker run -d \
--name open-network-diagram \
--restart unless-stopped \
-p 8080:3000 \
-e NETWORK_DATA_FILE=/app/data/network.json \
-e NETWORK_BACKUP_DIR=/app/data/.backups \
-v ond-data:/app/data \
jcreek23/open-network-diagram:latest
```
Docker creates the `ond-data` volume automatically. Use `docker volume inspect ond-data` to inspect its location or `docker cp open-network-diagram:/app/data/network.json ./network.json` to copy the data file out.
### Versioning Your Topology
`data/network.json` and `data/.backups` are ignored by Git because topology data may expose host names, addresses, MAC addresses, and other sensitive infrastructure details. If you have reviewed and sanitized the data and deliberately want to track it, opt in with:
```bash
git add -f data/network.json
git commit -m "Track network topology"
```
Once the file is tracked, normal Git commands include later changes. Backup files remain ignored.
## For Developers
### Local Development
@@ -139,6 +200,8 @@ pnpm run icons:manifest # regenerate local vendor icon manifest
- API endpoint: `GET/PUT /api/network-data`
- Writes are enabled unless `NETWORK_READ_ONLY=true`
- Writable deployments automatically initialize a missing or whitespace-only data file with a blank network.
- Read-only deployments never initialize or modify the configured data file and continue to use the bundled demo when the API data is unavailable.
- When writes are unavailable, API responses include `writableReason` for diagnostics.
- Writes are persisted atomically to the configured data file
- Rolling backups are kept in the backup directory (last 5)
@@ -232,7 +295,7 @@ OpenNetworkDiagram/
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
├── static/data/network.json # Demo dataset (Netlify)
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
├── data/network.json.example # Starter data template for Docker users
├── data/network.json.example # Optional starter data template
├── third_party/ # Third-party provenance + licensing
├── Dockerfile # Docker build/runtime image
├── server.mjs # Node runtime server (static + API)
@@ -243,17 +306,28 @@ OpenNetworkDiagram/
### CI/CD
- `docker.yml`: validates Docker build on pull requests.
- `release.yml`: semantic release on `main` and Docker Hub publish for tagged releases.
- `docker.yml`: validates AMD64 and ARM64 Docker builds on pull requests.
- `release.yml`: semantic release on `main` and multi-platform Docker Hub publish for tagged releases.
- Published images support AMD64 and 64-bit ARM (including Raspberry Pi systems); Docker selects the matching variant automatically.
- Docker Hub image: [`jcreek23/open-network-diagram`](https://hub.docker.com/r/jcreek23/open-network-diagram)
## Contributing
1. Fork the repository.
2. Create a feature branch.
3. Commit your changes.
4. Push your branch.
5. Open a pull request.
3. Before committing, run:
```bash
pnpm lint
pnpm test
pnpm check
pnpm run build:docker
pnpm run build:netlify
```
4. Commit your changes.
5. Push your branch.
6. Open a pull request.
## License
+4 -3
View File
@@ -4,11 +4,12 @@ services:
context: .
dockerfile: Dockerfile
image: open-network-diagram:local
user: '${OND_UID:?Set OND_UID to your host user ID}:${OND_GID:?Set OND_GID to your host group ID}'
ports:
- "8080:3000"
- '8080:3000'
volumes:
- ./data:/app/data
environment:
NETWORK_DATA_FILE: "/app/data/network.json"
NETWORK_BACKUP_DIR: "/app/data/.backups"
NETWORK_DATA_FILE: '/app/data/network.json'
NETWORK_BACKUP_DIR: '/app/data/.backups'
restart: unless-stopped
+6 -4
View File
@@ -7,9 +7,10 @@
"dev": "vite dev",
"build": "vite build",
"build:docker": "DEPLOY_TARGET=docker vite build",
"build:netlify": "NETWORK_READ_ONLY=true DEPLOY_TARGET=netlify vite build",
"build:netlify": "NETWORK_READ_ONLY=true OND_STATIC_DEMO=true DEPLOY_TARGET=netlify vite build",
"start": "node server.mjs",
"preview": "vite preview",
"test": "node --test tests/*.test.mjs",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -38,8 +39,8 @@
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
@@ -55,6 +56,7 @@
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
"dependencies": {
"cytoscape": "^3.31.2",
"cytoscape-dagre": "^2.5.0"
"cytoscape-dagre": "^2.5.0",
"html-to-image": "^1.11.13"
}
}
+28 -19
View File
@@ -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: {}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 117 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 148 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 114 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+137 -132
View File
@@ -12,181 +12,186 @@ const prettierIgnorePath = path.join(projectRoot, '.prettierignore');
const allowedExtensions = new Set(['.svg', '.png', '.webp']);
const extensionPriority = {
'.svg': 0,
'.png': 1,
'.webp': 2
'.svg': 0,
'.png': 1,
'.webp': 2
};
/** @param {string} input */
function toPosixPath(input) {
return input.split(path.sep).join('/');
return input.split(path.sep).join('/');
}
/** @param {string} filename */
function toSlug(filename) {
return filename
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/[._]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return filename
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/[._]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/** @param {string} slug */
function toLabel(slug) {
return slug
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
return slug
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
/** @param {string} dir */
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => path.join(dir, entry.name));
const directories = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name));
const nestedFileLists = await Promise.all(directories.map((childDirectory) => walk(childDirectory)));
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => path.join(dir, entry.name));
const directories = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name));
const nestedFileLists = await Promise.all(
directories.map((childDirectory) => walk(childDirectory))
);
for (const nestedFiles of nestedFileLists) {
files.push(...nestedFiles);
}
return files;
for (const nestedFiles of nestedFileLists) {
files.push(...nestedFiles);
}
return files;
}
function compareIconRecords(a, b) {
const labelCompare = a.label.localeCompare(b.label);
if (labelCompare !== 0) {
return labelCompare;
}
return a.key.localeCompare(b.key);
const labelCompare = a.label.localeCompare(b.label);
if (labelCompare !== 0) {
return labelCompare;
}
return a.key.localeCompare(b.key);
}
/** @param {string} filePath @param {string} entry */
async function ensureIgnoreEntry(filePath, entry) {
const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
let contents = '';
try {
contents = await fs.readFile(filePath, 'utf8');
} catch (error) {
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
throw error;
}
}
const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
let contents = '';
try {
contents = await fs.readFile(filePath, 'utf8');
} catch (error) {
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
throw error;
}
}
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
const existingEntries = contents
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => toPosixPath(line).replace(/^\.?\//, ''));
if (existingEntries.includes(normalizedEntry)) {
return;
}
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
const existingEntries = contents
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => toPosixPath(line).replace(/^\.?\//, ''));
if (existingEntries.includes(normalizedEntry)) {
return;
}
const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
await fs.writeFile(filePath, nextContents, 'utf8');
const suffix =
contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
await fs.writeFile(filePath, nextContents, 'utf8');
}
async function main() {
const vendorExists = await fs
.access(vendorRoot)
.then(() => true)
.catch(() => false);
const vendorExists = await fs
.access(vendorRoot)
.then(() => true)
.catch(() => false);
if (!vendorExists) {
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
}
if (!vendorExists) {
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
}
const allFiles = await walk(vendorRoot);
const dedupedBySlug = new Map();
const allFiles = await walk(vendorRoot);
const dedupedBySlug = new Map();
for (const absoluteFilePath of allFiles) {
const extension = path.extname(absoluteFilePath).toLowerCase();
if (!allowedExtensions.has(extension)) {
continue;
}
for (const absoluteFilePath of allFiles) {
const extension = path.extname(absoluteFilePath).toLowerCase();
if (!allowedExtensions.has(extension)) {
continue;
}
const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
const basename = path.basename(relativePath, extension);
const slug = toSlug(basename);
if (!slug) {
continue;
}
const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
const basename = path.basename(relativePath, extension);
const slug = toSlug(basename);
if (!slug) {
continue;
}
const key = `homarr:${slug}`;
const candidate = {
key,
label: toLabel(slug),
path: `/icons/vendor/homarr/${relativePath}`,
source: 'homarr-dashboard-icons',
extension,
priority: extensionPriority[extension] ?? 99,
relativePath
};
const key = `homarr:${slug}`;
const candidate = {
key,
label: toLabel(slug),
path: `/icons/vendor/homarr/${relativePath}`,
source: 'homarr-dashboard-icons',
extension,
priority: extensionPriority[extension] ?? 99,
relativePath
};
const existing = dedupedBySlug.get(slug);
if (!existing) {
dedupedBySlug.set(slug, candidate);
continue;
}
const existing = dedupedBySlug.get(slug);
if (!existing) {
dedupedBySlug.set(slug, candidate);
continue;
}
if (candidate.priority < existing.priority) {
dedupedBySlug.set(slug, candidate);
continue;
}
if (candidate.priority < existing.priority) {
dedupedBySlug.set(slug, candidate);
continue;
}
if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) {
dedupedBySlug.set(slug, candidate);
}
}
if (
candidate.priority === existing.priority &&
candidate.relativePath < existing.relativePath
) {
dedupedBySlug.set(slug, candidate);
}
}
const records = Array.from(dedupedBySlug.values())
.sort(compareIconRecords)
.map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
const records = Array.from(dedupedBySlug.values())
.sort(compareIconRecords)
.map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
const header = [
'/* eslint-disable */',
'// prettier-ignore',
'// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
'// Do not edit manually.',
'',
'export interface VendorIconDefinition {',
' key: string;',
' label: string;',
' path: string;',
" source: 'homarr-dashboard-icons';",
'}',
'',
'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
].join('\n');
const header = [
'/* eslint-disable */',
'// prettier-ignore',
'// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
'// Do not edit manually.',
'',
'export interface VendorIconDefinition {',
' key: string;',
' label: string;',
' path: string;',
" source: 'homarr-dashboard-icons';",
'}',
'',
'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
].join('\n');
const body = records
.map((record) => {
const key = JSON.stringify(record.key);
const label = JSON.stringify(record.label);
const iconPath = JSON.stringify(record.path);
return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
})
.join('\n');
const body = records
.map((record) => {
const key = JSON.stringify(record.key);
const label = JSON.stringify(record.label);
const iconPath = JSON.stringify(record.path);
return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
})
.join('\n');
const footer = ['];', ''].join('\n');
const output = `${header}\n${body}\n${footer}`;
const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
await fs.writeFile(outputPath, output, 'utf8');
const footer = ['];', ''].join('\n');
const output = `${header}\n${body}\n${footer}`;
const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
await fs.writeFile(outputPath, output, 'utf8');
}
main().catch((error) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
+15 -4
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
import {
getWritableState,
readNetworkFile,
readOrInitializeNetworkFile,
writeNetworkFile
} from './src/lib/shared/networkPersistenceCore.mjs';
@@ -59,12 +59,17 @@ function resolveReadOnlyErrorMessage(reason) {
async function serveApi(request, response) {
if (request.method === 'GET') {
try {
const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]);
const [payload, writableState] = await Promise.all([
readOrInitializeNetworkFile(),
getWritableState()
]);
const validation = validateNetworkData(payload.data);
if (!validation.valid) {
sendJson(response, 500, {
error: 'Stored network data is invalid',
details: validation.errors
details: validation.errors,
writable: false,
writableReason: writableState.reason
});
return;
}
@@ -77,8 +82,14 @@ async function serveApi(request, response) {
updatedAt: payload.updatedAt
});
} catch (error) {
const writableState = await getWritableState().catch(() => ({
writable: false,
reason: null
}));
sendJson(response, 500, {
error: error instanceof Error ? error.message : 'Failed to read network data'
error: error instanceof Error ? error.message : 'Failed to read network data',
writable: false,
writableReason: writableState.reason
});
}
return;
+85 -16
View File
@@ -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);
}
+8
View File
@@ -1,6 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
interface ImportMetaEnv {
readonly OND_STATIC_DEMO: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
namespace App {
// interface Error {}
// interface Locals {}
+20
View File
@@ -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);
}
};
}
+815
View File
@@ -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>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let isSampleData = false;
export let writable = false;
export let hasMachines = false;
export let storageReady = false;
const dispatch = createEventDispatcher<{
addmachine: void;
connectport: void;
openipam: void;
dismiss: void;
}>();
</script>
<div class="first-run">
<h2>Map your network</h2>
{#if storageReady}
<p class="storage-note">
Your network data file is ready. Add your first machine to start mapping.
</p>
{:else if isSampleData}
<p class="sample-note">This is sample data — replace it with your own machines.</p>
{/if}
<div class="actions">
{#if writable}
<button type="button" on:click={() => dispatch('addmachine')}>
<span class="action-glyph" aria-hidden="true">+</span>
<span class="action-text">
<strong>Add a machine</strong>
<span>Servers, NAS boxes, mini PCs — anything with an IP.</span>
</span>
</button>
{/if}
<button type="button" disabled={!hasMachines} on:click={() => dispatch('connectport')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Connect a port</strong>
<span>
{hasMachines
? 'Open a machine and add a port to draw your first cable.'
: 'Add a machine first, then wire up its ports.'}
</span>
</span>
</button>
<button type="button" on:click={() => dispatch('openipam')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Open IPAM</strong>
<span>See subnet utilisation and grab the next free IP.</span>
</span>
</button>
</div>
<button type="button" class="dismiss" on:click={() => dispatch('dismiss')}>Dismiss</button>
</div>
<style>
.first-run {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
width: min(92vw, 440px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.sample-note {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--status-warn);
}
.storage-note {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--text-2);
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions > button {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.actions > button:hover:not(:disabled) {
border-color: var(--accent);
}
.actions > button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.action-glyph {
width: 28px;
height: 28px;
border-radius: var(--radius-control);
background: var(--surface-2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
flex-shrink: 0;
}
.action-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.action-text strong {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.action-text span {
font-size: 12px;
color: var(--text-2);
line-height: 1.45;
}
.dismiss {
align-self: flex-end;
border: none;
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-control);
}
.dismiss:hover {
background: var(--surface-2);
color: var(--text);
}
</style>
+196
View File
@@ -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>
+47 -23
View File
@@ -32,14 +32,23 @@
<div class="modal-backdrop" on:click={onBackdropClick}>
<div class="modal-container" style={`max-width: ${maxWidth}`}>
<header class="modal-header">
{#if title}
<h2>{title}</h2>
{/if}
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
<slot name="header">
{#if title}
<h2>{title}</h2>
{/if}
</slot>
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
>×</button
>
</header>
<div class="modal-body">
<slot />
</div>
{#if $$slots.footer}
<footer class="modal-footer-bar">
<slot name="footer" />
</footer>
{/if}
</div>
</div>
{/if}
@@ -61,49 +70,64 @@
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-modal);
box-shadow: 0 24px 64px rgb(15 18 24 / 0.35);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--panel-border);
gap: 14px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-header h2 {
margin: 0;
font-size: 1.05rem;
color: var(--panel-contrast);
flex: 1;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.close-button {
border: 1px solid transparent;
border: none;
background: transparent;
color: var(--muted-text);
font-size: 1.7rem;
color: var(--text-2);
font-size: 1.5rem;
line-height: 1;
width: 2rem;
height: 2rem;
border-radius: 8px;
width: 30px;
height: 30px;
border-radius: var(--radius-control);
cursor: pointer;
flex-shrink: 0;
margin-left: auto;
}
.close-button:hover {
background: var(--chip-bg);
color: var(--panel-contrast);
background: var(--surface-2);
color: var(--text);
}
.modal-body {
padding: 1rem;
padding: 20px;
overflow: auto;
}
.modal-footer-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--surface-2);
flex-shrink: 0;
}
@media (max-width: 720px) {
.modal-backdrop {
padding: 0;
File diff suppressed because it is too large Load Diff
+323
View File
@@ -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}&nbsp;·
{/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>
+99 -17
View File
@@ -65,11 +65,33 @@
</div>
{/each}
</div>
{#if layout.unrackedCount > 0}
<p class="rack-footnote">
{layout.unrackedCount}
{layout.unrackedCount === 1 ? 'entity is' : 'entities are'} not rack-mounted.
</p>
{#if 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>
@@ -79,8 +101,8 @@
position: absolute;
inset: 0;
overflow: auto;
padding: 10rem 1rem 1rem;
background: var(--app-bg, transparent);
padding: 1.25rem 1rem 1rem;
background: var(--bg-canvas, transparent);
}
.rack-empty {
@@ -160,17 +182,17 @@
}
.rack-slot.machine {
background: color-mix(in oklab, var(--panel-bg) 55%, #3b82f6 45%);
border-color: #3b82f6;
background: var(--surface);
border-color: var(--border);
}
.rack-slot.device {
background: color-mix(in oklab, var(--panel-bg) 55%, #d97706 45%);
border-color: #d97706;
background: var(--surface-2);
border-color: var(--border);
}
.rack-slot:hover {
filter: brightness(1.08);
border-color: var(--accent);
}
.rack-slot img {
@@ -212,11 +234,71 @@
flex-shrink: 0;
}
.rack-footnote {
text-align: center;
font-size: 0.76rem;
.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(--muted-text);
margin-top: 1rem;
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>
+58 -48
View File
@@ -61,7 +61,11 @@ export function cloneNetworkData(data: NetworkData): NetworkData {
return cloned;
}
function findOwnerByKindAndName(data: NetworkData, kind: OwnerKind, name: string): OwnerRef | undefined {
function findOwnerByKindAndName(
data: NetworkData,
kind: OwnerKind,
name: string
): OwnerRef | undefined {
if (kind === 'machine') {
const machine = data.machines.find((item) => equalsIgnoreCase(item.machineName, name));
if (!machine) {
@@ -113,46 +117,49 @@ function ensureReciprocalConnections(data: NetworkData) {
continue;
}
const targetPort = findPort(targetOwner, port.connectedTo.port);
if (!targetPort) {
delete port.connectedTo;
continue;
}
const targetPort = findPort(targetOwner, port.connectedTo.port);
if (!targetPort) {
delete port.connectedTo;
continue;
}
const expectedReciprocal: Port['connectedTo'] = {
device: owner.name,
port: port.portName,
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
};
if (!targetPort.connectedTo) {
targetPort.connectedTo = expectedReciprocal;
continue;
}
const expectedReciprocal: Port['connectedTo'] = {
device: owner.name,
port: port.portName,
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
};
if (!targetPort.connectedTo) {
targetPort.connectedTo = expectedReciprocal;
continue;
}
const hasMatchingReciprocal =
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
if (hasMatchingReciprocal) {
// 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;
const hasMatchingReciprocal =
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
if (hasMatchingReciprocal) {
// keep cable metadata identical on both ends; the lexicographically
// smaller owner:port key is the canonical copy so the sync is deterministic
const ownKey = `${owner.name}:${port.portName}`.toLowerCase();
const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase();
const [from, to] =
ownKey <= targetKey
? [port.connectedTo, targetPort.connectedTo]
: [targetPort.connectedTo, port.connectedTo];
if (from.cable) {
to.cable = deepClone(from.cable);
} else {
delete to.cable;
}
continue;
}
}
}
}
function clearInboundReferences(data: NetworkData, predicate: (connection: Port['connectedTo']) => boolean) {
function clearInboundReferences(
data: NetworkData,
predicate: (connection: Port['connectedTo']) => boolean
) {
for (const owner of allOwners(data)) {
for (const port of owner.ports) {
if (!port.connectedTo) {
@@ -226,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;
}
@@ -292,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;
@@ -331,14 +341,12 @@ export function deletePort(
}
}
clearInboundReferences(
cloned,
(connection) =>
Boolean(
connection &&
equalsIgnoreCase(connection.device, ownerName) &&
equalsIgnoreCase(connection.port, portName)
)
clearInboundReferences(cloned, (connection) =>
Boolean(
connection &&
equalsIgnoreCase(connection.device, ownerName) &&
equalsIgnoreCase(connection.port, portName)
)
);
ensureReciprocalConnections(cloned);
return cloned;
@@ -441,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;
}
+22 -4
View File
@@ -21,10 +21,18 @@ export interface RackColumn {
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;
@@ -112,7 +120,7 @@ function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) {
export function buildRackLayout(data: NetworkData): RackLayout {
const slotsByRack = new Map<string, RackSlot[]>();
const canonicalNames = new Map<string, string>();
let unrackedCount = 0;
const unracked: UnrackedEntity[] = [];
const canonical = (rawName: string): string => {
const trimmed = rawName.trim();
@@ -149,7 +157,12 @@ export function buildRackLayout(data: NetworkData): RackLayout {
for (const [index, machine] of data.machines.entries()) {
if (!machine.rack?.name) {
unrackedCount += 1;
unracked.push({
kind: 'machine',
name: machine.machineName,
iconKey: machine.iconKey,
subtitle: machine.role
});
continue;
}
place(
@@ -167,7 +180,12 @@ export function buildRackLayout(data: NetworkData): RackLayout {
}
for (const [index, device] of data.devices.entries()) {
if (!device.rack?.name) {
unrackedCount += 1;
unracked.push({
kind: 'device',
name: device.name,
iconKey: device.iconKey,
subtitle: device.type
});
continue;
}
place(
@@ -216,7 +234,7 @@ export function buildRackLayout(data: NetworkData): RackLayout {
};
});
return { racks, warnings, unrackedCount };
return { racks, warnings, unrackedCount: unracked.length, unracked };
}
export function knownRackNames(data: NetworkData): string[] {
+37
View File
@@ -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)
};
}
+38
View File
@@ -51,3 +51,41 @@ export function computeSearchMatches(
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;
}
+73 -19
View File
@@ -1,8 +1,15 @@
import type { ConnectedPortRef, NetworkData, Port, Subnet } from '../types';
import { cidrContains, parseCidr } from '../data/ipam';
import { resolveIconPath } from '../config/iconRegistry';
import { vlanColor } from './vlanPalette';
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
import { buildVlanIndexMap } from './vlanPalette';
import {
NODE_DIMENSIONS,
estimatePillWidth,
type DeviceClass,
type GraphEdgeElement,
type GraphNodeElement,
type GraphTransformResult
} from './types';
interface EndpointOwner {
kind: 'machine' | 'device';
@@ -76,22 +83,36 @@ function resolveCableColor(name: string | undefined): string | undefined {
return undefined;
}
const trimmed = name.trim();
return cableColorHex[trimmed.toLowerCase()] ?? (/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined);
return (
cableColorHex[trimmed.toLowerCase()] ??
(/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined)
);
}
function buildVlanResolver(subnets: Subnet[] | undefined) {
const vlanSubnets = (subnets ?? [])
.filter((subnet) => typeof subnet.vlanId === 'number')
.sort((a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0));
return (ip: string): { vlanId: number; vlanColor: string } | undefined => {
const 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, vlanColor: vlanColor(home.vlanId) };
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);
@@ -154,14 +175,16 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
nodes.push({
data: {
id: machineNodeId,
label: machine.machineName,
rawName: machine.machineName,
kind: 'machine',
id: machineNodeId,
label: machine.machineName,
rawName: machine.machineName,
kind: 'machine',
iconKey: machine.iconKey,
iconUrl: resolveIconPath(machine.iconKey),
nodeWidth: 200,
nodeHeight: 110,
nodeWidth: NODE_DIMENSIONS.machine.width,
nodeHeight: NODE_DIMENSIONS.machine.height,
meta: machine.ipAddress,
vmCount: sourceMachineVms.length,
...resolveVlan(machine.ipAddress),
details: {
type: 'machine',
@@ -199,8 +222,9 @@ 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',
@@ -221,8 +245,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
id: hostingEdgeId,
source: machineNodeId,
target: vmNodeId,
kind: 'hosting',
label: 'hosts'
kind: 'hosting'
}
});
@@ -246,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: {
@@ -253,10 +285,18 @@ 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',
@@ -339,20 +379,34 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
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: {
id: `physical:${physicalEdgeCounter}`,
source: owner.nodeId,
target: targetOwner.nodeId,
kind: 'physical',
label: speedGbps ? `${speedGbps}GbE` : undefined,
chipLabel,
chipLabelNoSpeed,
sourcePort: port.portName,
targetPort: connectedTo.port,
speedGbps,
reciprocal,
...(cable?.type ? { cableType: cable.type } : {}),
...(cable?.color ? { cableColorName: cable.color } : {}),
...(resolveCableColor(cable?.color) ? { cableColor: resolveCableColor(cable?.color) } : {}),
...(resolveCableColor(cable?.color)
? { cableColor: resolveCableColor(cable?.color) }
: {}),
...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {})
}
});
+36 -1
View File
@@ -1,5 +1,35 @@
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;
@@ -63,6 +93,7 @@ export interface GraphNodeData {
id: string;
label: string;
kind: GraphNodeKind;
deviceClass?: DeviceClass;
rawName?: string;
hostMachineId?: string;
nodeWidth?: number;
@@ -70,7 +101,9 @@ export interface GraphNodeData {
iconUrl?: string;
iconKey?: string;
vlanId?: number;
vlanColor?: string;
vlanIndex?: number;
meta?: string;
vmCount?: number;
details?: GraphNodeDetails;
}
@@ -80,6 +113,8 @@ export interface GraphEdgeData {
target: string;
kind: GraphEdgeKind;
label?: string;
chipLabel?: string;
chipLabelNoSpeed?: string;
sourcePort?: string;
targetPort?: string;
speedGbps?: number;
+35 -5
View File
@@ -1,7 +1,37 @@
// Distinct from the kind colours (machine blue, VM green, device amber) so a
// VLAN border reads as VLAN, not as an entity kind. Mid-saturation hues stay
// legible against both the light and dark node fills.
const palette = [
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',
@@ -15,5 +45,5 @@ const palette = [
];
export function vlanColor(vlanId: number): string {
return palette[Math.abs(Math.trunc(vlanId)) % palette.length];
return legacyPalette[Math.abs(Math.trunc(vlanId)) % legacyPalette.length];
}
+10
View File
@@ -2,6 +2,7 @@ import {
checkWritableState as checkWritableStateCore,
getWritableState as getWritableStateCore,
isWriteEnabled as isWriteEnabledCore,
readOrInitializeNetworkFile as readOrInitializeNetworkFileCore,
readNetworkFile as readNetworkFileCore,
writeNetworkFile as writeNetworkFileCore
} from '../shared/networkPersistenceCore.mjs';
@@ -18,6 +19,11 @@ export interface WritableState {
reason: string | null;
}
export interface NetworkFileBootstrapResult extends NetworkFileMetadata {
data: NetworkData;
initialized: boolean;
}
export function isWriteEnabled(): boolean {
return isWriteEnabledCore();
}
@@ -26,6 +32,10 @@ export async function readNetworkFile(): Promise<{ data: NetworkData } & Network
return (await readNetworkFileCore()) as { data: NetworkData } & NetworkFileMetadata;
}
export async function readOrInitializeNetworkFile(): Promise<NetworkFileBootstrapResult> {
return (await readOrInitializeNetworkFileCore()) as NetworkFileBootstrapResult;
}
export async function writeNetworkFile(data: NetworkData): Promise<NetworkFileMetadata> {
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
}
+181 -8
View File
@@ -16,12 +16,25 @@ import path from 'node:path';
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
* @typedef {{ status: 'ready'; data: NetworkData; source: string; updatedAt: string } | { status: 'missing' | 'blank'; source: string; updatedAt: string | null }} NetworkFileReadState
* @typedef {NetworkFileReadResult & { initialized: boolean }} NetworkFileBootstrapResult
* @typedef {{ writable: boolean; reason: string | null }} WritableState
* @typedef {{ createBackup?: boolean; beforeCommit?: () => Promise<void> }} WriteNetworkFileOptions
*/
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
const DATA_FILE_DEFAULT = path.resolve(process.cwd(), 'data/network.json');
const BACKUP_DIR_DEFAULT = path.resolve(process.cwd(), 'data/.backups');
const EMPTY_NETWORK_DATA = Object.freeze({
machines: Object.freeze([]),
devices: Object.freeze([])
});
/** @type {Map<string, Promise<unknown>>} */
const fileWriteOperations = new Map();
/** @type {Map<string, Promise<NetworkFileBootstrapResult>>} */
const initializationFlights = new Map();
class InitializationSupersededError extends Error {}
/**
* @returns {string}
@@ -86,20 +99,57 @@ async function readUpdatedAt(dataFilePath) {
}
/**
* @returns {Promise<NetworkFileReadResult>}
* @returns {Promise<NetworkFileReadState>}
*/
export async function readNetworkFile() {
export async function readNetworkFileState() {
const source = resolveDataFilePath();
const raw = await readFile(source, 'utf8');
let raw;
try {
raw = await readFile(source, 'utf8');
} catch (error) {
if (formatFileSystemErrorCode(error) === 'ENOENT') {
return {
status: 'missing',
source,
updatedAt: null
};
}
throw error;
}
if (raw.trim().length === 0) {
return {
status: 'blank',
source,
updatedAt: await readUpdatedAt(source)
};
}
const parsed = JSON.parse(raw);
const updatedAt = await readUpdatedAt(source);
return {
status: 'ready',
data: parsed,
source,
updatedAt
};
}
/**
* @returns {Promise<NetworkFileReadResult>}
*/
export async function readNetworkFile() {
const state = await readNetworkFileState();
if (state.status !== 'ready') {
throw new Error(`Network data file "${state.source}" is ${state.status}.`);
}
return {
data: state.data,
source: state.source,
updatedAt: state.updatedAt
};
}
/**
* @param {string} dataFilePath
* @param {string} backupDirectory
@@ -149,14 +199,18 @@ export async function trimBackups(backupDirectory, baseName, keep = 5) {
/**
* @param {NetworkData} data
* @param {string} source
* @param {WriteNetworkFileOptions} [options]
* @returns {Promise<NetworkFileMetadata>}
*/
export async function writeNetworkFile(data) {
const source = resolveDataFilePath();
async function writeNetworkFileUnlocked(data, source, options) {
if (!isWriteEnabled()) {
throw new Error('Writes disabled by NETWORK_READ_ONLY=true.');
}
const backupDirectory = resolveBackupDirectory();
const directory = path.dirname(source);
await mkdir(directory, { recursive: true });
await createBackupIfPresent(source, backupDirectory);
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
@@ -168,10 +222,14 @@ export async function writeNetworkFile(data) {
await fileHandle.close();
}
try {
await options?.beforeCommit?.();
if (options?.createBackup ?? true) {
await createBackupIfPresent(source, backupDirectory);
}
await rename(temporaryPath, source);
} catch (renameError) {
} catch (error) {
await unlink(temporaryPath).catch(() => undefined);
throw renameError;
throw error;
}
await trimBackups(backupDirectory, path.basename(source), 5);
@@ -183,6 +241,36 @@ export async function writeNetworkFile(data) {
};
}
/**
* @template T
* @param {string} source
* @param {() => Promise<T>} operation
* @returns {Promise<T>}
*/
async function withFileWriteLock(source, operation) {
const previousOperation = fileWriteOperations.get(source) ?? Promise.resolve();
const currentOperation = previousOperation.catch(() => undefined).then(operation);
fileWriteOperations.set(source, currentOperation);
try {
return await currentOperation;
} finally {
if (fileWriteOperations.get(source) === currentOperation) {
fileWriteOperations.delete(source);
}
}
}
/**
* @param {NetworkData} data
* @param {WriteNetworkFileOptions} [options]
* @returns {Promise<NetworkFileMetadata>}
*/
export function writeNetworkFile(data, options) {
const source = resolveDataFilePath();
return withFileWriteLock(source, () => writeNetworkFileUnlocked(data, source, options));
}
/**
* @returns {Promise<WritableState>}
*/
@@ -223,6 +311,7 @@ export async function getWritableState() {
}
try {
// eslint-disable-next-line no-bitwise -- Node fs access mode flags combine bitwise
await access(source, constants.R_OK | constants.W_OK);
} catch (error) {
return {
@@ -243,3 +332,87 @@ export async function getWritableState() {
export async function checkWritableState() {
return (await getWritableState()).writable;
}
/**
* Reads configured network data, creating a valid blank dataset only when the
* file is missing/blank and the deployment has confirmed writable storage.
*
* @returns {Promise<NetworkFileBootstrapResult>}
*/
async function initializeNetworkFile() {
const state = await readNetworkFileState();
if (state.status === 'ready') {
return {
data: state.data,
source: state.source,
updatedAt: state.updatedAt,
initialized: false
};
}
const writableState = await getWritableState();
if (!writableState.writable) {
const suffix = writableState.reason ? ` ${writableState.reason}` : '';
throw new Error(`Network data file "${state.source}" is ${state.status}.${suffix}`);
}
const data = {
machines: [...EMPTY_NETWORK_DATA.machines],
devices: [...EMPTY_NETWORK_DATA.devices]
};
let metadata;
try {
metadata = await withFileWriteLock(state.source, () =>
writeNetworkFileUnlocked(data, state.source, {
createBackup: false,
beforeCommit: async () => {
const currentState = await readNetworkFileState();
if (currentState.status === 'ready') {
throw new InitializationSupersededError();
}
}
})
);
} catch (error) {
if (!(error instanceof InitializationSupersededError)) {
throw error;
}
const currentState = await readNetworkFileState();
if (currentState.status !== 'ready') {
throw new Error(`Network data file "${currentState.source}" is ${currentState.status}.`);
}
return {
data: currentState.data,
source: currentState.source,
updatedAt: currentState.updatedAt,
initialized: false
};
}
return {
data,
source: metadata.source,
updatedAt: metadata.updatedAt,
initialized: true
};
}
/**
* @returns {Promise<NetworkFileBootstrapResult>}
*/
export function readOrInitializeNetworkFile() {
const source = resolveDataFilePath();
const existingFlight = initializationFlights.get(source);
if (existingFlight) {
return existingFlight;
}
const flight = initializeNetworkFile();
initializationFlights.set(source, flight);
const clearFlight = () => {
if (initializationFlights.get(source) === flight) {
initializationFlights.delete(source);
}
};
flight.then(clearFlight, clearFlight);
return flight;
}
+48 -15
View File
@@ -135,10 +135,15 @@ export function normalizePort(issues, path, value, ownerLabel) {
optional: true,
allowEmpty: true
});
const lengthM = readNumber(issues, `${path}.connectedTo.cable.lengthM`, cableRaw.lengthM, {
optional: true,
min: 0
});
const lengthM = readNumber(
issues,
`${path}.connectedTo.cable.lengthM`,
cableRaw.lengthM,
{
optional: true,
min: 0
}
);
const candidate = {
...(cableType ? { type: cableType } : {}),
...(cableColor ? { color: cableColor } : {}),
@@ -265,7 +270,10 @@ 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 notes = readString(issues, `${path}.notes`, value.notes, {
optional: true,
allowEmpty: true
});
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
const softwareRaw = value.software;
@@ -304,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`,
@@ -318,10 +334,15 @@ function normalizeMachine(issues, path, value) {
isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined,
{ optional: true, min: 0 }
),
gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, {
optional: true,
allowEmpty: true
})
gpu: readString(
issues,
`${path}.hardware.gpu`,
isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined,
{
optional: true,
allowEmpty: true
}
)
};
const portsRaw = value.ports;
@@ -389,7 +410,10 @@ 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);
@@ -523,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);
@@ -533,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);
@@ -581,7 +611,10 @@ export function validateNetworkData(value) {
}
const subnets = [];
const subnetEntries = [];
for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw) ? subnetsRaw : []).entries()) {
for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw)
? subnetsRaw
: []
).entries()) {
const subnet = normalizeSubnet(issues, `$.subnets[${subnetIndex}]`, subnetValue);
if (subnet) {
subnets.push(subnet);
+45 -6
View File
@@ -3,11 +3,14 @@ import { json } from '@sveltejs/kit';
import { validateNetworkData } from '$lib/data/networkSchema';
import {
getWritableState,
readNetworkFile,
readOrInitializeNetworkFile,
writeNetworkFile
} from '$lib/server/networkPersistence';
import type { RequestHandler } from './$types';
const staticDemoMode = import.meta.env.OND_STATIC_DEMO;
const staticDemoReason = 'Static demo deployment; changes are not persisted.';
function serializeValidationErrors(errors: Array<{ path: string; message: string }>) {
return errors.map((issue) => ({
path: issue.path,
@@ -22,10 +25,35 @@ function resolveReadOnlyErrorMessage(reason: string | null): string {
return `Write API unavailable: ${reason}`;
}
async function resolveFailedReadPayload(error: unknown) {
const writableState = await getWritableState().catch(() => ({
writable: false,
reason: null
}));
const message =
error instanceof Error && error.message ? error.message : 'Failed to read network data file';
return {
error: message,
writable: false,
writableReason: writableState.reason
};
}
export const GET: RequestHandler = async () => {
if (staticDemoMode) {
return json(
{
error: staticDemoReason,
writable: false,
writableReason: staticDemoReason
},
{ status: 503 }
);
}
try {
const [{ data, source, updatedAt }, writableState] = await Promise.all([
readNetworkFile(),
readOrInitializeNetworkFile(),
getWritableState()
]);
const validation = validateNetworkData(data);
@@ -33,7 +61,9 @@ export const GET: RequestHandler = async () => {
return json(
{
error: 'Stored network data is invalid',
details: serializeValidationErrors(validation.errors)
details: serializeValidationErrors(validation.errors),
writable: false,
writableReason: writableState.reason
},
{ status: 500 }
);
@@ -47,13 +77,22 @@ export const GET: RequestHandler = async () => {
updatedAt
});
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to read network data file';
return json({ error: message }, { status: 500 });
return json(await resolveFailedReadPayload(error), { status: 500 });
}
};
export const PUT: RequestHandler = async ({ request }) => {
if (staticDemoMode) {
return json(
{
error: resolveReadOnlyErrorMessage(staticDemoReason),
writable: false,
writableReason: staticDemoReason
},
{ status: 403 }
);
}
const writableStateBeforeWrite = await getWritableState();
if (!writableStateBeforeWrite.writable) {
return json(
+7 -6
View File
@@ -3,12 +3,13 @@ import staticAdapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const deployTarget = (process.env.DEPLOY_TARGET ?? 'docker').toLowerCase();
const adapter = deployTarget === 'netlify'
? netlifyAdapter()
: staticAdapter({
fallback: 'index.html',
strict: false
});
const adapter =
deployTarget === 'netlify'
? netlifyAdapter()
: staticAdapter({
fallback: 'index.html',
strict: false
});
/** @type {import('@sveltejs/kit').Config} */
const config = {
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
let moduleSequence = 0;
function restoreEnvironment(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
async function withPersistenceEnvironment(run, options = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), 'ond-persistence-'));
const source = path.join(root, 'data', 'network.json');
const backupDirectory = path.join(root, 'backups');
const previousDataFile = process.env.NETWORK_DATA_FILE;
const previousBackupDirectory = process.env.NETWORK_BACKUP_DIR;
const previousReadOnly = process.env.NETWORK_READ_ONLY;
process.env.NETWORK_DATA_FILE = source;
process.env.NETWORK_BACKUP_DIR = backupDirectory;
process.env.NETWORK_READ_ONLY = options.readOnly ? 'true' : 'false';
moduleSequence += 1;
try {
const persistence = await import(
`../src/lib/shared/networkPersistenceCore.mjs?test=${moduleSequence}`
);
await run({ persistence, root, source, backupDirectory });
} finally {
restoreEnvironment('NETWORK_DATA_FILE', previousDataFile);
restoreEnvironment('NETWORK_BACKUP_DIR', previousBackupDirectory);
restoreEnvironment('NETWORK_READ_ONLY', previousReadOnly);
await rm(root, { recursive: true, force: true });
}
}
test('initializes a missing writable data file with a blank network', async () => {
await withPersistenceEnvironment(async ({ persistence, source, backupDirectory }) => {
const result = await persistence.readOrInitializeNetworkFile();
assert.equal(result.initialized, true);
assert.deepEqual(result.data, { machines: [], devices: [] });
assert.equal(result.source, source);
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), {
machines: [],
devices: []
});
assert.deepEqual(await readdir(backupDirectory), []);
});
});
async function assertInitializesBlankFile(contents) {
await withPersistenceEnvironment(async ({ persistence, source }) => {
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
await writeFile(source, contents, 'utf8');
const result = await persistence.readOrInitializeNetworkFile();
assert.equal(result.initialized, true);
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), {
machines: [],
devices: []
});
});
}
test('initializes zero-byte and whitespace-only writable data files', async () => {
await assertInitializesBlankFile('');
await assertInitializesBlankFile(' \n\t');
});
test('does not rewrite an existing valid empty network', async () => {
await withPersistenceEnvironment(async ({ persistence, source }) => {
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
const before = await stat(source);
const result = await persistence.readOrInitializeNetworkFile();
const after = await stat(source);
assert.equal(result.initialized, false);
assert.deepEqual(result.data, { machines: [], devices: [] });
assert.equal(after.mtimeMs, before.mtimeMs);
});
});
test('does not overwrite real data written while a missing file is being initialized', async () => {
await withPersistenceEnvironment(async ({ persistence, source }) => {
const realData = {
machines: [{ machineName: 'Router' }],
devices: []
};
const bootstrap = persistence.readOrInitializeNetworkFile();
const write = persistence.writeNetworkFile(realData, { createBackup: false });
const [bootstrapResult] = await Promise.all([bootstrap, write]);
assert.equal(bootstrapResult.initialized, false);
assert.deepEqual(bootstrapResult.data, realData);
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), realData);
});
});
test('does not replace malformed or schema-invalid non-empty JSON', async () => {
await withPersistenceEnvironment(async ({ persistence, source }) => {
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
await writeFile(source, '{not json', 'utf8');
await assert.rejects(persistence.readOrInitializeNetworkFile(), SyntaxError);
assert.equal(await readFile(source, 'utf8'), '{not json');
});
await withPersistenceEnvironment(async ({ persistence, source }) => {
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
await writeFile(source, '{}\n', 'utf8');
const result = await persistence.readOrInitializeNetworkFile();
assert.equal(result.initialized, false);
assert.deepEqual(result.data, {});
assert.equal(await readFile(source, 'utf8'), '{}\n');
});
});
test('never initializes missing or blank files in explicit read-only mode', async () => {
await withPersistenceEnvironment(
async ({ persistence, source }) => {
await assert.rejects(
persistence.readOrInitializeNetworkFile(),
/Writes disabled by NETWORK_READ_ONLY=true/
);
await assert.rejects(stat(source), { code: 'ENOENT' });
},
{ readOnly: true }
);
await withPersistenceEnvironment(
async ({ persistence, source }) => {
await mkdir(path.dirname(source), { recursive: true });
await writeFile(source, ' \n', 'utf8');
await assert.rejects(
persistence.readOrInitializeNetworkFile(),
/Writes disabled by NETWORK_READ_ONLY=true/
);
assert.equal(await readFile(source, 'utf8'), ' \n');
},
{ readOnly: true }
);
});
test(
'unwritable blank files remain untouched and report the permission failure',
{ skip: process.platform === 'win32' },
async () => {
await withPersistenceEnvironment(async ({ persistence, source }) => {
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
await writeFile(source, '', 'utf8');
await chmod(source, 0o400);
try {
const writableState = await persistence.getWritableState();
assert.equal(writableState.writable, false);
assert.match(writableState.reason, /not readable and writable/);
await assert.rejects(
persistence.readOrInitializeNetworkFile(),
/not readable and writable/
);
assert.equal(await readFile(source, 'utf8'), '');
} finally {
await chmod(source, 0o600);
}
});
}
);
+5
View File
@@ -2,6 +2,11 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
const staticDemoMode = process.env.OND_STATIC_DEMO === 'true';
export default defineConfig({
define: {
'import.meta.env.OND_STATIC_DEMO': JSON.stringify(staticDemoMode)
},
plugins: [tailwindcss(), sveltekit()]
});