Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7118bbf8 | |||
| 7f1bad009b | |||
| 0b1d647aec | |||
| 5976b46cad | |||
| 6a1c8f870a | |||
| cab77a1e48 | |||
| d0a631de8f | |||
| a067cc3231 | |||
| 68496ae904 | |||
| e58e427093 | |||
| 94bc7085bf | |||
| 6517fcb373 | |||
| ee143e607a | |||
| fe1e3c2abf | |||
| ec7598e15c | |||
| b4f5f12731 | |||
| b4c7d1bce4 | |||
| 2144eda352 | |||
| acf63a9707 | |||
| 336ee5e91b | |||
| 9cea45945c | |||
| 72fd77b9c9 | |||
| b04bc0acc9 | |||
| d04536d09d | |||
| ad3dccbdd4 | |||
| 5bed9cafb8 | |||
| ca26ea9063 | |||
| b1a3f08d5e | |||
| 9ffd8c4750 | |||
| a192bcab06 | |||
| b107d5d357 | |||
| e6487f7d25 | |||
| 53059caf31 | |||
| 808fe5a440 | |||
| c2bffede3e | |||
| 2fd4291bb9 | |||
| a09c98ec46 | |||
| bf0eac88f4 | |||
| 129041368b | |||
| 3615c38794 | |||
| 3ce0adfa0b | |||
| a7dbcf426f | |||
| e7dd300098 | |||
| d2826f4cb3 | |||
| b1fb4fc141 | |||
| 1d656ff120 | |||
| 45203a4c28 | |||
| 4cbec53394 | |||
| 74680ca23c | |||
| 9836bddcf1 | |||
| 2ee356dfdf | |||
| 69a5dedf67 | |||
| 7bfab68d94 | |||
| 6d0b50a4bf | |||
| c4e790cddb | |||
| 5954f63f22 | |||
| b5d34456af | |||
| 726d89946d | |||
| d333d6af4a | |||
| 494518c2d9 | |||
| 5c3eed296a | |||
| b11026cfef | |||
| 1ef5bae8ee | |||
| 151f942d9d | |||
| a22846993f | |||
| 2f4a784646 | |||
| b9c7d9eee0 | |||
| 7957b15c29 |
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -16,14 +16,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: docker.io/${{ env.IMAGE_NAME }}
|
images: docker.io/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -31,11 +34,12 @@ jobs:
|
|||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
- name: Build (PR validation only)
|
- name: Build (PR validation only)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: false
|
push: false
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Docker Hub Description
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- README.md
|
||||||
|
- .github/workflows/dockerhub-description.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dockerhub-description:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Preflight Docker Hub repository access
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
DOCKERHUB_REPOSITORY: jcreek23/open-network-diagram
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_PASSWORD}" ]; then
|
||||||
|
echo "Missing DOCKERHUB_USERNAME or DOCKERHUB_PASSWORD secret."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOGIN_RESPONSE="$(curl -fsS -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_PASSWORD}\"}" \
|
||||||
|
https://hub.docker.com/v2/users/login/)"
|
||||||
|
|
||||||
|
TOKEN="$(printf '%s' "${LOGIN_RESPONSE}" | ruby -rjson -e 'input = STDIN.read; data = JSON.parse(input); puts data.fetch("token", "")')"
|
||||||
|
|
||||||
|
if [ -z "${TOKEN}" ]; then
|
||||||
|
echo "Docker Hub login succeeded but token was missing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: JWT ${TOKEN}" \
|
||||||
|
"https://hub.docker.com/v2/repositories/${DOCKERHUB_REPOSITORY}/" >/dev/null
|
||||||
|
|
||||||
|
echo "Docker Hub preflight access check passed for ${DOCKERHUB_REPOSITORY}."
|
||||||
|
|
||||||
|
- name: Update Docker Hub repository overview
|
||||||
|
uses: peter-evans/dockerhub-description@v5
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
repository: jcreek23/open-network-diagram
|
||||||
|
readme-filepath: ./README.md
|
||||||
|
enable-url-completion: true
|
||||||
@@ -27,18 +27,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Create Git tag + GitHub release
|
- name: Create Git tag + GitHub release
|
||||||
id: semantic
|
id: semantic
|
||||||
uses: cycjimmy/semantic-release-action@v5.0.2
|
uses: cycjimmy/semantic-release-action@v6
|
||||||
with:
|
with:
|
||||||
extra_plugins: |
|
extra_plugins: |
|
||||||
@semantic-release/commit-analyzer
|
@semantic-release/commit-analyzer
|
||||||
@@ -57,20 +57,23 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: docker.io/${{ env.IMAGE_NAME }}
|
images: docker.io/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -80,11 +83,12 @@ jobs:
|
|||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# User-provided Docker data
|
# User-provided Docker data
|
||||||
data/network.json
|
data/network.json
|
||||||
|
data/.backups
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
|
|||||||
@@ -4,3 +4,9 @@ pnpm-lock.yaml
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
bun.lock
|
bun.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
src/lib/config/vendorIconManifest.ts
|
||||||
|
|
||||||
|
# Design handoff bundles (prototypes, not code)
|
||||||
|
new-design/
|
||||||
|
claude-design/
|
||||||
|
homelab-hub-main/
|
||||||
|
|||||||
@@ -15,24 +15,28 @@ COPY . .
|
|||||||
ENV DEPLOY_TARGET=docker
|
ENV DEPLOY_TARGET=docker
|
||||||
RUN pnpm run build:docker
|
RUN pnpm run build:docker
|
||||||
|
|
||||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
USER root
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
RUN apk add --no-cache libcap && \
|
RUN addgroup -S appuser && adduser -S -G appuser appuser
|
||||||
setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx
|
|
||||||
|
|
||||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
COPY --from=builder /app/build ./build
|
||||||
COPY --from=builder /app/build /usr/share/nginx/html
|
COPY --from=builder /app/server.mjs ./server.mjs
|
||||||
|
COPY --from=builder /app/src/lib/shared ./src/lib/shared
|
||||||
|
COPY --from=builder /app/data ./data
|
||||||
|
|
||||||
RUN mkdir -p /usr/share/nginx/html/data && \
|
RUN chown -R appuser:appuser /app/build /app/server.mjs /app/data /app \
|
||||||
chown -R 101:101 /usr/share/nginx/html /var/cache/nginx /var/run /etc/nginx/conf.d
|
&& chmod -R u+rwX /app /app/data
|
||||||
|
|
||||||
USER 101
|
EXPOSE 3000
|
||||||
|
|
||||||
EXPOSE 80
|
USER appuser
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://127.0.0.1/ > /dev/null || exit 1
|
CMD wget -qO- http://127.0.0.1:3000/ > /dev/null || exit 1
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["node", "server.mjs"]
|
||||||
|
|||||||
@@ -1,143 +1,221 @@
|
|||||||
# **Open Network Diagram**
|
# Open Network Diagram
|
||||||
|
|
||||||

|
[](https://hub.docker.com/r/jcreek23/open-network-diagram)
|
||||||

|
[](https://github.com/jcreek/OpenNetworkDiagram/actions/workflows/release.yml)
|
||||||

|
[](https://github.com/jcreek/OpenNetworkDiagram/actions/workflows/docker.yml)
|
||||||

|
[](https://github.com/jcreek/OpenNetworkDiagram/releases)
|
||||||

|
[](https://opennetworkdiagram.jcreek.co.uk)
|
||||||
|
|
||||||
**A declarative, self-hosted tool for visualising and managing home lab & network architecture diagrams.**
|
**A declarative, self-hosted containerised tool for visualising and managing home lab & network architecture diagrams.**
|
||||||
|
|
||||||
---
|
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can back up, reuse, and optionally version after reviewing it for sensitive information.
|
||||||
|
|
||||||
## **📝 About**
|
- Homelab-friendly: run it in minutes with Docker.
|
||||||
|
- Practical: edit in the UI and autosave to `network.json`.
|
||||||
|
- Declarative: keep your topology in Git if you wish.
|
||||||
|
|
||||||
**Open Network Diagram** is an **open-source, self-hosted tool** for creating **interactive network and infrastructure diagrams** using a **declarative JSON format**.
|
[Docker Hub](https://hub.docker.com/r/jcreek23/open-network-diagram) | [Live Demo (Read-Only)](https://opennetworkdiagram.jcreek.co.uk) | [GitHub Releases](https://github.com/jcreek/OpenNetworkDiagram/releases)
|
||||||
|
|
||||||
✅ **Fully self-hostable via Docker**
|

|
||||||
✅ **Docker-first deployment target** (Netlify optional for demo hosting)
|
|
||||||
✅ **Interactive network visualisation**
|
|
||||||
✅ **Simple file-based configuration**
|
|
||||||
✅ **Lightweight Svelte**
|
|
||||||
|
|
||||||
Use it to **document your home lab, office network, or cloud infrastructure** with an easy-to-use web interface.
|
## Features
|
||||||
|
|
||||||
---
|
- Network view with ethernet labels to make physical and logical links easy to read.
|
||||||
|
- Hosts & VMs view for host-first inventory and service mapping.
|
||||||
|
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
|
||||||
|
- Node search that highlights matches by name, IP, role or OS and dims everything else.
|
||||||
|
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
|
||||||
|
- Subnet and VLAN declarations, with nodes colour-coded by VLAN and a clickable legend filter.
|
||||||
|
- Cable tracking on connections (type, colour, length) with colour-coded links and hover details.
|
||||||
|
- Expandable VM lists per machine for quick virtualization visibility.
|
||||||
|
- Modal editor for machines/devices with live diagram updates, notes and MAC address fields.
|
||||||
|
- JSON-backed persistence with autosave in self-hosted mode.
|
||||||
|
- Docker-first deployment with writable data volume support.
|
||||||
|
- Optional read-only mode for public demos and safe sharing.
|
||||||
|
- Local vendored icon catalog for offline-friendly runtime behavior.
|
||||||
|
|
||||||
## **🚀 Quick Start (For Users)**
|
## Why Home Lab Users Use It
|
||||||
|
|
||||||
### **1️⃣ Create Your Runtime Data File**
|
- Keep an always-up-to-date map of machines, VMs, and devices.
|
||||||
|
- Find a free IP and spot duplicate assignments without a spreadsheet.
|
||||||
|
- Know exactly what's in your rack and where, down to the U.
|
||||||
|
- Edit quickly through a modal UI instead of hand-editing large diagrams.
|
||||||
|
- Persist everything to JSON so backups and Git workflows stay simple.
|
||||||
|
- Stay fully self-hosted with no runtime dependency on external APIs.
|
||||||
|
|
||||||
Copy the template and edit your own network data:
|
## 2-Minute Docker Quick Start
|
||||||
|
|
||||||
|
This is the fastest way to run Open Network Diagram for a home lab.
|
||||||
|
|
||||||
|
1. Create a local data folder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp data/network.json.example data/network.json
|
mkdir -p data
|
||||||
```
|
```
|
||||||
|
|
||||||
### **2️⃣ Run Open Network Diagram via Docker**
|
2. Run the published Docker image:
|
||||||
|
|
||||||
From this repo:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
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)/data:/app/data" \
|
||||||
|
jcreek23/open-network-diagram:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Or pull and run directly:
|
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. 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 8080:80 -v "$(pwd)/data:/usr/share/nginx/html/data:ro" jcreek23/open-network-diagram
|
docker logs -f open-network-diagram
|
||||||
|
docker stop open-network-diagram
|
||||||
|
docker rm open-network-diagram
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`-p 8080:80`** → Maps the app to `http://localhost:8080`
|
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.
|
||||||
- **`-v .../data:/usr/share/nginx/html/data:ro`** → Uses your local `data/network.json`
|
|
||||||
|
|
||||||
### **3️⃣ Open the Web UI**
|
### Optional Sample Data
|
||||||
|
|
||||||
Visit **`http://localhost:8080`** to view your network diagram.
|
The application starts with a blank network by default. To explore the sample topology instead, download it before starting the container:
|
||||||
|
|
||||||
### **4️⃣ Modify Your Network (JSON-Based)**
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example \
|
||||||
|
-o data/network.json
|
||||||
|
```
|
||||||
|
|
||||||
- Docker runtime reads **`/data/network.json`** from the mounted volume.
|
The sample is optional and is never required for startup.
|
||||||
- In this repo, your editable file is **`data/network.json`** (gitignored).
|
|
||||||
- Netlify demo builds use committed sample data at **`static/data/network.json`**.
|
|
||||||
- After editing JSON, click **Reload Default JSON** in the UI.
|
|
||||||
|
|
||||||
---
|
## Deployment Security
|
||||||
|
|
||||||
## **👩💻 Development Setup**
|
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.
|
||||||
|
|
||||||
### **1️⃣ Clone the Repository**
|
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 | Hosts & VMs view with VMs expanded | Modal editing a machine |
|
||||||
|
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
|
||||||
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
| Zero-setup first run | Read-only live demo |
|
||||||
|
| ------------------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## Docker Compose Option
|
||||||
|
|
||||||
|
If you prefer compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
NETWORK_DATA_FILE: /app/data/network.json
|
||||||
|
NETWORK_BACKUP_DIR: /app/data/.backups
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Start it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/jcreek/OpenNetworkDiagram.git
|
git clone https://github.com/jcreek/OpenNetworkDiagram.git
|
||||||
cd open-network-diagram
|
cd OpenNetworkDiagram
|
||||||
```
|
|
||||||
|
|
||||||
### **2️⃣ Install Dependencies**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
|
||||||
|
|
||||||
### **3️⃣ Run in Development Mode**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Runs at `http://localhost:5173`
|
App URL: `http://localhost:5173`
|
||||||
|
|
||||||
### **4️⃣ Build Targets**
|
### Build Targets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run build # default (Docker/static target)
|
pnpm run build # default build
|
||||||
pnpm run build:docker # explicit Docker/static target
|
pnpm run build:docker # Docker/static target
|
||||||
pnpm run build:netlify # explicit Netlify target
|
pnpm run build:netlify # Netlify target (read-only mode)
|
||||||
|
pnpm run icons:manifest # regenerate local vendor icon manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Runtime and Persistence
|
||||||
|
|
||||||
## **🛠️ Project Structure**
|
- API endpoint: `GET/PUT /api/network-data`
|
||||||
|
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
||||||
|
- 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)
|
||||||
|
|
||||||
```text
|
Environment variables:
|
||||||
open-network-diagram/
|
|
||||||
├── src/ # Svelte app source
|
|
||||||
├── static/data/network.json # Demo dataset (Netlify/demo)
|
|
||||||
├── data/network.json.example # User data template (Docker)
|
|
||||||
├── Dockerfile # Docker build/runtime
|
|
||||||
├── docker-compose.yml # Local Docker run with mounted data
|
|
||||||
├── netlify.toml # Netlify build config
|
|
||||||
├── .github/workflows/ # CI workflows (PR build + automated release/publish)
|
|
||||||
└── README.md # Documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- `NETWORK_READ_ONLY` (default: `false`)
|
||||||
|
- Set to `true` to disable writes and force read-only mode.
|
||||||
|
- `NETWORK_DATA_FILE` (default: `data/network.json`)
|
||||||
|
- JSON file path to read/write.
|
||||||
|
- `NETWORK_BACKUP_DIR` (default: `data/.backups`)
|
||||||
|
- Directory for backup files.
|
||||||
|
|
||||||
## **📦 Docker Build & Deployment**
|
### JSON Example
|
||||||
|
|
||||||
### **Build the Docker Image Locally**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t open-network-diagram .
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Run Locally**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 8080:80 -v "$(pwd)/data:/usr/share/nginx/html/data:ro" open-network-diagram
|
|
||||||
```
|
|
||||||
|
|
||||||
### **CI/CD (GitHub Actions + Netlify)**
|
|
||||||
|
|
||||||
- GitHub Actions workflow (`.github/workflows/docker.yml`) builds Docker on PRs (validation only).
|
|
||||||
- GitHub Actions workflow (`.github/workflows/release.yml`) runs on `main`, creates semantic version tags/releases, and publishes Docker images to **Docker Hub** (`jcreek23/open-network-diagram`).
|
|
||||||
- Netlify uses its own CI/CD pipeline with `netlify.toml` (`pnpm run build:netlify`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **📝 JSON Network Configuration Example**
|
|
||||||
|
|
||||||
Define your network using **`network.json`**:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -147,51 +225,110 @@ Define your network using **`network.json`**:
|
|||||||
"ipAddress": "10.0.0.3",
|
"ipAddress": "10.0.0.3",
|
||||||
"role": "Hypervisor",
|
"role": "Hypervisor",
|
||||||
"operatingSystem": "Proxmox",
|
"operatingSystem": "Proxmox",
|
||||||
|
"notes": "Primary router box.",
|
||||||
"software": {
|
"software": {
|
||||||
"vms": [{ "name": "OpnSense", "role": "Router", "ipAddress": "10.0.0.4" }]
|
"vms": [
|
||||||
|
{
|
||||||
|
"name": "OpnSense",
|
||||||
|
"role": "Router",
|
||||||
|
"ipAddress": "10.0.0.4",
|
||||||
|
"macAddress": "bc:24:11:5a:2e:01"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"hardware": {
|
"hardware": {
|
||||||
"cpu": "Intel N100",
|
"cpu": "Intel N100",
|
||||||
"ram": "8GB",
|
"ram": "8GB",
|
||||||
"networkPorts": 4
|
"networkPorts": 4
|
||||||
}
|
},
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "eth0",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "Switch",
|
||||||
|
"port": "1",
|
||||||
|
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rack": { "name": "Lab Rack", "unit": 10 }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"devices": [{ "name": "Switch", "ipAddress": "10.0.0.2", "type": "Nintendo Switch" }]
|
"devices": [
|
||||||
|
{
|
||||||
|
"name": "Switch",
|
||||||
|
"ipAddress": "10.0.0.2",
|
||||||
|
"type": "Network Switch",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "1",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "ProxRouter",
|
||||||
|
"port": "eth0",
|
||||||
|
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rack": { "name": "Lab Rack", "unit": 12 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subnets": [{ "cidr": "10.0.0.0/24", "name": "LAN", "vlanId": 1 }],
|
||||||
|
"racks": [{ "name": "Lab Rack", "heightU": 12 }]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Notes on the optional fields:
|
||||||
|
|
||||||
## **🔜 Roadmap**
|
- `subnets` powers the IPAM panel and VLAN colouring (`vlanId` is optional per subnet).
|
||||||
|
- `racks` declares rack names and total units for the rack view; machines/devices opt in with a `rack` placement (`unit` is the bottom U, `heightU` defaults to 1). Items with the identical U range render side by side as shelf-mates.
|
||||||
|
- `cable` on a `connectedTo` records type/colour/length and is kept identical on both ends automatically.
|
||||||
|
- `notes` (machines and devices) and `macAddress` (ports and VMs) are free text.
|
||||||
|
|
||||||
✅ **Initial version with JSON-based diagrams**
|
### Project Structure
|
||||||
⏳ **Drag-and-drop editing in the UI**
|
|
||||||
⏳ **Custom icons for different devices**
|
|
||||||
⏳ **Export diagrams as PNG/SVG/Graphviz**
|
|
||||||
⏳ **Dark mode & UI themes**
|
|
||||||
|
|
||||||
---
|
```text
|
||||||
|
OpenNetworkDiagram/
|
||||||
|
├── src/ # Svelte app source
|
||||||
|
├── src/lib/shared/ # Schema + persistence core (shared with server.mjs)
|
||||||
|
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
|
||||||
|
├── static/data/network.json # Demo dataset (Netlify)
|
||||||
|
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
|
||||||
|
├── data/network.json.example # Optional starter data template
|
||||||
|
├── third_party/ # Third-party provenance + licensing
|
||||||
|
├── Dockerfile # Docker build/runtime image
|
||||||
|
├── server.mjs # Node runtime server (static + API)
|
||||||
|
├── docker-compose.yml # Local compose example (build from repo)
|
||||||
|
├── netlify.toml # Netlify build config
|
||||||
|
└── .github/workflows/ # CI workflows
|
||||||
|
```
|
||||||
|
|
||||||
## **🤝 Contributing**
|
### CI/CD
|
||||||
|
|
||||||
We welcome contributions! To contribute:
|
- `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)
|
||||||
|
|
||||||
1. **Fork the repository**.
|
## Contributing
|
||||||
2. **Create a feature branch** (`git checkout -b feature-name`).
|
|
||||||
3. **Commit your changes** (`git commit -m "Add feature X"`).
|
|
||||||
4. **Push to your fork** (`git push origin feature-name`).
|
|
||||||
5. **Submit a Pull Request**.
|
|
||||||
|
|
||||||
---
|
1. Fork the repository.
|
||||||
|
2. Create a feature branch.
|
||||||
|
3. Before committing, run:
|
||||||
|
|
||||||
## **📜 License**
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm check
|
||||||
|
pnpm run build:docker
|
||||||
|
pnpm run build:netlify
|
||||||
|
```
|
||||||
|
|
||||||
[GNU GPL v3 License](LICENSE) – Free to use, modify, and distribute, except distributing closed source versions.
|
4. Commit your changes.
|
||||||
|
5. Push your branch.
|
||||||
|
6. Open a pull request.
|
||||||
|
|
||||||
---
|
## License
|
||||||
|
|
||||||
## **📬 Contact**
|
[GNU GPL v3](LICENSE)
|
||||||
|
|
||||||
**Author:** [Joshua Creek](https://github.com/jcreek)
|
|
||||||
**Project Repo:** [GitHub](https://github.com/jcreek/OpenNetworkDiagram)
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
"ipAddress": "192.168.1.10",
|
"ipAddress": "192.168.1.10",
|
||||||
"role": "Hypervisor",
|
"role": "Hypervisor",
|
||||||
"operatingSystem": "Linux",
|
"operatingSystem": "Linux",
|
||||||
|
"notes": "Optional notes",
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
|
"macAddress": "aa:bb:cc:dd:ee:01",
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Example Switch",
|
"device": "Example Switch",
|
||||||
"port": "1"
|
"port": "1"
|
||||||
@@ -20,7 +22,8 @@
|
|||||||
{
|
{
|
||||||
"name": "Example VM",
|
"name": "Example VM",
|
||||||
"role": "Service",
|
"role": "Service",
|
||||||
"ipAddress": "192.168.1.20"
|
"ipAddress": "192.168.1.20",
|
||||||
|
"macAddress": "aa:bb:cc:dd:ee:02"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -39,6 +42,10 @@
|
|||||||
"ipAddress": "192.168.1.2",
|
"ipAddress": "192.168.1.2",
|
||||||
"type": "Network Switch",
|
"type": "Network Switch",
|
||||||
"notes": "Optional notes",
|
"notes": "Optional notes",
|
||||||
|
"rack": {
|
||||||
|
"name": "Example Rack",
|
||||||
|
"unit": 12
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "1",
|
"portName": "1",
|
||||||
@@ -50,5 +57,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"subnets": [
|
||||||
|
{
|
||||||
|
"cidr": "192.168.1.0/24",
|
||||||
|
"name": "LAN",
|
||||||
|
"vlanId": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"racks": [
|
||||||
|
{
|
||||||
|
"name": "Example Rack",
|
||||||
|
"heightU": 24
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: open-network-diagram:local
|
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:
|
ports:
|
||||||
- "8080:80"
|
- '8080:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/usr/share/nginx/html/data:ro
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
NETWORK_DATA_FILE: '/app/data/network.json'
|
||||||
|
NETWORK_BACKUP_DIR: '/app/data/.backups'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
'import/resolver': {
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
|
project: ['./tsconfig.json']
|
||||||
|
},
|
||||||
node: {
|
node: {
|
||||||
extensions: ['.js', '.mjs', '.cjs', '.ts', '.d.ts', '.svelte', '.json']
|
extensions: ['.js', '.mjs', '.cjs', '.ts', '.d.ts', '.svelte', '.json']
|
||||||
}
|
}
|
||||||
@@ -57,17 +61,10 @@ export default tseslint.config(
|
|||||||
'no-inner-declarations': 'off',
|
'no-inner-declarations': 'off',
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'import/no-extraneous-dependencies': 'off',
|
'import/no-extraneous-dependencies': 'off',
|
||||||
|
'import/no-unresolved': 'error',
|
||||||
|
'import/prefer-default-export': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
'import/extensions': [
|
'import/extensions': 'off'
|
||||||
'error',
|
|
||||||
'ignorePackages',
|
|
||||||
{
|
|
||||||
js: 'never',
|
|
||||||
mjs: 'never',
|
|
||||||
cjs: 'never',
|
|
||||||
ts: 'never'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:docker": "DEPLOY_TARGET=docker vite build",
|
"build:docker": "DEPLOY_TARGET=docker vite build",
|
||||||
"build:netlify": "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",
|
"preview": "vite preview",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"icons:manifest": "node scripts/generate-vendor-icon-manifest.mjs",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint ."
|
||||||
},
|
},
|
||||||
@@ -27,15 +30,17 @@
|
|||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/cytoscape-dagre": "^2.3.3",
|
"@types/cytoscape-dagre": "^2.3.3",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -51,6 +56,7 @@
|
|||||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cytoscape": "^3.31.2",
|
"cytoscape": "^3.31.2",
|
||||||
"cytoscape-dagre": "^2.5.0"
|
"cytoscape-dagre": "^2.5.0",
|
||||||
|
"html-to-image": "^1.11.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ importers:
|
|||||||
cytoscape-dagre:
|
cytoscape-dagre:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0(cytoscape@3.33.1)
|
version: 2.5.0(cytoscape@3.33.1)
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.2.5
|
specifier: ^1.2.5
|
||||||
@@ -26,40 +29,46 @@ importers:
|
|||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
'@sveltejs/adapter-auto':
|
'@sveltejs/adapter-auto':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))
|
version: 4.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))
|
||||||
'@sveltejs/adapter-netlify':
|
'@sveltejs/adapter-netlify':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))
|
version: 5.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))
|
||||||
'@sveltejs/adapter-static':
|
'@sveltejs/adapter-static':
|
||||||
specifier: ^3.0.10
|
specifier: ^3.0.10
|
||||||
version: 3.0.10(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))
|
version: 3.0.10(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))
|
||||||
'@sveltejs/kit':
|
'@sveltejs/kit':
|
||||||
specifier: ^2.16.0
|
specifier: ^2.16.0
|
||||||
version: 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
version: 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
'@sveltejs/vite-plugin-svelte':
|
'@sveltejs/vite-plugin-svelte':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
version: 5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.15
|
specifier: ^0.5.15
|
||||||
version: 0.5.16(tailwindcss@4.1.3)
|
version: 0.5.16(tailwindcss@4.1.3)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.1.3(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
version: 4.1.3(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
'@types/cytoscape-dagre':
|
'@types/cytoscape-dagre':
|
||||||
specifier: ^2.3.3
|
specifier: ^2.3.3
|
||||||
version: 2.3.3
|
version: 2.3.3
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.13.10
|
||||||
|
version: 22.19.11
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.57.1
|
specifier: ^8.57.1
|
||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
eslint-config-airbnb-base:
|
eslint-config-airbnb-base:
|
||||||
specifier: ^15.0.0
|
specifier: ^15.0.0
|
||||||
version: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
version: 15.0.0(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
specifier: ^10.1.8
|
specifier: ^10.1.8
|
||||||
version: 10.1.8(eslint@8.57.1)
|
version: 10.1.8(eslint@8.57.1)
|
||||||
|
eslint-import-resolver-typescript:
|
||||||
|
specifier: ^4.4.4
|
||||||
|
version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||||
eslint-plugin-import:
|
eslint-plugin-import:
|
||||||
specifier: ^2.32.0
|
specifier: ^2.32.0
|
||||||
version: 2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
|
version: 2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1)
|
||||||
eslint-plugin-svelte:
|
eslint-plugin-svelte:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.5.1(eslint@8.57.1)(svelte@5.25.8)
|
version: 3.5.1(eslint@8.57.1)(svelte@5.25.8)
|
||||||
@@ -70,17 +79,17 @@ importers:
|
|||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
specifier: ^3.3.3
|
specifier: ^4.1.1
|
||||||
version: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
version: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||||
prettier-plugin-tailwindcss:
|
prettier-plugin-tailwindcss:
|
||||||
specifier: ^0.6.11
|
specifier: ^0.8.0
|
||||||
version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
version: 0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.25.8
|
version: 5.25.8
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.1.5(svelte@5.25.8)(typescript@5.9.3)
|
version: 4.1.5(picomatch@4.0.3)(svelte@5.25.8)(typescript@5.9.3)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.1.3
|
version: 4.1.3
|
||||||
@@ -92,7 +101,7 @@ importers:
|
|||||||
version: 8.29.1(eslint@8.57.1)(typescript@5.9.3)
|
version: 8.29.1(eslint@8.57.1)(typescript@5.9.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.2.5
|
specifier: ^6.2.5
|
||||||
version: 6.2.5(jiti@2.4.2)(lightningcss@1.29.2)
|
version: 6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -100,6 +109,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@emnapi/core@1.8.1':
|
||||||
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.8.1':
|
||||||
|
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
|
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.24.2':
|
'@esbuild/aix-ppc64@0.24.2':
|
||||||
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
|
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -465,6 +483,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -710,6 +731,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6
|
vite: ^5.2.0 || ^6
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.1':
|
||||||
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
'@types/cookie@0.6.0':
|
'@types/cookie@0.6.0':
|
||||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||||
|
|
||||||
@@ -725,6 +749,9 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
|
'@types/node@22.19.11':
|
||||||
|
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.29.1':
|
'@typescript-eslint/eslint-plugin@8.29.1':
|
||||||
resolution: {integrity: sha512-ba0rr4Wfvg23vERs3eB+P3lfj2E+2g3lhWcCVukUuhtcdUx5lSIFZlGFEBHKr+3zizDa/TvZTptdNHVZWAkSBg==}
|
resolution: {integrity: sha512-ba0rr4Wfvg23vERs3eB+P3lfj2E+2g3lhWcCVukUuhtcdUx5lSIFZlGFEBHKr+3zizDa/TvZTptdNHVZWAkSBg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -775,6 +802,101 @@ packages:
|
|||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
|
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-android-arm64@1.11.1':
|
||||||
|
resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-darwin-arm64@1.11.1':
|
||||||
|
resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-darwin-x64@1.11.1':
|
||||||
|
resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-freebsd-x64@1.11.1':
|
||||||
|
resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1':
|
||||||
|
resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm-musleabihf@1.11.1':
|
||||||
|
resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm64-gnu@1.11.1':
|
||||||
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-win32-arm64-msvc@1.11.1':
|
||||||
|
resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-win32-ia32-msvc@1.11.1':
|
||||||
|
resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
|
resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -947,6 +1069,15 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
@@ -1040,9 +1171,31 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=7.0.0'
|
eslint: '>=7.0.0'
|
||||||
|
|
||||||
|
eslint-import-context@0.1.9:
|
||||||
|
resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
unrs-resolver: ^1.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
unrs-resolver:
|
||||||
|
optional: true
|
||||||
|
|
||||||
eslint-import-resolver-node@0.3.9:
|
eslint-import-resolver-node@0.3.9:
|
||||||
resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==}
|
resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==}
|
||||||
|
|
||||||
|
eslint-import-resolver-typescript@4.4.4:
|
||||||
|
resolution: {integrity: sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==}
|
||||||
|
engines: {node: ^16.17.0 || >=18.6.0}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: '*'
|
||||||
|
eslint-plugin-import: '*'
|
||||||
|
eslint-plugin-import-x: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
eslint-plugin-import:
|
||||||
|
optional: true
|
||||||
|
eslint-plugin-import-x:
|
||||||
|
optional: true
|
||||||
|
|
||||||
eslint-module-utils@2.12.1:
|
eslint-module-utils@2.12.1:
|
||||||
resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==}
|
resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1160,6 +1313,15 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fdir@6.5.0:
|
||||||
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
|
||||||
file-entry-cache@6.0.1:
|
file-entry-cache@6.0.1:
|
||||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||||
engines: {node: ^10.12.0 || >=12.0.0}
|
engines: {node: ^10.12.0 || >=12.0.0}
|
||||||
@@ -1217,6 +1379,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-tsconfig@4.13.6:
|
||||||
|
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1285,6 +1450,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -1327,6 +1495,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-bun-module@2.0.0:
|
||||||
|
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
|
||||||
|
|
||||||
is-callable@1.2.7:
|
is-callable@1.2.7:
|
||||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1587,6 +1758,11 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
napi-postinstall@0.3.4:
|
||||||
|
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
@@ -1663,6 +1839,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
picomatch@4.0.3:
|
||||||
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1707,17 +1887,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
prettier-plugin-svelte@3.3.3:
|
prettier-plugin-svelte@4.1.1:
|
||||||
resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==}
|
resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prettier: ^3.0.0
|
prettier: ^3.0.0
|
||||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
prettier-plugin-tailwindcss@0.6.11:
|
prettier-plugin-tailwindcss@0.8.0:
|
||||||
resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
|
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=20.19'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||||
|
'@prettier/plugin-hermes': '*'
|
||||||
|
'@prettier/plugin-oxc': '*'
|
||||||
'@prettier/plugin-pug': '*'
|
'@prettier/plugin-pug': '*'
|
||||||
'@shopify/prettier-plugin-liquid': '*'
|
'@shopify/prettier-plugin-liquid': '*'
|
||||||
'@trivago/prettier-plugin-sort-imports': '*'
|
'@trivago/prettier-plugin-sort-imports': '*'
|
||||||
@@ -1725,18 +1908,20 @@ packages:
|
|||||||
prettier: ^3.0
|
prettier: ^3.0
|
||||||
prettier-plugin-astro: '*'
|
prettier-plugin-astro: '*'
|
||||||
prettier-plugin-css-order: '*'
|
prettier-plugin-css-order: '*'
|
||||||
prettier-plugin-import-sort: '*'
|
|
||||||
prettier-plugin-jsdoc: '*'
|
prettier-plugin-jsdoc: '*'
|
||||||
prettier-plugin-marko: '*'
|
prettier-plugin-marko: '*'
|
||||||
prettier-plugin-multiline-arrays: '*'
|
prettier-plugin-multiline-arrays: '*'
|
||||||
prettier-plugin-organize-attributes: '*'
|
prettier-plugin-organize-attributes: '*'
|
||||||
prettier-plugin-organize-imports: '*'
|
prettier-plugin-organize-imports: '*'
|
||||||
prettier-plugin-sort-imports: '*'
|
prettier-plugin-sort-imports: '*'
|
||||||
prettier-plugin-style-order: '*'
|
|
||||||
prettier-plugin-svelte: '*'
|
prettier-plugin-svelte: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@ianvs/prettier-plugin-sort-imports':
|
'@ianvs/prettier-plugin-sort-imports':
|
||||||
optional: true
|
optional: true
|
||||||
|
'@prettier/plugin-hermes':
|
||||||
|
optional: true
|
||||||
|
'@prettier/plugin-oxc':
|
||||||
|
optional: true
|
||||||
'@prettier/plugin-pug':
|
'@prettier/plugin-pug':
|
||||||
optional: true
|
optional: true
|
||||||
'@shopify/prettier-plugin-liquid':
|
'@shopify/prettier-plugin-liquid':
|
||||||
@@ -1749,8 +1934,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-css-order:
|
prettier-plugin-css-order:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-import-sort:
|
|
||||||
optional: true
|
|
||||||
prettier-plugin-jsdoc:
|
prettier-plugin-jsdoc:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-marko:
|
prettier-plugin-marko:
|
||||||
@@ -1763,8 +1946,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-sort-imports:
|
prettier-plugin-sort-imports:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-style-order:
|
|
||||||
optional: true
|
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1796,6 +1977,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1890,6 +2074,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stable-hash-x@0.2.0:
|
||||||
|
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1957,6 +2145,10 @@ packages:
|
|||||||
text-table@0.2.0:
|
text-table@0.2.0:
|
||||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||||
|
|
||||||
|
tinyglobby@0.2.15:
|
||||||
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -1974,6 +2166,9 @@ packages:
|
|||||||
tsconfig-paths@3.15.0:
|
tsconfig-paths@3.15.0:
|
||||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2014,6 +2209,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
undici-types@6.21.0:
|
||||||
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
unrs-resolver@1.11.1:
|
||||||
|
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
@@ -2114,6 +2315,22 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.8
|
'@jridgewell/gen-mapping': 0.3.8
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
||||||
|
'@emnapi/core@1.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/wasi-threads': 1.1.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.8.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.24.2':
|
'@esbuild/aix-ppc64@0.24.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2336,6 +2553,13 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.8.1
|
||||||
|
'@emnapi/runtime': 1.8.1
|
||||||
|
'@tybys/wasm-util': 0.10.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -2416,25 +2640,25 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.14.1
|
acorn: 8.14.1
|
||||||
|
|
||||||
'@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))':
|
'@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/kit': 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
'@sveltejs/kit': 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
import-meta-resolve: 4.1.0
|
import-meta-resolve: 4.1.0
|
||||||
|
|
||||||
'@sveltejs/adapter-netlify@5.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))':
|
'@sveltejs/adapter-netlify@5.0.0(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iarna/toml': 2.2.5
|
'@iarna/toml': 2.2.5
|
||||||
'@sveltejs/kit': 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
'@sveltejs/kit': 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
esbuild: 0.24.2
|
esbuild: 0.24.2
|
||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.1
|
||||||
|
|
||||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))':
|
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/kit': 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
'@sveltejs/kit': 2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
|
|
||||||
'@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))':
|
'@sveltejs/kit@2.20.4(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
'@types/cookie': 0.6.0
|
'@types/cookie': 0.6.0
|
||||||
cookie: 0.6.0
|
cookie: 0.6.0
|
||||||
devalue: 5.1.1
|
devalue: 5.1.1
|
||||||
@@ -2447,27 +2671,27 @@ snapshots:
|
|||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.1
|
||||||
sirv: 3.0.1
|
sirv: 3.0.1
|
||||||
svelte: 5.25.8
|
svelte: 5.25.8
|
||||||
vite: 6.2.5(jiti@2.4.2)(lightningcss@1.29.2)
|
vite: 6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))':
|
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
debug: 4.4.0
|
debug: 4.4.0
|
||||||
svelte: 5.25.8
|
svelte: 5.25.8
|
||||||
vite: 6.2.5(jiti@2.4.2)(lightningcss@1.29.2)
|
vite: 6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))':
|
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.8)(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
debug: 4.4.0
|
debug: 4.4.0
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
kleur: 4.1.5
|
kleur: 4.1.5
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
svelte: 5.25.8
|
svelte: 5.25.8
|
||||||
vite: 6.2.5(jiti@2.4.2)(lightningcss@1.29.2)
|
vite: 6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)
|
||||||
vitefu: 1.0.6(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))
|
vitefu: 1.0.6(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -2533,12 +2757,17 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 4.1.3
|
tailwindcss: 4.1.3
|
||||||
|
|
||||||
'@tailwindcss/vite@4.1.3(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2))':
|
'@tailwindcss/vite@4.1.3(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.1.3
|
'@tailwindcss/node': 4.1.3
|
||||||
'@tailwindcss/oxide': 4.1.3
|
'@tailwindcss/oxide': 4.1.3
|
||||||
tailwindcss: 4.1.3
|
tailwindcss: 4.1.3
|
||||||
vite: 6.2.5(jiti@2.4.2)(lightningcss@1.29.2)
|
vite: 6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/cookie@0.6.0': {}
|
'@types/cookie@0.6.0': {}
|
||||||
|
|
||||||
@@ -2552,6 +2781,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
|
'@types/node@22.19.11':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.29.1(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.29.1(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
@@ -2631,6 +2864,65 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-android-arm64@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-darwin-arm64@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-darwin-x64@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-freebsd-x64@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm-musleabihf@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm64-gnu@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
|
dependencies:
|
||||||
|
'@napi-rs/wasm-runtime': 0.2.12
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-win32-arm64-msvc@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-win32-ia32-msvc@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.14.1):
|
acorn-jsx@5.3.2(acorn@8.14.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.14.1
|
acorn: 8.14.1
|
||||||
@@ -2815,6 +3107,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
@@ -2994,11 +3290,11 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
confusing-browser-globals: 1.0.11
|
confusing-browser-globals: 1.0.11
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1)
|
||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.entries: 1.1.9
|
object.entries: 1.1.9
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
@@ -3007,6 +3303,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
|
|
||||||
|
eslint-import-context@0.1.9(unrs-resolver@1.11.1):
|
||||||
|
dependencies:
|
||||||
|
get-tsconfig: 4.13.6
|
||||||
|
stable-hash-x: 0.2.0
|
||||||
|
optionalDependencies:
|
||||||
|
unrs-resolver: 1.11.1
|
||||||
|
|
||||||
eslint-import-resolver-node@0.3.9:
|
eslint-import-resolver-node@0.3.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
@@ -3015,17 +3318,33 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1):
|
eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
eslint: 8.57.1
|
||||||
|
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||||
|
get-tsconfig: 4.13.6
|
||||||
|
is-bun-module: 2.0.0
|
||||||
|
stable-hash-x: 0.2.0
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
unrs-resolver: 1.11.1
|
||||||
|
optionalDependencies:
|
||||||
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.29.1(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.29.1(eslint@8.57.1)(typescript@5.9.3)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
|
eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -3036,7 +3355,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1)
|
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.29.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1)
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -3176,7 +3495,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
|
|
||||||
fdir@6.4.3: {}
|
fdir@6.4.3(picomatch@4.0.3):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
file-entry-cache@6.0.1:
|
file-entry-cache@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3247,6 +3572,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
|
get-tsconfig@4.13.6:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -3309,6 +3638,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -3356,6 +3687,10 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
|
|
||||||
|
is-bun-module@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
semver: 7.7.1
|
||||||
|
|
||||||
is-callable@1.2.7: {}
|
is-callable@1.2.7: {}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
@@ -3572,6 +3907,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
napi-postinstall@0.3.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -3657,6 +3994,8 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-load-config@3.1.4(postcss@8.5.3):
|
postcss-load-config@3.1.4(postcss@8.5.3):
|
||||||
@@ -3692,16 +4031,16 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8):
|
prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
svelte: 5.25.8
|
svelte: 5.25.8
|
||||||
|
|
||||||
prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
|
prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prettier-plugin-svelte: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
|
prettier-plugin-svelte: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
|
||||||
|
|
||||||
prettier@3.8.1: {}
|
prettier@3.8.1: {}
|
||||||
|
|
||||||
@@ -3733,6 +4072,8 @@ snapshots:
|
|||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
@@ -3868,6 +4209,8 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
stable-hash-x@0.2.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -3910,11 +4253,11 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
svelte-check@4.1.5(svelte@5.25.8)(typescript@5.9.3):
|
svelte-check@4.1.5(picomatch@4.0.3)(svelte@5.25.8)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
fdir: 6.4.3
|
fdir: 6.4.3(picomatch@4.0.3)
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sade: 1.8.1
|
sade: 1.8.1
|
||||||
svelte: 5.25.8
|
svelte: 5.25.8
|
||||||
@@ -3956,6 +4299,11 @@ snapshots:
|
|||||||
|
|
||||||
text-table@0.2.0: {}
|
text-table@0.2.0: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.15:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
@@ -3973,6 +4321,9 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-bom: 3.0.0
|
strip-bom: 3.0.0
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -4031,25 +4382,52 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
unrs-resolver@1.11.1:
|
||||||
|
dependencies:
|
||||||
|
napi-postinstall: 0.3.4
|
||||||
|
optionalDependencies:
|
||||||
|
'@unrs/resolver-binding-android-arm-eabi': 1.11.1
|
||||||
|
'@unrs/resolver-binding-android-arm64': 1.11.1
|
||||||
|
'@unrs/resolver-binding-darwin-arm64': 1.11.1
|
||||||
|
'@unrs/resolver-binding-darwin-x64': 1.11.1
|
||||||
|
'@unrs/resolver-binding-freebsd-x64': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-arm64-gnu': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-arm64-musl': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-riscv64-musl': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-s390x-gnu': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-x64-gnu': 1.11.1
|
||||||
|
'@unrs/resolver-binding-linux-x64-musl': 1.11.1
|
||||||
|
'@unrs/resolver-binding-wasm32-wasi': 1.11.1
|
||||||
|
'@unrs/resolver-binding-win32-arm64-msvc': 1.11.1
|
||||||
|
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
|
||||||
|
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2):
|
vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.2
|
esbuild: 0.25.2
|
||||||
postcss: 8.5.3
|
postcss: 8.5.3
|
||||||
rollup: 4.39.0
|
rollup: 4.39.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.11
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.4.2
|
jiti: 2.4.2
|
||||||
lightningcss: 1.29.2
|
lightningcss: 1.29.2
|
||||||
|
|
||||||
vitefu@1.0.6(vite@6.2.5(jiti@2.4.2)(lightningcss@1.29.2)):
|
vitefu@1.0.6(vite@6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 6.2.5(jiti@2.4.2)(lightningcss@1.29.2)
|
vite: 6.2.5(@types/node@22.19.11)(jiti@2.4.2)(lightningcss@1.29.2)
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,197 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const currentDirPath = path.dirname(currentFilePath);
|
||||||
|
const projectRoot = path.resolve(currentDirPath, '..');
|
||||||
|
|
||||||
|
const vendorRoot = path.join(projectRoot, 'static/icons/vendor/homarr');
|
||||||
|
const outputPath = path.join(projectRoot, 'src/lib/config/vendorIconManifest.ts');
|
||||||
|
const prettierIgnorePath = path.join(projectRoot, '.prettierignore');
|
||||||
|
|
||||||
|
const allowedExtensions = new Set(['.svg', '.png', '.webp']);
|
||||||
|
const extensionPriority = {
|
||||||
|
'.svg': 0,
|
||||||
|
'.png': 1,
|
||||||
|
'.webp': 2
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {string} input */
|
||||||
|
function toPosixPath(input) {
|
||||||
|
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, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} slug */
|
||||||
|
function toLabel(slug) {
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const vendorExists = await fs
|
||||||
|
.access(vendorRoot)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!vendorExists) {
|
||||||
|
throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 &&
|
||||||
|
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 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 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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Vendor Icon Refresh
|
||||||
|
|
||||||
|
This project vendors icon assets from `homarr-labs/dashboard-icons` into:
|
||||||
|
|
||||||
|
- `static/icons/vendor/homarr/`
|
||||||
|
|
||||||
|
After refreshing the vendored files, regenerate the searchable manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run icons:manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify:
|
||||||
|
|
||||||
|
- `src/lib/config/vendorIconManifest.ts` changed as expected
|
||||||
|
- `third_party/homarr-dashboard-icons/SOURCE.txt` has the new upstream commit/date
|
||||||
|
- `third_party/homarr-dashboard-icons/LICENSE` still matches upstream
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
||||||
|
import {
|
||||||
|
getWritableState,
|
||||||
|
readOrInitializeNetworkFile,
|
||||||
|
writeNetworkFile
|
||||||
|
} from './src/lib/shared/networkPersistenceCore.mjs';
|
||||||
|
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const currentDirPath = path.dirname(currentFilePath);
|
||||||
|
const buildDir = path.resolve(currentDirPath, 'build');
|
||||||
|
|
||||||
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||||
|
const PORT = Number(process.env.PORT ?? 3000);
|
||||||
|
const MAX_BODY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
const contentTypeByExtension = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml; charset=utf-8',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.txt': 'text/plain; charset=utf-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendJson(response, status, payload, extraHeaders = {}) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
response.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
...extraHeaders
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badRequest(response, message, details) {
|
||||||
|
sendJson(response, 400, {
|
||||||
|
error: message,
|
||||||
|
...(details ? { details } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyErrorMessage(reason) {
|
||||||
|
if (!reason) {
|
||||||
|
return 'Write API unavailable in current deployment.';
|
||||||
|
}
|
||||||
|
return `Write API unavailable: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveApi(request, response) {
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const [payload, 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,
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableState.reason
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 200, {
|
||||||
|
data: validation.data,
|
||||||
|
writable: writableState.writable,
|
||||||
|
writableReason: writableState.reason,
|
||||||
|
source: payload.source,
|
||||||
|
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',
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableState.reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'PUT') {
|
||||||
|
const writableStateBeforeWrite = await getWritableState();
|
||||||
|
if (!writableStateBeforeWrite.writable) {
|
||||||
|
sendJson(response, 403, {
|
||||||
|
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableStateBeforeWrite.reason
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = '';
|
||||||
|
let accumulatedSize = 0;
|
||||||
|
for await (const chunk of request) {
|
||||||
|
const chunkSize = Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
|
||||||
|
accumulatedSize += chunkSize;
|
||||||
|
if (accumulatedSize > MAX_BODY_BYTES) {
|
||||||
|
raw = '';
|
||||||
|
accumulatedSize = 0;
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
413,
|
||||||
|
{ error: `Payload too large. Max body size is ${MAX_BODY_BYTES} bytes.` },
|
||||||
|
{ Connection: 'close' }
|
||||||
|
);
|
||||||
|
request.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raw += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
badRequest(response, 'Request body must be valid JSON');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateNetworkData(parsed);
|
||||||
|
if (!validation.valid) {
|
||||||
|
badRequest(response, 'Network data validation failed', validation.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await writeNetworkFile(validation.data);
|
||||||
|
const writableStateAfterWrite = await getWritableState();
|
||||||
|
sendJson(response, 200, {
|
||||||
|
data: validation.data,
|
||||||
|
writable: writableStateAfterWrite.writable,
|
||||||
|
writableReason: writableStateAfterWrite.reason,
|
||||||
|
source: metadata.source,
|
||||||
|
updatedAt: metadata.updatedAt
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to persist network data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 405, { error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRelativePath(requestPath) {
|
||||||
|
const decoded = decodeURIComponent(requestPath);
|
||||||
|
const normalized = path.posix.normalize(decoded);
|
||||||
|
if (normalized.startsWith('..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(requestPath, response) {
|
||||||
|
const relativePath = safeRelativePath(requestPath);
|
||||||
|
if (relativePath === null) {
|
||||||
|
response.writeHead(400);
|
||||||
|
response.end('Bad request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedFilePath = path.join(
|
||||||
|
buildDir,
|
||||||
|
relativePath === '/' ? 'index.html' : relativePath.slice(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendFile = async (filePath) => {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = contentTypeByExtension[ext] ?? 'application/octet-stream';
|
||||||
|
const content = await readFile(filePath);
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': ext === '.html' ? 'no-store' : 'public, max-age=31536000, immutable'
|
||||||
|
});
|
||||||
|
response.end(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileInfo = await stat(requestedFilePath);
|
||||||
|
if (fileInfo.isDirectory()) {
|
||||||
|
await sendFile(path.join(requestedFilePath, 'index.html'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendFile(requestedFilePath);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// continue with SPA fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendFile(path.join(buildDir, 'index.html'));
|
||||||
|
} catch {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end('Not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
||||||
|
if (requestUrl.pathname === '/api/network-data') {
|
||||||
|
await serveApi(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await serveStatic(requestUrl.pathname, response);
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[OpenNetworkDiagram] request handling failed', error);
|
||||||
|
if (response.headersSent || response.writableEnded) {
|
||||||
|
response.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = JSON.stringify({ error: 'Internal server error' });
|
||||||
|
response.statusCode = 500;
|
||||||
|
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
response.setHeader('Content-Length', Buffer.byteLength(body));
|
||||||
|
response.setHeader('Cache-Control', 'no-store');
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[OpenNetworkDiagram] listening on http://${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
@@ -1,2 +1,99 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
/* Design tokens from the UX review (new-design/project/UX Review.dc.html, panel 1b).
|
||||||
|
Principle: calm chrome, loud data. */
|
||||||
|
:root {
|
||||||
|
/* 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'] {
|
||||||
|
--bg-canvas: #14171c;
|
||||||
|
--surface: #1d222a;
|
||||||
|
--surface-2: #262d38;
|
||||||
|
--border: #363f4d;
|
||||||
|
--text: #e7ebf2;
|
||||||
|
--text-2: #9aa4b2;
|
||||||
|
--accent: #7da5e8;
|
||||||
|
--danger: #e0716c;
|
||||||
|
|
||||||
|
--graph-edge: #5b6575;
|
||||||
|
--graph-node-border: #363f4d;
|
||||||
|
--graph-infra-fill: #3e4c61;
|
||||||
|
--graph-infra-border: #5b6c86;
|
||||||
|
--graph-dumb-border: #414c5d;
|
||||||
|
--graph-edge-chip-bg: #262d38;
|
||||||
|
--graph-edge-chip-text: #c3cad4;
|
||||||
|
|
||||||
|
--vlan-0: oklch(0.68 0.13 290);
|
||||||
|
--vlan-1: oklch(0.68 0.13 15);
|
||||||
|
--vlan-2: oklch(0.68 0.13 175);
|
||||||
|
--vlan-3: oklch(0.68 0.13 75);
|
||||||
|
--vlan-4: oklch(0.68 0.13 240);
|
||||||
|
--vlan-5: oklch(0.68 0.13 125);
|
||||||
|
|
||||||
|
--shadow-panel: 0 4px 16px rgb(0 0 0 / 0.35);
|
||||||
|
--shadow-popover: 0 8px 24px rgb(0 0 0 / 0.45);
|
||||||
|
--modal-overlay: rgb(8 10 14 / 0.6);
|
||||||
|
|
||||||
|
--status-ok: #5cb884;
|
||||||
|
--status-warn: #f0b429;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: var(--app-bg);
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly OND_STATIC_DEMO: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Svelte action: invoke the callback when a pointer goes down outside the
|
||||||
|
// node. Listens in the capture phase so clicks swallowed by the Cytoscape
|
||||||
|
// canvas still dismiss open popovers.
|
||||||
|
export default function clickOutside(node: HTMLElement, onOutside: () => void) {
|
||||||
|
let callback = onOutside;
|
||||||
|
const handler = (event: PointerEvent) => {
|
||||||
|
if (!node.contains(event.target as Node)) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handler, true);
|
||||||
|
return {
|
||||||
|
update(next: () => void) {
|
||||||
|
callback = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener('pointerdown', handler, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import clickOutside from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
|
type DiagramViewMode = 'network' | 'device' | 'rack';
|
||||||
|
type SaveState = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
|
export let dataSourceLabel = '';
|
||||||
|
export let saveState: SaveState = 'saved';
|
||||||
|
export let writable = false;
|
||||||
|
export let hasLoadedInitialData = false;
|
||||||
|
export let readOnlyNotice = '';
|
||||||
|
export let saveError: string | null = null;
|
||||||
|
export let isLoadingData = false;
|
||||||
|
|
||||||
|
export let viewMode: DiagramViewMode = 'network';
|
||||||
|
export let searchQuery = '';
|
||||||
|
export let searchCountLabel = '';
|
||||||
|
|
||||||
|
export let showEthernetLabels = false;
|
||||||
|
export let showCableSpeeds = true;
|
||||||
|
export let showVms = true;
|
||||||
|
export let hasAnyVmHosts = false;
|
||||||
|
export let ipamOpen = false;
|
||||||
|
export let theme: 'light' | 'dark' = 'light';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
viewchange: DiagramViewMode;
|
||||||
|
search: void;
|
||||||
|
searchcycle: { direction: 1 | -1 };
|
||||||
|
clearsearch: void;
|
||||||
|
addmachine: void;
|
||||||
|
adddevice: void;
|
||||||
|
toggleipam: void;
|
||||||
|
ethernetlabels: boolean;
|
||||||
|
cablespeeds: boolean;
|
||||||
|
showvms: boolean;
|
||||||
|
exportpng: void;
|
||||||
|
reload: void;
|
||||||
|
toggletheme: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let addMenuOpen = false;
|
||||||
|
let displayMenuOpen = false;
|
||||||
|
let searchInput: HTMLInputElement;
|
||||||
|
|
||||||
|
export function focusSearch() {
|
||||||
|
searchInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: fileName = dataSourceLabel.split('/').pop() || dataSourceLabel;
|
||||||
|
$: status = (() => {
|
||||||
|
if (isLoadingData) {
|
||||||
|
return { text: 'Loading…', kind: 'muted', title: undefined as string | undefined };
|
||||||
|
}
|
||||||
|
if (hasLoadedInitialData && !writable) {
|
||||||
|
return { text: '⬦ Read-only', kind: 'warn', title: readOnlyNotice };
|
||||||
|
}
|
||||||
|
if (saveState === 'saving') {
|
||||||
|
return { text: 'Saving…', kind: 'muted', title: undefined };
|
||||||
|
}
|
||||||
|
if (saveState === 'error') {
|
||||||
|
return { text: '⚠ Save failed', kind: 'danger', title: saveError ?? undefined };
|
||||||
|
}
|
||||||
|
if (saveState === 'unsaved') {
|
||||||
|
return { text: 'Unsaved changes', kind: 'muted', title: undefined };
|
||||||
|
}
|
||||||
|
return { text: '✓ Saved', kind: 'ok', title: undefined };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const views: Array<{ id: DiagramViewMode; label: string }> = [
|
||||||
|
{ id: 'network', label: 'Network' },
|
||||||
|
{ id: 'device', label: 'Hosts & VMs' },
|
||||||
|
{ id: 'rack', label: 'Rack' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function selectView(mode: DiagramViewMode) {
|
||||||
|
if (mode !== viewMode) {
|
||||||
|
dispatch('viewchange', mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewSelectChange(event: Event) {
|
||||||
|
selectView((event.currentTarget as HTMLSelectElement).value as DiagramViewMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
dispatch('searchcycle', { direction: event.shiftKey ? -1 : 1 });
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
dispatch('clearsearch');
|
||||||
|
searchInput?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && (addMenuOpen || displayMenuOpen)) {
|
||||||
|
addMenuOpen = false;
|
||||||
|
displayMenuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onWindowKeydown} />
|
||||||
|
|
||||||
|
<header class="app-bar">
|
||||||
|
<div class="identity" title={dataSourceLabel}>
|
||||||
|
<div class="glyph" aria-hidden="true">◆</div>
|
||||||
|
<div class="identity-text">
|
||||||
|
<span class="file-name">{fileName}</span>
|
||||||
|
<span class="status status-{status.kind}" title={status.title}>{status.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="views">
|
||||||
|
<div class="seg" role="radiogroup" aria-label="Diagram view">
|
||||||
|
{#each views as view (view.id)}
|
||||||
|
<label class="seg-option" class:checked={viewMode === view.id}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="diagram-view"
|
||||||
|
value={view.id}
|
||||||
|
checked={viewMode === view.id}
|
||||||
|
on:change={() => selectView(view.id)}
|
||||||
|
/>
|
||||||
|
{view.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="seg-select"
|
||||||
|
value={viewMode}
|
||||||
|
aria-label="Select diagram view"
|
||||||
|
on:change={onViewSelectChange}
|
||||||
|
>
|
||||||
|
{#each views as view (view.id)}
|
||||||
|
<option value={view.id}>{view.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<div class="search" class:has-query={searchQuery.trim()}>
|
||||||
|
<span class="search-glyph" aria-hidden="true">⌕</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Find name, IP, VM…"
|
||||||
|
aria-label="Search diagram nodes"
|
||||||
|
bind:this={searchInput}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:input={() => dispatch('search')}
|
||||||
|
on:keydown={onSearchKeydown}
|
||||||
|
/>
|
||||||
|
{#if searchQuery.trim()}
|
||||||
|
<span class="search-count">{searchCountLabel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-clear"
|
||||||
|
aria-label="Clear search"
|
||||||
|
title="Clear search"
|
||||||
|
on:click={() => dispatch('clearsearch')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<kbd class="search-kbd" aria-hidden="true">/</kbd>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-anchor" use:clickOutside={() => (addMenuOpen = false)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
disabled={!writable}
|
||||||
|
title={!writable ? readOnlyNotice : undefined}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={addMenuOpen}
|
||||||
|
on:click={() => (addMenuOpen = !addMenuOpen)}
|
||||||
|
>
|
||||||
|
+ <span class="btn-text">Add</span> <span class="caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
{#if addMenuOpen}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
on:click={() => {
|
||||||
|
addMenuOpen = false;
|
||||||
|
dispatch('addmachine');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Machine
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
on:click={() => {
|
||||||
|
addMenuOpen = false;
|
||||||
|
dispatch('adddevice');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
class:active={ipamOpen}
|
||||||
|
on:click={() => dispatch('toggleipam')}
|
||||||
|
>
|
||||||
|
IPAM
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="menu-anchor" use:clickOutside={() => (displayMenuOpen = false)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-quiet"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={displayMenuOpen}
|
||||||
|
on:click={() => (displayMenuOpen = !displayMenuOpen)}
|
||||||
|
>
|
||||||
|
<span class="btn-text">Display</span><span class="btn-text-narrow" aria-hidden="true"
|
||||||
|
>⚙</span
|
||||||
|
>
|
||||||
|
<span class="caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
{#if displayMenuOpen}
|
||||||
|
<div class="menu display-menu">
|
||||||
|
<div class="menu-title">Display</div>
|
||||||
|
<label
|
||||||
|
class="menu-toggle"
|
||||||
|
class:disabled={viewMode !== 'network'}
|
||||||
|
title={viewMode !== 'network'
|
||||||
|
? 'Ethernet labels are available in Network view.'
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<span>Ethernet labels</span>
|
||||||
|
<input
|
||||||
|
class="toggle-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showEthernetLabels}
|
||||||
|
disabled={viewMode !== 'network'}
|
||||||
|
on:change={(event) => dispatch('ethernetlabels', event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class="menu-toggle"
|
||||||
|
class:disabled={viewMode !== 'network' || !showEthernetLabels}
|
||||||
|
title={viewMode !== 'network' || !showEthernetLabels
|
||||||
|
? 'Cable speeds show inside ethernet labels.'
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<span>Cable speeds</span>
|
||||||
|
<input
|
||||||
|
class="toggle-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showCableSpeeds}
|
||||||
|
disabled={viewMode !== 'network' || !showEthernetLabels}
|
||||||
|
on:change={(event) => dispatch('cablespeeds', event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<label class="menu-toggle" class:disabled={!hasAnyVmHosts}>
|
||||||
|
<span>Show VMs</span>
|
||||||
|
<input
|
||||||
|
class="toggle-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showVms}
|
||||||
|
disabled={!hasAnyVmHosts}
|
||||||
|
on:change={(event) => dispatch('showvms', event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<div class="menu-rule" aria-hidden="true"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={viewMode === 'rack'}
|
||||||
|
title={viewMode === 'rack' ? 'PNG export is available in the graph views.' : undefined}
|
||||||
|
on:click={() => {
|
||||||
|
displayMenuOpen = false;
|
||||||
|
dispatch('exportpng');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export PNG…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isLoadingData}
|
||||||
|
on:click={() => {
|
||||||
|
displayMenuOpen = false;
|
||||||
|
dispatch('reload');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload from disk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-icon"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
title="Toggle dark mode"
|
||||||
|
on:click={() => dispatch('toggletheme')}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left: identity + status */
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok {
|
||||||
|
color: var(--status-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warn {
|
||||||
|
color: var(--status-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-muted {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Centre: segmented view control */
|
||||||
|
.views {
|
||||||
|
margin: 0 auto 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option.checked {
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option:focus-within {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-select {
|
||||||
|
display: none;
|
||||||
|
height: var(--control-h);
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right cluster */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
height: var(--control-h);
|
||||||
|
width: 220px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-glyph {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input::placeholder {
|
||||||
|
color: var(--text-2);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-kbd {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-quiet,
|
||||||
|
.btn-icon {
|
||||||
|
height: var(--control-h);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0 13px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--surface);
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiet {
|
||||||
|
padding: 0 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quiet:hover,
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: var(--control-h);
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
border: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text-narrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menus */
|
||||||
|
.menu-anchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 170px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-menu {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 6px 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu > button:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu > button:disabled {
|
||||||
|
color: var(--text-2);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-rule {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle.disabled {
|
||||||
|
color: var(--text-2);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 19px;
|
||||||
|
background: color-mix(in oklab, var(--text-2) 40%, var(--surface-2));
|
||||||
|
border-radius: 999px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 120ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:checked + .toggle-track {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:checked + .toggle-track .toggle-thumb {
|
||||||
|
transform: translateX(13px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:focus-visible + .toggle-track {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.app-bar {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-select {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0 9px;
|
||||||
|
transition: width 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within,
|
||||||
|
.search.has-query {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text-narrow {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone widths: shed the divider and tighten spacing so the single row
|
||||||
|
never overflows the viewport. */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-bar {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views {
|
||||||
|
margin-left: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-select {
|
||||||
|
max-width: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-quiet {
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within,
|
||||||
|
.search.has-query {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let isSampleData = false;
|
||||||
|
export let writable = false;
|
||||||
|
export let hasMachines = false;
|
||||||
|
export let storageReady = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
addmachine: void;
|
||||||
|
connectport: void;
|
||||||
|
openipam: void;
|
||||||
|
dismiss: void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="first-run">
|
||||||
|
<h2>Map your network</h2>
|
||||||
|
{#if storageReady}
|
||||||
|
<p class="storage-note">
|
||||||
|
Your network data file is ready. Add your first machine to start mapping.
|
||||||
|
</p>
|
||||||
|
{:else if isSampleData}
|
||||||
|
<p class="sample-note">This is sample data — replace it with your own machines.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
{#if writable}
|
||||||
|
<button type="button" on:click={() => dispatch('addmachine')}>
|
||||||
|
<span class="action-glyph" aria-hidden="true">+</span>
|
||||||
|
<span class="action-text">
|
||||||
|
<strong>Add a machine</strong>
|
||||||
|
<span>Servers, NAS boxes, mini PCs — anything with an IP.</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" disabled={!hasMachines} on:click={() => dispatch('connectport')}>
|
||||||
|
<span class="action-glyph" aria-hidden="true">⇄</span>
|
||||||
|
<span class="action-text">
|
||||||
|
<strong>Connect a port</strong>
|
||||||
|
<span>
|
||||||
|
{hasMachines
|
||||||
|
? 'Open a machine and add a port to draw your first cable.'
|
||||||
|
: 'Add a machine first, then wire up its ports.'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" on:click={() => dispatch('openipam')}>
|
||||||
|
<span class="action-glyph" aria-hidden="true">⌗</span>
|
||||||
|
<span class="action-text">
|
||||||
|
<strong>Open IPAM</strong>
|
||||||
|
<span>See subnet utilisation and grab the next free IP.</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="dismiss" on:click={() => dispatch('dismiss')}>Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.first-run {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 20;
|
||||||
|
width: min(92vw, 440px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--status-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-glyph {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text strong {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss {
|
||||||
|
align-self: flex-end;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { listIconDefinitions } from '$lib/config/iconRegistry';
|
||||||
|
import clickOutside from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
|
// Anchored popover for choosing an icon. The parent positions it (the
|
||||||
|
// wrapper just needs position: relative); selection is reported via the
|
||||||
|
// `select` event and the popover closes itself on outside click/Escape.
|
||||||
|
export let currentIconKey: string | undefined = undefined;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ select: string; close: void }>();
|
||||||
|
|
||||||
|
const iconDefinitions = listIconDefinitions();
|
||||||
|
const iconResultLimit = 100;
|
||||||
|
|
||||||
|
let iconSearch = '';
|
||||||
|
|
||||||
|
function focusInput(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: searchTokens = normalize(iconSearch).split(' ').filter(Boolean);
|
||||||
|
$: filteredIcons = iconDefinitions.filter((icon) => {
|
||||||
|
if (searchTokens.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const searchable = normalize(`${icon.label} ${icon.key}`);
|
||||||
|
return searchTokens.every((token) => searchable.includes(token));
|
||||||
|
});
|
||||||
|
$: visibleIcons = filteredIcons.slice(0, iconResultLimit);
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div class="icon-popover" use:clickOutside={() => dispatch('close')} on:keydown={onKeydown}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search icons…"
|
||||||
|
aria-label="Search icons"
|
||||||
|
bind:value={iconSearch}
|
||||||
|
use:focusInput
|
||||||
|
/>
|
||||||
|
<div class="results-meta">
|
||||||
|
Showing {visibleIcons.length} of {filteredIcons.length} icons
|
||||||
|
</div>
|
||||||
|
{#if visibleIcons.length > 0}
|
||||||
|
<div class="results-grid" role="listbox" aria-label="Icon results">
|
||||||
|
{#each visibleIcons as icon (icon.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={icon.key === currentIconKey}
|
||||||
|
class:selected={icon.key === currentIconKey}
|
||||||
|
title={icon.label}
|
||||||
|
on:click={() => dispatch('select', icon.key)}
|
||||||
|
>
|
||||||
|
<img src={icon.path} alt="" loading="lazy" />
|
||||||
|
<span>{icon.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-results">No icons match your search.</div>
|
||||||
|
{/if}
|
||||||
|
{#if currentIconKey}
|
||||||
|
<div class="popover-footer">
|
||||||
|
<button type="button" class="clear-icon" on:click={() => dispatch('select', '')}>
|
||||||
|
Remove icon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.icon-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
width: min(420px, 80vw);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: var(--control-h);
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid button:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid button.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid span {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-footer {
|
||||||
|
border-top: 1px solid var(--surface-2);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-icon {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-icon:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,14 +32,23 @@
|
|||||||
<div class="modal-backdrop" on:click={onBackdropClick}>
|
<div class="modal-backdrop" on:click={onBackdropClick}>
|
||||||
<div class="modal-container" style={`max-width: ${maxWidth}`}>
|
<div class="modal-container" style={`max-width: ${maxWidth}`}>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
{#if title}
|
<slot name="header">
|
||||||
<h2>{title}</h2>
|
{#if title}
|
||||||
{/if}
|
<h2>{title}</h2>
|
||||||
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button>
|
{/if}
|
||||||
|
</slot>
|
||||||
|
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
|
||||||
|
>×</button
|
||||||
|
>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
{#if $$slots.footer}
|
||||||
|
<footer class="modal-footer-bar">
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -52,7 +61,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: rgba(15, 23, 42, 0.58);
|
background: var(--modal-overlay);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,49 +70,64 @@
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #f8fafc;
|
background: var(--surface);
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-modal);
|
||||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
|
box-shadow: 0 24px 64px rgb(15 18 24 / 0.35);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 14px;
|
||||||
gap: 0.5rem;
|
padding: 16px 20px;
|
||||||
padding: 0.9rem 1rem;
|
border-bottom: 1px solid var(--border);
|
||||||
border-bottom: 1px solid #e2e8f0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.05rem;
|
flex: 1;
|
||||||
color: #0f172a;
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #475569;
|
color: var(--text-2);
|
||||||
font-size: 1.7rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
width: 2rem;
|
width: 30px;
|
||||||
height: 2rem;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-control);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
.close-button:hover {
|
||||||
background: #e2e8f0;
|
background: var(--surface-2);
|
||||||
color: #0f172a;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 1rem;
|
padding: 20px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-footer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type cytoscape from 'cytoscape';
|
||||||
|
import { vlanPaletteColor } from '$lib/graph/vlanPalette';
|
||||||
|
import type { GraphNodeElement } from '$lib/graph/types';
|
||||||
|
|
||||||
|
// HTML card overlay for graph nodes. Cytoscape keeps invisible hit-target
|
||||||
|
// nodes (interaction, layout, edge anchoring); this layer draws the actual
|
||||||
|
// cards at the same model coordinates and mirrors pan/zoom with a single
|
||||||
|
// container transform. pointer-events stay off so Cytoscape owns input.
|
||||||
|
export let cy: cytoscape.Core | null = null;
|
||||||
|
export let nodes: GraphNodeElement[] = [];
|
||||||
|
export let dimmedIds: ReadonlySet<string> = new Set();
|
||||||
|
export let matchedIds: ReadonlySet<string> = new Set();
|
||||||
|
export let activeId: string | null = null;
|
||||||
|
|
||||||
|
let viewport = { panX: 0, panY: 0, zoom: 1 };
|
||||||
|
let positionById: Record<string, { x: number; y: number }> = {};
|
||||||
|
let attachedCy: cytoscape.Core | null = null;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function syncFromCy() {
|
||||||
|
rafId = null;
|
||||||
|
if (!attachedCy || attachedCy.destroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pan = attachedCy.pan();
|
||||||
|
viewport = { panX: pan.x, panY: pan.y, zoom: attachedCy.zoom() };
|
||||||
|
const next: Record<string, { x: number; y: number }> = {};
|
||||||
|
for (const node of attachedCy.nodes().toArray()) {
|
||||||
|
const position = node.position();
|
||||||
|
next[node.id()] = { x: position.x, y: position.y };
|
||||||
|
}
|
||||||
|
positionById = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync() {
|
||||||
|
if (rafId === null) {
|
||||||
|
rafId = requestAnimationFrame(syncFromCy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(core: cytoscape.Core | null) {
|
||||||
|
if (core === attachedCy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachedCy && !attachedCy.destroyed()) {
|
||||||
|
attachedCy.off('render', scheduleSync);
|
||||||
|
}
|
||||||
|
attachedCy = core;
|
||||||
|
if (attachedCy) {
|
||||||
|
// 'render' fires on pan, zoom, layout, add/remove and position
|
||||||
|
// changes, so one rAF-throttled listener keeps cards in lockstep.
|
||||||
|
attachedCy.on('render', scheduleSync);
|
||||||
|
scheduleSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: attach(cy);
|
||||||
|
// Re-read positions whenever the visible node set changes (refreshGraph
|
||||||
|
// re-adds elements and re-runs layout synchronously).
|
||||||
|
$: if (nodes && attachedCy) {
|
||||||
|
scheduleSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
attach(null);
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fallbackGlyph(kind: string, deviceClass: string | undefined): string {
|
||||||
|
if (kind === 'device') {
|
||||||
|
return deviceClass === 'infrastructure' ? '⇄' : '○';
|
||||||
|
}
|
||||||
|
if (kind === 'vm') {
|
||||||
|
return '◫';
|
||||||
|
}
|
||||||
|
return '🖥';
|
||||||
|
}
|
||||||
|
|
||||||
|
// position is passed in from the template (rather than read from
|
||||||
|
// positionById here) so Svelte's legacy-mode invalidation sees the
|
||||||
|
// dependency and re-renders cards when positions change.
|
||||||
|
function cardStyle(
|
||||||
|
node: GraphNodeElement,
|
||||||
|
position: { x: number; y: number } | undefined
|
||||||
|
): string {
|
||||||
|
const { data } = node;
|
||||||
|
if (!position) {
|
||||||
|
return 'display: none;';
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
`left: ${position.x}px`,
|
||||||
|
`top: ${position.y}px`,
|
||||||
|
`width: ${data.nodeWidth}px`,
|
||||||
|
`height: ${data.nodeHeight}px`
|
||||||
|
];
|
||||||
|
if (typeof data.vlanIndex === 'number') {
|
||||||
|
parts.push(`--card-vlan: ${vlanPaletteColor(data.vlanIndex)}`);
|
||||||
|
}
|
||||||
|
return `${parts.join('; ')};`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card-layer" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
class="card-plane"
|
||||||
|
style={`transform: translate(${viewport.panX}px, ${viewport.panY}px) scale(${viewport.zoom});`}
|
||||||
|
>
|
||||||
|
{#each nodes as node (node.data.id)}
|
||||||
|
{@const data = node.data}
|
||||||
|
{@const isDumb = data.kind === 'device' && data.deviceClass !== 'infrastructure'}
|
||||||
|
<div
|
||||||
|
class="node-card kind-{data.kind}"
|
||||||
|
class:infra={data.kind === 'device' && data.deviceClass === 'infrastructure'}
|
||||||
|
class:dumb={isDumb}
|
||||||
|
class:has-vlan={typeof data.vlanIndex === 'number'}
|
||||||
|
class:dimmed={dimmedIds.has(data.id)}
|
||||||
|
class:match={matchedIds.has(data.id)}
|
||||||
|
class:active={activeId === data.id}
|
||||||
|
style={cardStyle(node, positionById[node.data.id])}
|
||||||
|
>
|
||||||
|
<div class="icon-tile" class:round={isDumb}>
|
||||||
|
{#if data.iconUrl}
|
||||||
|
<img src={data.iconUrl} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="icon-fallback">{fallbackGlyph(data.kind, data.deviceClass)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-text">
|
||||||
|
<span class="card-name">{data.rawName ?? data.label}</span>
|
||||||
|
{#if !isDumb && (data.meta || data.vmCount)}
|
||||||
|
<span class="card-meta">
|
||||||
|
{#if data.meta}{data.meta}{/if}{#if data.kind === 'machine' && (data.vmCount ?? 0) > 0}{#if data.meta} ·
|
||||||
|
{/if}<span class="vm-count">{data.vmCount} {data.vmCount === 1 ? 'VM' : 'VMs'}</span
|
||||||
|
>{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-plane {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px 0 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--graph-node-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 0.07);
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.has-vlan {
|
||||||
|
border-left: 4px solid var(--card-vlan);
|
||||||
|
padding-left: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.kind-vm {
|
||||||
|
background: var(--surface-2);
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.infra {
|
||||||
|
background: var(--graph-infra-fill);
|
||||||
|
border-color: var(--graph-infra-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.dumb {
|
||||||
|
border: 1px dashed var(--graph-dumb-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 14px 0 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.dumb.has-vlan {
|
||||||
|
border-left: 4px solid var(--card-vlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.dimmed {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.match {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-tile {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-tile.round {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-vm .icon-tile {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra .icon-tile {
|
||||||
|
background: rgb(255 255 255 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .infra .icon-tile {
|
||||||
|
background: rgb(255 255 255 / 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-tile img {
|
||||||
|
width: 76%;
|
||||||
|
height: 76%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-fallback {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra .icon-fallback {
|
||||||
|
color: #e7ebf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra .card-name {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .infra .card-name {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dumb .card-name {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-vm .card-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra .card-meta {
|
||||||
|
color: #b6bfcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-vm .card-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-count {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import { resolveIconPath } from '$lib/config/iconRegistry';
|
||||||
|
import type { RackLayout, RackSlot } from '$lib/data/rackLayout';
|
||||||
|
|
||||||
|
export let layout: RackLayout;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
select: { kind: RackSlot['kind']; name: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const unitHeightPx = 34;
|
||||||
|
|
||||||
|
function slotStyle(slot: RackSlot, rackHeightU: number): string {
|
||||||
|
const top = (rackHeightU - (slot.unit + slot.heightU - 1)) * unitHeightPx;
|
||||||
|
const laneWidth = `(100% - 2.45rem) / ${slot.laneCount}`;
|
||||||
|
const left = `calc(2.1rem + (${laneWidth}) * ${slot.laneIndex})`;
|
||||||
|
const width = `calc(${laneWidth} - 4px)`;
|
||||||
|
return `top: ${top}px; height: ${slot.heightU * unitHeightPx - 4}px; left: ${left}; width: ${width};`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rack-view">
|
||||||
|
{#if layout.racks.length === 0}
|
||||||
|
<div class="rack-empty">
|
||||||
|
<h3>No rack placements yet</h3>
|
||||||
|
<p>
|
||||||
|
Open a machine or device and fill in the Rack section (rack name + U position) to see it
|
||||||
|
here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rack-columns">
|
||||||
|
{#each layout.racks as rack (rack.name)}
|
||||||
|
<div class="rack-column">
|
||||||
|
<h3>{rack.name} <span class="rack-height-tag">{rack.heightU}U</span></h3>
|
||||||
|
<div class="rack-frame" style={`height: ${rack.heightU * unitHeightPx}px;`}>
|
||||||
|
{#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)}
|
||||||
|
<div class="rack-unit-row" style={`height: ${unitHeightPx}px;`}>
|
||||||
|
<span class="rack-unit-label">{unit}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each rack.slots as slot (`${slot.kind}:${slot.name}`)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rack-slot"
|
||||||
|
class:machine={slot.kind === 'machine'}
|
||||||
|
class:device={slot.kind === 'device'}
|
||||||
|
style={slotStyle(slot, rack.heightU)}
|
||||||
|
title={`${slot.name} — U${slot.unit}${slot.heightU > 1 ? `–U${slot.unit + slot.heightU - 1}` : ''}`}
|
||||||
|
on:click={() => dispatch('select', { kind: slot.kind, name: slot.name })}
|
||||||
|
>
|
||||||
|
{#if resolveIconPath(slot.iconKey)}
|
||||||
|
<img src={resolveIconPath(slot.iconKey)} alt="" loading="lazy" />
|
||||||
|
{/if}
|
||||||
|
<span class="rack-slot-text">
|
||||||
|
<span class="rack-slot-name">{slot.name}</span>
|
||||||
|
<span class="rack-slot-subtitle">{slot.subtitle}</span>
|
||||||
|
</span>
|
||||||
|
<span class="rack-slot-units">{slot.heightU}U</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if layout.unracked.length > 0}
|
||||||
|
<details class="unracked">
|
||||||
|
<summary>
|
||||||
|
{layout.unracked.length}
|
||||||
|
{layout.unracked.length === 1 ? 'entity is' : 'entities are'} not rack-mounted
|
||||||
|
</summary>
|
||||||
|
<div class="unracked-list">
|
||||||
|
{#each layout.unracked as entity (`${entity.kind}:${entity.name}`)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="unracked-item"
|
||||||
|
title="Open the editor to place this in a rack"
|
||||||
|
on:click={() => dispatch('select', { kind: entity.kind, name: entity.name })}
|
||||||
|
>
|
||||||
|
{#if resolveIconPath(entity.iconKey)}
|
||||||
|
<img src={resolveIconPath(entity.iconKey)} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="unracked-glyph" aria-hidden="true">
|
||||||
|
{entity.kind === 'machine' ? '🖥' : '○'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="unracked-name">{entity.name}</span>
|
||||||
|
<span class="unracked-kind">{entity.subtitle || entity.kind}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rack-view {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1.25rem 1rem 1rem;
|
||||||
|
background: var(--bg-canvas, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-empty {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 4rem auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-empty h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-column h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-height-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-frame {
|
||||||
|
position: relative;
|
||||||
|
width: min(86vw, 340px);
|
||||||
|
border: 2px solid var(--panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in oklab, var(--panel-bg) 88%, transparent 12%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-unit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px dashed color-mix(in oklab, var(--panel-border) 55%, transparent 45%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-unit-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-unit-label {
|
||||||
|
width: 1.9rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot {
|
||||||
|
position: absolute;
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot.machine {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot.device {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot img {
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot-subtitle {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rack-slot-units {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 1.25rem auto 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked summary {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-item:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-item img {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-glyph {
|
||||||
|
width: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unracked-kind {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { VENDOR_ICON_DEFINITIONS } from './vendorIconManifest';
|
||||||
|
|
||||||
|
export interface IconDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
source?: 'built-in' | 'homarr-dashboard-icons';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_ICONS: IconDefinition[] = [
|
||||||
|
{ key: 'server', label: 'Server', path: '/icons/server.svg', source: 'built-in' },
|
||||||
|
{ key: 'router', label: 'Router', path: '/icons/router.svg', source: 'built-in' },
|
||||||
|
{ key: 'switch', label: 'Switch', path: '/icons/switch.svg', source: 'built-in' },
|
||||||
|
{ key: 'storage', label: 'Storage', path: '/icons/storage.svg', source: 'built-in' },
|
||||||
|
{ key: 'desktop', label: 'Desktop', path: '/icons/desktop.svg', source: 'built-in' },
|
||||||
|
{ key: 'cloud', label: 'Cloud', path: '/icons/cloud.svg', source: 'built-in' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ICONS: IconDefinition[] = [...BUILTIN_ICONS, ...VENDOR_ICON_DEFINITIONS];
|
||||||
|
|
||||||
|
const ICON_BY_KEY = new Map(ICONS.map((icon) => [icon.key, icon]));
|
||||||
|
|
||||||
|
export function listIconDefinitions(): IconDefinition[] {
|
||||||
|
return ICONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIconPath(iconKey?: string): string | undefined {
|
||||||
|
if (!iconKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return ICON_BY_KEY.get(iconKey)?.path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import type { NetworkData, Subnet } from '../types';
|
||||||
|
|
||||||
|
export type IpOwnerKind = 'machine' | 'device' | 'vm';
|
||||||
|
|
||||||
|
export interface IpAssignment {
|
||||||
|
ip: string;
|
||||||
|
ownerLabel: string;
|
||||||
|
ownerKind: IpOwnerKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubnetReport {
|
||||||
|
cidr: string;
|
||||||
|
name?: string;
|
||||||
|
vlanId?: number;
|
||||||
|
declared: boolean;
|
||||||
|
used: number;
|
||||||
|
capacity: number;
|
||||||
|
members: IpAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IpamReport {
|
||||||
|
subnets: SubnetReport[];
|
||||||
|
duplicates: Array<{ ip: string; owners: string[] }>;
|
||||||
|
unparsed: IpAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a dotted-quad IPv4 address into a 32-bit number; null for anything else ("DHCP", empty, IPv6). */
|
||||||
|
export function parseIpv4(text: string): number | null {
|
||||||
|
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text.trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const octets = match.slice(1).map(Number);
|
||||||
|
if (octets.some((octet) => octet > 255)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return octets[0] * 2 ** 24 + octets[1] * 2 ** 16 + octets[2] * 256 + octets[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIpv4(value: number): string {
|
||||||
|
return [
|
||||||
|
Math.floor(value / 2 ** 24) % 256,
|
||||||
|
Math.floor(value / 2 ** 16) % 256,
|
||||||
|
Math.floor(value / 256) % 256,
|
||||||
|
value % 256
|
||||||
|
].join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCidr(cidr: string): { base: number; prefix: number } | null {
|
||||||
|
const slash = cidr.indexOf('/');
|
||||||
|
if (slash < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base = parseIpv4(cidr.slice(0, slash));
|
||||||
|
const prefix = Number(cidr.slice(slash + 1));
|
||||||
|
if (base === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hostBlock = 2 ** (32 - prefix);
|
||||||
|
return { base: Math.floor(base / hostBlock) * hostBlock, prefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cidrContains(cidr: string, ip: string): boolean {
|
||||||
|
const parsed = parseCidr(cidr);
|
||||||
|
const value = parseIpv4(ip);
|
||||||
|
if (!parsed || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hostBlock = 2 ** (32 - parsed.prefix);
|
||||||
|
return Math.floor(value / hostBlock) * hostBlock === parsed.base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferSlash24(ip: string): string | null {
|
||||||
|
const value = parseIpv4(ip);
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${formatIpv4(Math.floor(value / 256) * 256)}/24`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectIpAssignments(data: NetworkData): IpAssignment[] {
|
||||||
|
const assignments: IpAssignment[] = [];
|
||||||
|
for (const machine of data.machines) {
|
||||||
|
assignments.push({
|
||||||
|
ip: machine.ipAddress,
|
||||||
|
ownerLabel: machine.machineName,
|
||||||
|
ownerKind: 'machine'
|
||||||
|
});
|
||||||
|
for (const vm of machine.software?.vms ?? []) {
|
||||||
|
assignments.push({
|
||||||
|
ip: vm.ipAddress,
|
||||||
|
ownerLabel: `${vm.name} (VM on ${machine.machineName})`,
|
||||||
|
ownerKind: 'vm'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const device of data.devices) {
|
||||||
|
assignments.push({ ip: device.ipAddress, ownerLabel: device.name, ownerKind: 'device' });
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostCapacity(prefix: number): number {
|
||||||
|
if (prefix >= 31) {
|
||||||
|
return prefix === 31 ? 2 : 1;
|
||||||
|
}
|
||||||
|
return 2 ** (32 - prefix) - 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIpamReport(data: NetworkData): IpamReport {
|
||||||
|
const assignments = collectIpAssignments(data);
|
||||||
|
const declared = data.subnets ?? [];
|
||||||
|
|
||||||
|
const parsed: Array<IpAssignment & { value: number }> = [];
|
||||||
|
const unparsed: IpAssignment[] = [];
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const value = parseIpv4(assignment.ip);
|
||||||
|
if (value === null) {
|
||||||
|
unparsed.push(assignment);
|
||||||
|
} else {
|
||||||
|
parsed.push({ ...assignment, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byIp = new Map<string, IpAssignment[]>();
|
||||||
|
for (const assignment of parsed) {
|
||||||
|
const normalizedIp = formatIpv4(assignment.value);
|
||||||
|
const owners = byIp.get(normalizedIp) ?? [];
|
||||||
|
owners.push(assignment);
|
||||||
|
byIp.set(normalizedIp, owners);
|
||||||
|
}
|
||||||
|
const duplicates = [...byIp.entries()]
|
||||||
|
.filter(([, owners]) => owners.length > 1)
|
||||||
|
.map(([ip, owners]) => ({ ip, owners: owners.map((owner) => owner.ownerLabel) }))
|
||||||
|
.sort((a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0));
|
||||||
|
|
||||||
|
const subnets: SubnetReport[] = [];
|
||||||
|
const claimed = new Set<IpAssignment>();
|
||||||
|
|
||||||
|
const declaredSorted = [...declared].sort(
|
||||||
|
(a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0)
|
||||||
|
);
|
||||||
|
const reportBySubnet = new Map<Subnet, IpAssignment[]>();
|
||||||
|
for (const subnet of declaredSorted) {
|
||||||
|
reportBySubnet.set(subnet, []);
|
||||||
|
}
|
||||||
|
for (const assignment of parsed) {
|
||||||
|
// most-specific declared subnet wins; each IP is counted once
|
||||||
|
const home = declaredSorted.find((subnet) => cidrContains(subnet.cidr, assignment.ip));
|
||||||
|
if (home) {
|
||||||
|
reportBySubnet.get(home)?.push(assignment);
|
||||||
|
claimed.add(assignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const subnet of declared) {
|
||||||
|
const members = (reportBySubnet.get(subnet) ?? []).sort(
|
||||||
|
(a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0)
|
||||||
|
);
|
||||||
|
const prefix = parseCidr(subnet.cidr)?.prefix ?? 24;
|
||||||
|
subnets.push({
|
||||||
|
cidr: subnet.cidr,
|
||||||
|
name: subnet.name,
|
||||||
|
vlanId: subnet.vlanId,
|
||||||
|
declared: true,
|
||||||
|
used: new Set(members.map((member) => member.ip)).size,
|
||||||
|
capacity: hostCapacity(prefix),
|
||||||
|
members
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredGroups = new Map<string, IpAssignment[]>();
|
||||||
|
for (const assignment of parsed) {
|
||||||
|
if (claimed.has(assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inferred = inferSlash24(assignment.ip);
|
||||||
|
if (!inferred) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const members = inferredGroups.get(inferred) ?? [];
|
||||||
|
members.push(assignment);
|
||||||
|
inferredGroups.set(inferred, members);
|
||||||
|
}
|
||||||
|
const inferredSorted = [...inferredGroups.entries()].sort(
|
||||||
|
(a, b) => (parseCidr(a[0])?.base ?? 0) - (parseCidr(b[0])?.base ?? 0)
|
||||||
|
);
|
||||||
|
for (const [cidr, members] of inferredSorted) {
|
||||||
|
members.sort((a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0));
|
||||||
|
subnets.push({
|
||||||
|
cidr,
|
||||||
|
declared: false,
|
||||||
|
used: new Set(members.map((member) => member.ip)).size,
|
||||||
|
capacity: hostCapacity(24),
|
||||||
|
members
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subnets, duplicates, unparsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowest unused host address in the subnet, skipping the network address,
|
||||||
|
* broadcast address and .1 (conventionally the gateway). Defaults to the
|
||||||
|
* most-populated subnet when no CIDR is given.
|
||||||
|
*/
|
||||||
|
export function suggestNextFreeIp(data: NetworkData, cidr?: string): string | null {
|
||||||
|
const report = buildIpamReport(data);
|
||||||
|
const target = cidr
|
||||||
|
? (report.subnets.find((subnet) => subnet.cidr === cidr) ??
|
||||||
|
(parseCidr(cidr) ? { cidr, members: [] as IpAssignment[] } : undefined))
|
||||||
|
: [...report.subnets].sort((a, b) => b.used - a.used)[0];
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = parseCidr(target.cidr);
|
||||||
|
if (!parsed || parsed.prefix > 30) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = new Set<number>();
|
||||||
|
for (const assignment of collectIpAssignments(data)) {
|
||||||
|
const value = parseIpv4(assignment.ip);
|
||||||
|
if (value !== null) {
|
||||||
|
used.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = 2 ** (32 - parsed.prefix);
|
||||||
|
const first = parsed.base + 1;
|
||||||
|
const last = parsed.base + size - 2;
|
||||||
|
for (let candidate = first; candidate <= last; candidate += 1) {
|
||||||
|
if (candidate % 256 === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!used.has(candidate)) {
|
||||||
|
return formatIpv4(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import type { CableInfo, Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
|
||||||
|
|
||||||
|
export type OwnerKind = 'machine' | 'device';
|
||||||
|
|
||||||
|
interface OwnerRef {
|
||||||
|
kind: OwnerKind;
|
||||||
|
name: string;
|
||||||
|
ports: Port[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepClone<T>(value: T): T {
|
||||||
|
if (typeof structuredClone === 'function') {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function equalsIgnoreCase(a: string, b: string): boolean {
|
||||||
|
return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePorts(ports: Port[] | undefined): Port[] {
|
||||||
|
return Array.isArray(ports) ? ports : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVms(vms: VM[] | undefined): VM[] {
|
||||||
|
return Array.isArray(vms) ? vms : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function allOwners(data: NetworkData): OwnerRef[] {
|
||||||
|
const machineOwners = data.machines.map((machine) => {
|
||||||
|
const ports = normalizePorts(machine.ports);
|
||||||
|
return {
|
||||||
|
kind: 'machine' as const,
|
||||||
|
name: machine.machineName,
|
||||||
|
ports
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const deviceOwners = data.devices.map((device) => {
|
||||||
|
const ports = normalizePorts(device.ports);
|
||||||
|
return {
|
||||||
|
kind: 'device' as const,
|
||||||
|
name: device.name,
|
||||||
|
ports
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [...machineOwners, ...deviceOwners];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneNetworkData(data: NetworkData): NetworkData {
|
||||||
|
const cloned = deepClone(data);
|
||||||
|
for (const machine of cloned.machines) {
|
||||||
|
machine.ports = normalizePorts(machine.ports);
|
||||||
|
machine.software = {
|
||||||
|
vms: normalizeVms(machine.software?.vms)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const device of cloned.devices) {
|
||||||
|
device.ports = normalizePorts(device.ports);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
machine.ports = normalizePorts(machine.ports);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
name: machine.machineName,
|
||||||
|
ports: machine.ports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = data.devices.find((item) => equalsIgnoreCase(item.name, name));
|
||||||
|
if (!device) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
device.ports = normalizePorts(device.ports);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
name: device.name,
|
||||||
|
ports: device.ports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOwnerByName(data: NetworkData, name: string): OwnerRef | undefined {
|
||||||
|
const owners = allOwners(data);
|
||||||
|
return owners.find((owner) => equalsIgnoreCase(owner.name, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPort(owner: OwnerRef, portName: string): Port | undefined {
|
||||||
|
return owner.ports.find((port) => equalsIgnoreCase(port.portName, portName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReciprocalConnections(data: NetworkData) {
|
||||||
|
const owners = allOwners(data);
|
||||||
|
|
||||||
|
for (const owner of owners) {
|
||||||
|
for (const port of owner.ports) {
|
||||||
|
if (!port.connectedTo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOwner = owners.find((candidate) =>
|
||||||
|
equalsIgnoreCase(candidate.name, port.connectedTo!.device)
|
||||||
|
);
|
||||||
|
if (!targetOwner) {
|
||||||
|
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 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
|
||||||
|
) {
|
||||||
|
for (const owner of allOwners(data)) {
|
||||||
|
for (const port of owner.ports) {
|
||||||
|
if (!port.connectedTo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (predicate(port.connectedTo)) {
|
||||||
|
delete port.connectedTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withReconciledConnections(data: NetworkData): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
ensureReciprocalConnections(cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyMachine(): Machine {
|
||||||
|
return {
|
||||||
|
machineName: 'New Machine',
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
role: 'Role',
|
||||||
|
operatingSystem: 'OS',
|
||||||
|
software: {
|
||||||
|
vms: []
|
||||||
|
},
|
||||||
|
hardware: {
|
||||||
|
cpu: 'CPU',
|
||||||
|
ram: '8GB',
|
||||||
|
networkPorts: 1
|
||||||
|
},
|
||||||
|
ports: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyDevice(): NetworkDevice {
|
||||||
|
return {
|
||||||
|
name: 'New Device',
|
||||||
|
ipAddress: '192.168.1.200',
|
||||||
|
type: 'Device',
|
||||||
|
notes: '',
|
||||||
|
ports: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyVm(): VM {
|
||||||
|
return {
|
||||||
|
name: 'New VM',
|
||||||
|
role: 'Role',
|
||||||
|
ipAddress: '192.168.1.201'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyPort(prefix = 'eth'): Port {
|
||||||
|
return {
|
||||||
|
portName: `${prefix}0`,
|
||||||
|
speedGbps: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameOwner(
|
||||||
|
data: NetworkData,
|
||||||
|
kind: OwnerKind,
|
||||||
|
previousName: string,
|
||||||
|
nextName: string
|
||||||
|
): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
if (equalsIgnoreCase(previousName, nextName)) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'machine') {
|
||||||
|
const machine = cloned.machines.find((item) =>
|
||||||
|
equalsIgnoreCase(item.machineName, previousName)
|
||||||
|
);
|
||||||
|
if (machine) {
|
||||||
|
machine.machineName = nextName;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const device = cloned.devices.find((item) => equalsIgnoreCase(item.name, previousName));
|
||||||
|
if (device) {
|
||||||
|
device.name = nextName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const owner of allOwners(cloned)) {
|
||||||
|
for (const port of owner.ports) {
|
||||||
|
if (port.connectedTo && equalsIgnoreCase(port.connectedTo.device, previousName)) {
|
||||||
|
port.connectedTo.device = nextName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureReciprocalConnections(cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renamePort(
|
||||||
|
data: NetworkData,
|
||||||
|
kind: OwnerKind,
|
||||||
|
ownerName: string,
|
||||||
|
previousPortName: string,
|
||||||
|
nextPortName: string
|
||||||
|
): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
if (equalsIgnoreCase(previousPortName, nextPortName)) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
|
||||||
|
if (!owner) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = findPort(owner, previousPortName);
|
||||||
|
if (!port) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
port.portName = nextPortName;
|
||||||
|
|
||||||
|
for (const candidateOwner of allOwners(cloned)) {
|
||||||
|
for (const candidatePort of candidateOwner.ports) {
|
||||||
|
if (
|
||||||
|
candidatePort.connectedTo &&
|
||||||
|
equalsIgnoreCase(candidatePort.connectedTo.device, owner.name) &&
|
||||||
|
equalsIgnoreCase(candidatePort.connectedTo.port, previousPortName)
|
||||||
|
) {
|
||||||
|
candidatePort.connectedTo.port = nextPortName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureReciprocalConnections(cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOwner(data: NetworkData, kind: OwnerKind, ownerName: string): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
|
||||||
|
if (kind === 'machine') {
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
ensureReciprocalConnections(cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePort(
|
||||||
|
data: NetworkData,
|
||||||
|
kind: OwnerKind,
|
||||||
|
ownerName: string,
|
||||||
|
portName: string
|
||||||
|
): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
|
||||||
|
if (!owner) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
owner.ports = owner.ports.filter((port) => !equalsIgnoreCase(port.portName, portName));
|
||||||
|
|
||||||
|
if (kind === 'machine') {
|
||||||
|
const machine = cloned.machines.find((item) => equalsIgnoreCase(item.machineName, ownerName));
|
||||||
|
if (machine) {
|
||||||
|
machine.ports = owner.ports;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const device = cloned.devices.find((item) => equalsIgnoreCase(item.name, ownerName));
|
||||||
|
if (device) {
|
||||||
|
device.ports = owner.ports;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInboundReferences(cloned, (connection) =>
|
||||||
|
Boolean(
|
||||||
|
connection &&
|
||||||
|
equalsIgnoreCase(connection.device, ownerName) &&
|
||||||
|
equalsIgnoreCase(connection.port, portName)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ensureReciprocalConnections(cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameRack(data: NetworkData, previousName: string, nextName: string): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || equalsIgnoreCase(previousName, trimmed)) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const machine of cloned.machines) {
|
||||||
|
if (machine.rack && equalsIgnoreCase(machine.rack.name, previousName)) {
|
||||||
|
machine.rack.name = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const device of cloned.devices) {
|
||||||
|
if (device.rack && equalsIgnoreCase(device.rack.name, previousName)) {
|
||||||
|
device.rack.name = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const definition = cloned.racks?.find((rack) => equalsIgnoreCase(rack.name, previousName));
|
||||||
|
if (definition) {
|
||||||
|
definition.name = trimmed;
|
||||||
|
}
|
||||||
|
// renaming onto an existing rack merges them; keep the first definition
|
||||||
|
if (cloned.racks) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
cloned.racks = cloned.racks.filter((rack) => {
|
||||||
|
const key = rack.name.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPortCable(
|
||||||
|
data: NetworkData,
|
||||||
|
kind: OwnerKind,
|
||||||
|
ownerName: string,
|
||||||
|
portName: string,
|
||||||
|
cable: CableInfo | undefined
|
||||||
|
): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
|
||||||
|
const port = owner ? findPort(owner, portName) : undefined;
|
||||||
|
if (!owner || !port?.connectedTo) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(cable ?? {}).filter(([, value]) =>
|
||||||
|
typeof value === 'number' ? Number.isFinite(value) : Boolean(value)
|
||||||
|
);
|
||||||
|
const normalized = entries.length > 0 ? (Object.fromEntries(entries) as CableInfo) : undefined;
|
||||||
|
|
||||||
|
if (normalized) {
|
||||||
|
port.connectedTo.cable = deepClone(normalized);
|
||||||
|
} else {
|
||||||
|
delete port.connectedTo.cable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerOwner = findOwnerByName(cloned, port.connectedTo.device);
|
||||||
|
const peerPort = peerOwner ? findPort(peerOwner, port.connectedTo.port) : undefined;
|
||||||
|
if (
|
||||||
|
peerPort?.connectedTo &&
|
||||||
|
equalsIgnoreCase(peerPort.connectedTo.device, owner.name) &&
|
||||||
|
equalsIgnoreCase(peerPort.connectedTo.port, port.portName)
|
||||||
|
) {
|
||||||
|
if (normalized) {
|
||||||
|
peerPort.connectedTo.cable = deepClone(normalized);
|
||||||
|
} else {
|
||||||
|
delete peerPort.connectedTo.cable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPortConnection(
|
||||||
|
data: NetworkData,
|
||||||
|
kind: OwnerKind,
|
||||||
|
ownerName: string,
|
||||||
|
portName: string,
|
||||||
|
target: Port['connectedTo'] | undefined
|
||||||
|
): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
|
||||||
|
if (!owner) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
const port = findPort(owner, portName);
|
||||||
|
if (!port) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousConnection = port.connectedTo;
|
||||||
|
if (previousConnection) {
|
||||||
|
const previousOwner = findOwnerByName(cloned, previousConnection.device);
|
||||||
|
const previousPort = previousOwner
|
||||||
|
? findPort(previousOwner, previousConnection.port)
|
||||||
|
: undefined;
|
||||||
|
if (previousPort) {
|
||||||
|
delete previousPort.connectedTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target?.device || !target.port) {
|
||||||
|
delete port.connectedTo;
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
port.connectedTo = {
|
||||||
|
device: target.device,
|
||||||
|
port: target.port
|
||||||
|
};
|
||||||
|
ensureReciprocalConnections(cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
normalizeNetworkData as normalizeNetworkDataCore,
|
||||||
|
validateNetworkData as validateNetworkDataCore
|
||||||
|
} from '../shared/networkSchemaCore.mjs';
|
||||||
|
import type { NetworkData } from '../types';
|
||||||
|
|
||||||
|
export interface ValidationIssue {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: ValidationIssue[];
|
||||||
|
data?: NetworkData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNetworkData(value: unknown): ValidationResult {
|
||||||
|
return validateNetworkDataCore(value) as ValidationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNetworkData(data: NetworkData): NetworkData {
|
||||||
|
return normalizeNetworkDataCore(data) as NetworkData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { NetworkData } from '../types';
|
||||||
|
|
||||||
|
export interface RackSlot {
|
||||||
|
kind: 'machine' | 'device';
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
unit: number;
|
||||||
|
heightU: number;
|
||||||
|
iconKey?: string;
|
||||||
|
subtitle: string;
|
||||||
|
/** Horizontal lane when several items share the same U (e.g. on a shelf). */
|
||||||
|
laneIndex: number;
|
||||||
|
laneCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RackColumn {
|
||||||
|
name: string;
|
||||||
|
heightU: number;
|
||||||
|
declared: boolean;
|
||||||
|
declaredHeightU?: number;
|
||||||
|
slots: RackSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnrackedEntity {
|
||||||
|
kind: 'machine' | 'device';
|
||||||
|
name: string;
|
||||||
|
iconKey?: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RackLayout {
|
||||||
|
racks: RackColumn[];
|
||||||
|
warnings: string[];
|
||||||
|
unrackedCount: number;
|
||||||
|
unracked: UnrackedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumRackHeightU = 12;
|
||||||
|
|
||||||
|
function slotsOverlap(a: RackSlot, b: RackSlot): boolean {
|
||||||
|
const aTop = a.unit + a.heightU - 1;
|
||||||
|
const bTop = b.unit + b.heightU - 1;
|
||||||
|
return a.unit <= bTop && b.unit <= aTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRange(a: RackSlot, b: RackSlot): boolean {
|
||||||
|
return a.unit === b.unit && a.heightU === b.heightU;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns horizontal lanes so overlapping items render side by side.
|
||||||
|
* Items occupying the exact same U range are treated as sharing a shelf and
|
||||||
|
* do not warn; partially overlapping ranges are reported as conflicts.
|
||||||
|
*/
|
||||||
|
function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) {
|
||||||
|
// connected components of the overlap graph
|
||||||
|
const componentOf = new Map<RackSlot, number>();
|
||||||
|
let componentCount = 0;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const touching = slots.filter((other) => componentOf.has(other) && slotsOverlap(slot, other));
|
||||||
|
const componentIds = new Set(touching.map((other) => componentOf.get(other) ?? 0));
|
||||||
|
if (componentIds.size === 0) {
|
||||||
|
componentOf.set(slot, componentCount);
|
||||||
|
componentCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [target] = [...componentIds].sort((a, b) => a - b);
|
||||||
|
componentOf.set(slot, target);
|
||||||
|
for (const [other, id] of componentOf.entries()) {
|
||||||
|
if (componentIds.has(id)) {
|
||||||
|
componentOf.set(other, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentSlots = new Map<number, RackSlot[]>();
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = componentOf.get(slot) ?? 0;
|
||||||
|
const members = componentSlots.get(id) ?? [];
|
||||||
|
members.push(slot);
|
||||||
|
componentSlots.set(id, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const members of componentSlots.values()) {
|
||||||
|
const ordered = [...members].sort((a, b) => a.unit - b.unit || a.name.localeCompare(b.name));
|
||||||
|
const lanes: RackSlot[][] = [];
|
||||||
|
for (const slot of ordered) {
|
||||||
|
let placed = false;
|
||||||
|
for (const [laneIndex, lane] of lanes.entries()) {
|
||||||
|
if (!lane.some((other) => slotsOverlap(slot, other))) {
|
||||||
|
lane.push(slot);
|
||||||
|
slot.laneIndex = laneIndex;
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
slot.laneIndex = lanes.length;
|
||||||
|
lanes.push([slot]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const slot of members) {
|
||||||
|
slot.laneCount = lanes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < ordered.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < ordered.length; j += 1) {
|
||||||
|
const a = ordered[i];
|
||||||
|
const b = ordered[j];
|
||||||
|
if (slotsOverlap(a, b) && !sameRange(a, b)) {
|
||||||
|
warnings.push(
|
||||||
|
`Rack "${rackName}": "${a.name}" (U${a.unit}–U${a.unit + a.heightU - 1}) partially overlaps "${b.name}" (U${b.unit}–U${b.unit + b.heightU - 1}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRackLayout(data: NetworkData): RackLayout {
|
||||||
|
const slotsByRack = new Map<string, RackSlot[]>();
|
||||||
|
const canonicalNames = new Map<string, string>();
|
||||||
|
const unracked: UnrackedEntity[] = [];
|
||||||
|
|
||||||
|
const canonical = (rawName: string): string => {
|
||||||
|
const trimmed = rawName.trim();
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
const existing = canonicalNames.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
canonicalNames.set(key, trimmed);
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const definition of data.racks ?? []) {
|
||||||
|
const name = canonical(definition.name);
|
||||||
|
if (!slotsByRack.has(name)) {
|
||||||
|
slotsByRack.set(name, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = (
|
||||||
|
slot: Omit<RackSlot, 'heightU' | 'laneIndex' | 'laneCount'> & { heightU?: number },
|
||||||
|
rackName: string
|
||||||
|
) => {
|
||||||
|
const name = canonical(rackName);
|
||||||
|
const slots = slotsByRack.get(name) ?? [];
|
||||||
|
slots.push({
|
||||||
|
...slot,
|
||||||
|
heightU: Math.max(1, Math.round(slot.heightU ?? 1)),
|
||||||
|
laneIndex: 0,
|
||||||
|
laneCount: 1
|
||||||
|
});
|
||||||
|
slotsByRack.set(name, slots);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [index, machine] of data.machines.entries()) {
|
||||||
|
if (!machine.rack?.name) {
|
||||||
|
unracked.push({
|
||||||
|
kind: 'machine',
|
||||||
|
name: machine.machineName,
|
||||||
|
iconKey: machine.iconKey,
|
||||||
|
subtitle: machine.role
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
place(
|
||||||
|
{
|
||||||
|
kind: 'machine',
|
||||||
|
name: machine.machineName,
|
||||||
|
index,
|
||||||
|
unit: machine.rack.unit,
|
||||||
|
heightU: machine.rack.heightU,
|
||||||
|
iconKey: machine.iconKey,
|
||||||
|
subtitle: machine.role
|
||||||
|
},
|
||||||
|
machine.rack.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [index, device] of data.devices.entries()) {
|
||||||
|
if (!device.rack?.name) {
|
||||||
|
unracked.push({
|
||||||
|
kind: 'device',
|
||||||
|
name: device.name,
|
||||||
|
iconKey: device.iconKey,
|
||||||
|
subtitle: device.type
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
place(
|
||||||
|
{
|
||||||
|
kind: 'device',
|
||||||
|
name: device.name,
|
||||||
|
index,
|
||||||
|
unit: device.rack.unit,
|
||||||
|
heightU: device.rack.heightU,
|
||||||
|
iconKey: device.iconKey,
|
||||||
|
subtitle: device.type
|
||||||
|
},
|
||||||
|
device.rack.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredByName = new Map(
|
||||||
|
(data.racks ?? []).map((definition) => [definition.name.trim().toLowerCase(), definition])
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const racks: RackColumn[] = [...slotsByRack.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, slots]) => {
|
||||||
|
const sorted = [...slots].sort((a, b) => b.unit - a.unit || a.name.localeCompare(b.name));
|
||||||
|
assignLanes(sorted, name, warnings);
|
||||||
|
const topUnit =
|
||||||
|
sorted.length > 0 ? Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)) : 0;
|
||||||
|
const definition = declaredByName.get(name.toLowerCase());
|
||||||
|
const declaredHeightU = definition?.heightU;
|
||||||
|
if (typeof declaredHeightU === 'number' && topUnit > declaredHeightU) {
|
||||||
|
warnings.push(
|
||||||
|
`Rack "${name}" is declared as ${declaredHeightU}U but items reach U${topUnit}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const heightU =
|
||||||
|
typeof declaredHeightU === 'number'
|
||||||
|
? Math.max(declaredHeightU, topUnit)
|
||||||
|
: Math.max(minimumRackHeightU, topUnit);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
heightU,
|
||||||
|
declared: Boolean(definition),
|
||||||
|
declaredHeightU,
|
||||||
|
slots: sorted
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { racks, warnings, unrackedCount: unracked.length, unracked };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function knownRackNames(data: NetworkData): string[] {
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
const add = (name: string | undefined) => {
|
||||||
|
const trimmed = name?.trim();
|
||||||
|
if (trimmed && !names.has(trimmed.toLowerCase())) {
|
||||||
|
names.set(trimmed.toLowerCase(), trimmed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const definition of data.racks ?? []) {
|
||||||
|
add(definition.name);
|
||||||
|
}
|
||||||
|
for (const machine of data.machines) {
|
||||||
|
add(machine.rack?.name);
|
||||||
|
}
|
||||||
|
for (const device of data.devices) {
|
||||||
|
add(device.rack?.name);
|
||||||
|
}
|
||||||
|
return [...names.values()].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Reads the graph-facing design tokens off the document root so the
|
||||||
|
// Cytoscape stylesheet (canvas-drawn edges) always matches the CSS theme.
|
||||||
|
// The --graph-* custom properties are plain hex on purpose: Cytoscape's
|
||||||
|
// colour parser doesn't understand oklch() or var().
|
||||||
|
export interface CanvasTokens {
|
||||||
|
bgCanvas: string;
|
||||||
|
edge: string;
|
||||||
|
border: string;
|
||||||
|
chipBg: string;
|
||||||
|
chipText: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbacks: CanvasTokens = {
|
||||||
|
bgCanvas: '#eef0f3',
|
||||||
|
edge: '#8b94a3',
|
||||||
|
border: '#dce0e6',
|
||||||
|
chipBg: '#ffffff',
|
||||||
|
chipText: '#3a4250',
|
||||||
|
accent: '#3565c4'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readCanvasTokens(): CanvasTokens {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return fallbacks;
|
||||||
|
}
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
|
||||||
|
return {
|
||||||
|
bgCanvas: read('--bg-canvas', fallbacks.bgCanvas),
|
||||||
|
edge: read('--graph-edge', fallbacks.edge),
|
||||||
|
border: read('--border', fallbacks.border),
|
||||||
|
chipBg: read('--graph-edge-chip-bg', fallbacks.chipBg),
|
||||||
|
chipText: read('--graph-edge-chip-text', fallbacks.chipText),
|
||||||
|
accent: read('--accent', fallbacks.accent)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { GraphNodeDetails, GraphNodeElement, GraphTransformResult } from './types';
|
||||||
|
|
||||||
|
function collectSearchableText(node: GraphNodeElement): string[] {
|
||||||
|
const texts: string[] = [node.data.rawName ?? node.data.label];
|
||||||
|
const details = node.data.details as GraphNodeDetails | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.push(details.ip);
|
||||||
|
if (details.type === 'machine') {
|
||||||
|
texts.push(details.role, details.os);
|
||||||
|
for (const vm of details.vms) {
|
||||||
|
texts.push(vm.name, vm.ip, vm.role);
|
||||||
|
}
|
||||||
|
} else if (details.type === 'device') {
|
||||||
|
texts.push(details.deviceType);
|
||||||
|
} else {
|
||||||
|
texts.push(details.role);
|
||||||
|
}
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the IDs of nodes matching the query (case-insensitive substring on
|
||||||
|
* name, IP, role, OS and device type). When a VM matches, its host machine ID
|
||||||
|
* is included too so hits remain visible while the host's VMs are collapsed.
|
||||||
|
*/
|
||||||
|
export function computeSearchMatches(
|
||||||
|
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||||
|
query: string
|
||||||
|
): Set<string> {
|
||||||
|
const matches = new Set<string>();
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of transformed.nodes) {
|
||||||
|
const isMatch = collectSearchableText(node).some((text) =>
|
||||||
|
text.toLowerCase().includes(normalized)
|
||||||
|
);
|
||||||
|
if (!isMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matches.add(node.data.id);
|
||||||
|
if (node.data.kind === 'vm' && node.data.hostMachineId) {
|
||||||
|
matches.add(node.data.hostMachineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered variant used for Enter/Shift-Enter cycling: node IDs in graph
|
||||||
|
* order, deduped, with VM hits mapped to their host so the camera can land
|
||||||
|
* on something visible while the host's VMs are collapsed.
|
||||||
|
*/
|
||||||
|
export function computeSearchMatchList(
|
||||||
|
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||||
|
query: string
|
||||||
|
): string[] {
|
||||||
|
const ordered: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of transformed.nodes) {
|
||||||
|
const isMatch = collectSearchableText(node).some((text) =>
|
||||||
|
text.toLowerCase().includes(normalized)
|
||||||
|
);
|
||||||
|
if (!isMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ids =
|
||||||
|
node.data.kind === 'vm' && node.data.hostMachineId
|
||||||
|
? [node.data.id, node.data.hostMachineId]
|
||||||
|
: [node.data.id];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
ordered.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { ConnectedPortRef, NetworkData, Port } from '../types';
|
import type { ConnectedPortRef, NetworkData, Port, Subnet } from '../types';
|
||||||
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
|
import { cidrContains, parseCidr } from '../data/ipam';
|
||||||
|
import { resolveIconPath } from '../config/iconRegistry';
|
||||||
|
import { buildVlanIndexMap } from './vlanPalette';
|
||||||
|
import {
|
||||||
|
NODE_DIMENSIONS,
|
||||||
|
estimatePillWidth,
|
||||||
|
type DeviceClass,
|
||||||
|
type GraphEdgeElement,
|
||||||
|
type GraphNodeElement,
|
||||||
|
type GraphTransformResult
|
||||||
|
} from './types';
|
||||||
|
|
||||||
interface EndpointOwner {
|
interface EndpointOwner {
|
||||||
kind: 'machine' | 'device';
|
kind: 'machine' | 'device';
|
||||||
@@ -8,8 +18,12 @@ interface EndpointOwner {
|
|||||||
ports: Map<string, Port>;
|
ports: Map<string, Port>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOwnerKey(kind: EndpointOwner['kind'], name: string): string {
|
function normalizeOwnerName(name: string): string {
|
||||||
return `${kind}:${name}`;
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOwnerLookupKey(kind: EndpointOwner['kind'], name: string): string {
|
||||||
|
return `${kind}:${normalizeOwnerName(name)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMachineNodeId(name: string): string {
|
function toMachineNodeId(name: string): string {
|
||||||
@@ -50,6 +64,55 @@ function connectionKey(a: string, b: string): string {
|
|||||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cableColorHex: Record<string, string> = {
|
||||||
|
blue: '#3b82f6',
|
||||||
|
red: '#ef4444',
|
||||||
|
green: '#22c55e',
|
||||||
|
yellow: '#eab308',
|
||||||
|
orange: '#f97316',
|
||||||
|
purple: '#a855f7',
|
||||||
|
pink: '#ec4899',
|
||||||
|
white: '#e2e8f0',
|
||||||
|
grey: '#94a3b8',
|
||||||
|
gray: '#94a3b8',
|
||||||
|
black: '#334155'
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveCableColor(name: string | undefined): string | undefined {
|
||||||
|
if (!name) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
return (
|
||||||
|
cableColorHex[trimmed.toLowerCase()] ??
|
||||||
|
(/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVlanResolver(subnets: Subnet[] | undefined) {
|
||||||
|
const vlanSubnets = (subnets ?? [])
|
||||||
|
.filter((subnet) => typeof subnet.vlanId === 'number')
|
||||||
|
.sort((a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0));
|
||||||
|
const vlanIndexById = buildVlanIndexMap(subnets);
|
||||||
|
return (ip: string): { vlanId: number; vlanIndex: number } | undefined => {
|
||||||
|
const home = vlanSubnets.find((subnet) => cidrContains(subnet.cidr, ip));
|
||||||
|
if (!home || typeof home.vlanId !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { vlanId: home.vlanId, vlanIndex: vlanIndexById.get(home.vlanId) ?? 0 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices whose type reads as network infrastructure get the dark "infra"
|
||||||
|
// treatment on the map; everything else (consoles, peripherals, UPSes) is an
|
||||||
|
// unmanaged "dumb" device rendered as a dashed pill.
|
||||||
|
const infrastructureTypePattern =
|
||||||
|
/switch|router|firewall|gateway|access\s?point|\bap\b|wan|isp|modem|dns|patch\s?panel/i;
|
||||||
|
|
||||||
|
export function classifyDevice(type: string | undefined): DeviceClass {
|
||||||
|
return type && infrastructureTypePattern.test(type) ? 'infrastructure' : 'dumb';
|
||||||
|
}
|
||||||
|
|
||||||
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
|
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
|
||||||
if (typeof a === 'number' && typeof b === 'number') {
|
if (typeof a === 'number' && typeof b === 'number') {
|
||||||
return Math.min(a, b);
|
return Math.min(a, b);
|
||||||
@@ -75,8 +138,9 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
vmCount: number;
|
vmCount: number;
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
const ownersByKey = new Map<string, EndpointOwner>();
|
const ownersByLookup = new Map<string, EndpointOwner>();
|
||||||
const seenWarnings = new Set<string>();
|
const seenWarnings = new Set<string>();
|
||||||
|
const resolveVlan = buildVlanResolver(data.subnets);
|
||||||
|
|
||||||
const warn = (message: string) => {
|
const warn = (message: string) => {
|
||||||
if (!seenWarnings.has(message)) {
|
if (!seenWarnings.has(message)) {
|
||||||
@@ -86,14 +150,14 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addOwner = (owner: EndpointOwner) => {
|
const addOwner = (owner: EndpointOwner) => {
|
||||||
const ownerKey = toOwnerKey(owner.kind, owner.name);
|
const ownerLookupKey = toOwnerLookupKey(owner.kind, owner.name);
|
||||||
if (ownersByKey.has(ownerKey)) {
|
if (ownersByLookup.has(ownerLookupKey)) {
|
||||||
warn(
|
warn(
|
||||||
`Duplicate ${owner.kind} name "${owner.name}" found. First occurrence will be used for link resolution.`
|
`Duplicate ${owner.kind} name "${owner.name}" found. First occurrence will be used for link resolution.`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ownersByKey.set(ownerKey, owner);
|
ownersByLookup.set(ownerLookupKey, owner);
|
||||||
};
|
};
|
||||||
|
|
||||||
let hostingEdgeCounter = 0;
|
let hostingEdgeCounter = 0;
|
||||||
@@ -105,7 +169,8 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
const machineVms = sourceMachineVms.map((vm) => ({
|
const machineVms = sourceMachineVms.map((vm) => ({
|
||||||
name: vm.name,
|
name: vm.name,
|
||||||
ip: vm.ipAddress,
|
ip: vm.ipAddress,
|
||||||
role: vm.role
|
role: vm.role,
|
||||||
|
macAddress: vm.macAddress
|
||||||
}));
|
}));
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
@@ -114,20 +179,27 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
label: machine.machineName,
|
label: machine.machineName,
|
||||||
rawName: machine.machineName,
|
rawName: machine.machineName,
|
||||||
kind: 'machine',
|
kind: 'machine',
|
||||||
nodeWidth: 200,
|
iconKey: machine.iconKey,
|
||||||
nodeHeight: 110,
|
iconUrl: resolveIconPath(machine.iconKey),
|
||||||
|
nodeWidth: NODE_DIMENSIONS.machine.width,
|
||||||
|
nodeHeight: NODE_DIMENSIONS.machine.height,
|
||||||
|
meta: machine.ipAddress,
|
||||||
|
vmCount: sourceMachineVms.length,
|
||||||
|
...resolveVlan(machine.ipAddress),
|
||||||
details: {
|
details: {
|
||||||
type: 'machine',
|
type: 'machine',
|
||||||
name: machine.machineName,
|
name: machine.machineName,
|
||||||
ip: machine.ipAddress,
|
iconKey: machine.iconKey,
|
||||||
role: machine.role,
|
ip: machine.ipAddress,
|
||||||
os: machine.operatingSystem,
|
role: machine.role,
|
||||||
cpu: machine.hardware?.cpu ?? 'Unknown',
|
os: machine.operatingSystem,
|
||||||
ram: machine.hardware?.ram ?? 'Unknown',
|
cpu: machine.hardware?.cpu ?? 'Unknown',
|
||||||
gpu: machine.hardware?.gpu ?? undefined,
|
ram: machine.hardware?.ram ?? 'Unknown',
|
||||||
ports: machinePortsList,
|
gpu: machine.hardware?.gpu ?? undefined,
|
||||||
vmCount: machineVms.length,
|
notes: machine.notes,
|
||||||
vms: machineVms
|
ports: machinePortsList,
|
||||||
|
vmCount: machineVms.length,
|
||||||
|
vms: machineVms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,13 +220,19 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
rawName: vm.name,
|
rawName: vm.name,
|
||||||
kind: 'vm',
|
kind: 'vm',
|
||||||
hostMachineId: machineNodeId,
|
hostMachineId: machineNodeId,
|
||||||
nodeWidth: 140,
|
iconKey: vm.iconKey,
|
||||||
nodeHeight: 66,
|
iconUrl: resolveIconPath(vm.iconKey),
|
||||||
|
nodeWidth: NODE_DIMENSIONS.vm.width,
|
||||||
|
nodeHeight: NODE_DIMENSIONS.vm.height,
|
||||||
|
meta: vm.ipAddress,
|
||||||
|
...resolveVlan(vm.ipAddress),
|
||||||
details: {
|
details: {
|
||||||
type: 'vm',
|
type: 'vm',
|
||||||
name: vm.name,
|
name: vm.name,
|
||||||
|
iconKey: vm.iconKey,
|
||||||
ip: vm.ipAddress,
|
ip: vm.ipAddress,
|
||||||
role: vm.role,
|
role: vm.role,
|
||||||
|
macAddress: vm.macAddress,
|
||||||
hostName: machine.machineName
|
hostName: machine.machineName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,8 +245,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
id: hostingEdgeId,
|
id: hostingEdgeId,
|
||||||
source: machineNodeId,
|
source: machineNodeId,
|
||||||
target: vmNodeId,
|
target: vmNodeId,
|
||||||
kind: 'hosting',
|
kind: 'hosting'
|
||||||
label: 'hosts'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,6 +269,15 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
for (const device of data.devices) {
|
for (const device of data.devices) {
|
||||||
const deviceNodeId = toDeviceNodeId(device.name);
|
const deviceNodeId = toDeviceNodeId(device.name);
|
||||||
const devicePorts = [...(device.ports ?? [])];
|
const devicePorts = [...(device.ports ?? [])];
|
||||||
|
const deviceClass = classifyDevice(device.type);
|
||||||
|
const portsInUse = devicePorts.filter((port) => parseConnectedTo(port.connectedTo)).length;
|
||||||
|
let deviceMeta: string | undefined;
|
||||||
|
if (deviceClass === 'infrastructure') {
|
||||||
|
deviceMeta =
|
||||||
|
devicePorts.length > 0
|
||||||
|
? `${devicePorts.length} ${devicePorts.length === 1 ? 'port' : 'ports'} · ${portsInUse} in use`
|
||||||
|
: device.ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
data: {
|
data: {
|
||||||
@@ -199,11 +285,23 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
label: device.name,
|
label: device.name,
|
||||||
rawName: device.name,
|
rawName: device.name,
|
||||||
kind: 'device',
|
kind: 'device',
|
||||||
nodeWidth: 176,
|
deviceClass,
|
||||||
nodeHeight: 116,
|
iconKey: device.iconKey,
|
||||||
|
iconUrl: resolveIconPath(device.iconKey),
|
||||||
|
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: {
|
details: {
|
||||||
type: 'device',
|
type: 'device',
|
||||||
name: device.name,
|
name: device.name,
|
||||||
|
iconKey: device.iconKey,
|
||||||
ip: device.ipAddress,
|
ip: device.ipAddress,
|
||||||
deviceType: device.type,
|
deviceType: device.type,
|
||||||
notes: device.notes,
|
notes: device.notes,
|
||||||
@@ -228,15 +326,17 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
const seenPhysicalConnections = new Set<string>();
|
const seenPhysicalConnections = new Set<string>();
|
||||||
let physicalEdgeCounter = 0;
|
let physicalEdgeCounter = 0;
|
||||||
|
|
||||||
for (const owner of ownersByKey.values()) {
|
for (const owner of ownersByLookup.values()) {
|
||||||
for (const port of owner.ports.values()) {
|
for (const port of owner.ports.values()) {
|
||||||
const connectedTo = parseConnectedTo(port.connectedTo);
|
const connectedTo = parseConnectedTo(port.connectedTo);
|
||||||
if (!connectedTo) {
|
if (!connectedTo) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const machineTargetOwner = ownersByKey.get(toOwnerKey('machine', connectedTo.device));
|
const machineTargetOwner = ownersByLookup.get(
|
||||||
const deviceTargetOwner = ownersByKey.get(toOwnerKey('device', connectedTo.device));
|
toOwnerLookupKey('machine', connectedTo.device)
|
||||||
|
);
|
||||||
|
const deviceTargetOwner = ownersByLookup.get(toOwnerLookupKey('device', connectedTo.device));
|
||||||
if (machineTargetOwner && deviceTargetOwner) {
|
if (machineTargetOwner && deviceTargetOwner) {
|
||||||
warn(
|
warn(
|
||||||
`Link from "${owner.name}:${port.portName}" references ambiguous device "${connectedTo.device}" (matches both machine and device names).`
|
`Link from "${owner.name}:${port.portName}" references ambiguous device "${connectedTo.device}" (matches both machine and device names).`
|
||||||
@@ -277,6 +377,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
|
|
||||||
physicalEdgeCounter += 1;
|
physicalEdgeCounter += 1;
|
||||||
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
|
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
|
||||||
|
const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable;
|
||||||
|
|
||||||
|
// One chip per cable: interface name (prefer the machine side —
|
||||||
|
// "eth0" reads better than "port 3") plus speed, with the network
|
||||||
|
// default of 1GbE omitted so only exceptions are informative.
|
||||||
|
const ifname =
|
||||||
|
owner.kind !== 'machine' && targetOwner.kind === 'machine'
|
||||||
|
? connectedTo.port
|
||||||
|
: port.portName;
|
||||||
|
const chipLabelNoSpeed = ifname;
|
||||||
|
const chipLabel =
|
||||||
|
typeof speedGbps === 'number' && speedGbps !== 1 ? `${ifname} · ${speedGbps}GbE` : ifname;
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
data: {
|
data: {
|
||||||
@@ -284,11 +396,18 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
|
|||||||
source: owner.nodeId,
|
source: owner.nodeId,
|
||||||
target: targetOwner.nodeId,
|
target: targetOwner.nodeId,
|
||||||
kind: 'physical',
|
kind: 'physical',
|
||||||
label: speedGbps ? `${speedGbps}GbE` : undefined,
|
chipLabel,
|
||||||
|
chipLabelNoSpeed,
|
||||||
sourcePort: port.portName,
|
sourcePort: port.portName,
|
||||||
targetPort: connectedTo.port,
|
targetPort: connectedTo.port,
|
||||||
speedGbps,
|
speedGbps,
|
||||||
reciprocal
|
reciprocal,
|
||||||
|
...(cable?.type ? { cableType: cable.type } : {}),
|
||||||
|
...(cable?.color ? { cableColorName: cable.color } : {}),
|
||||||
|
...(resolveCableColor(cable?.color)
|
||||||
|
? { cableColor: resolveCableColor(cable?.color) }
|
||||||
|
: {}),
|
||||||
|
...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,76 @@
|
|||||||
export type GraphNodeKind = 'machine' | 'device' | 'vm';
|
export type GraphNodeKind = 'machine' | 'device' | 'vm';
|
||||||
export type GraphEdgeKind = 'physical' | 'hosting';
|
export type GraphEdgeKind = 'physical' | 'hosting';
|
||||||
|
export type DeviceClass = 'infrastructure' | 'dumb';
|
||||||
|
|
||||||
|
// Hit-target dimensions for the invisible Cytoscape nodes. The HTML card
|
||||||
|
// layer sizes its cards from the same constants so edges anchor exactly at
|
||||||
|
// card borders.
|
||||||
|
export const NODE_DIMENSIONS = {
|
||||||
|
machine: { width: 218, height: 56 },
|
||||||
|
infrastructure: { width: 218, height: 56 },
|
||||||
|
vm: { width: 170, height: 44 },
|
||||||
|
dumbHeight: 36
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Content-hugging pills need a width so edges anchor at the card border:
|
||||||
|
// 24px icon tile + gaps/padding ≈ 54px, plus the rendered text width.
|
||||||
|
// Text is measured with a canvas context (matching the pill's 12.5px type);
|
||||||
|
// the char-count estimate is only an SSR/test fallback.
|
||||||
|
let pillMeasureContext: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
|
export function estimatePillWidth(name: string): number {
|
||||||
|
let textWidth = name.length * 7.6;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
pillMeasureContext ??= document.createElement('canvas').getContext('2d');
|
||||||
|
if (pillMeasureContext) {
|
||||||
|
pillMeasureContext.font =
|
||||||
|
'600 12.5px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
||||||
|
textWidth = pillMeasureContext.measureText(name).width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(Math.min(300, Math.max(110, textWidth + 60)));
|
||||||
|
}
|
||||||
|
|
||||||
export interface PortDetails {
|
export interface PortDetails {
|
||||||
portName: string;
|
portName: string;
|
||||||
speedGbps?: number;
|
speedGbps?: number;
|
||||||
|
macAddress?: string;
|
||||||
connectedTo?: {
|
connectedTo?: {
|
||||||
device: string;
|
device: string;
|
||||||
port: string;
|
port: string;
|
||||||
|
cable?: {
|
||||||
|
type?: string;
|
||||||
|
color?: string;
|
||||||
|
lengthM?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MachineDetails {
|
export interface MachineDetails {
|
||||||
type: 'machine';
|
type: 'machine';
|
||||||
name: string;
|
name: string;
|
||||||
|
iconKey?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
role: string;
|
role: string;
|
||||||
os: string;
|
os: string;
|
||||||
cpu: string;
|
cpu: string;
|
||||||
ram: string;
|
ram: string;
|
||||||
gpu?: string;
|
gpu?: string;
|
||||||
|
notes?: string;
|
||||||
ports: PortDetails[];
|
ports: PortDetails[];
|
||||||
vmCount: number;
|
vmCount: number;
|
||||||
vms: Array<{
|
vms: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
macAddress?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceDetails {
|
export interface DeviceDetails {
|
||||||
type: 'device';
|
type: 'device';
|
||||||
name: string;
|
name: string;
|
||||||
|
iconKey?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -40,8 +80,10 @@ export interface DeviceDetails {
|
|||||||
export interface VmDetails {
|
export interface VmDetails {
|
||||||
type: 'vm';
|
type: 'vm';
|
||||||
name: string;
|
name: string;
|
||||||
|
iconKey?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
macAddress?: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +93,17 @@ export interface GraphNodeData {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
kind: GraphNodeKind;
|
kind: GraphNodeKind;
|
||||||
|
deviceClass?: DeviceClass;
|
||||||
rawName?: string;
|
rawName?: string;
|
||||||
hostMachineId?: string;
|
hostMachineId?: string;
|
||||||
nodeWidth?: number;
|
nodeWidth?: number;
|
||||||
nodeHeight?: number;
|
nodeHeight?: number;
|
||||||
|
iconUrl?: string;
|
||||||
|
iconKey?: string;
|
||||||
|
vlanId?: number;
|
||||||
|
vlanIndex?: number;
|
||||||
|
meta?: string;
|
||||||
|
vmCount?: number;
|
||||||
details?: GraphNodeDetails;
|
details?: GraphNodeDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +113,16 @@ export interface GraphEdgeData {
|
|||||||
target: string;
|
target: string;
|
||||||
kind: GraphEdgeKind;
|
kind: GraphEdgeKind;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
chipLabel?: string;
|
||||||
|
chipLabelNoSpeed?: string;
|
||||||
sourcePort?: string;
|
sourcePort?: string;
|
||||||
targetPort?: string;
|
targetPort?: string;
|
||||||
speedGbps?: number;
|
speedGbps?: number;
|
||||||
reciprocal?: boolean;
|
reciprocal?: boolean;
|
||||||
|
cableType?: string;
|
||||||
|
cableColorName?: string;
|
||||||
|
cableColor?: string;
|
||||||
|
cableLengthM?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphNodeElement {
|
export interface GraphNodeElement {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Subnet } from '$lib/types';
|
||||||
|
|
||||||
|
// Categorical VLAN palette from the UX review: equal lightness/chroma
|
||||||
|
// (oklch 0.55/0.13 light, 0.68/0.13 dark), hue varies, so no VLAN shouts
|
||||||
|
// louder than another. The actual oklch values live in app.css as
|
||||||
|
// --vlan-0..--vlan-5 (light + dark variants); consumers are all HTML/CSS,
|
||||||
|
// so we hand out var() references and let the theme resolve them.
|
||||||
|
export const VLAN_PALETTE_SIZE = 6;
|
||||||
|
|
||||||
|
export function vlanPaletteColor(index: number): string {
|
||||||
|
const slot = ((Math.trunc(index) % VLAN_PALETTE_SIZE) + VLAN_PALETTE_SIZE) % VLAN_PALETTE_SIZE;
|
||||||
|
return `var(--vlan-${slot})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VLANs are assigned palette slots in declaration order (first appearance in
|
||||||
|
// data.subnets), not by vlanId, so the first VLAN a user declares always gets
|
||||||
|
// the first hue.
|
||||||
|
export function buildVlanIndexMap(subnets: Subnet[] | undefined): Map<number, number> {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
for (const subnet of subnets ?? []) {
|
||||||
|
const { vlanId } = subnet;
|
||||||
|
if (typeof vlanId !== 'number' || !Number.isFinite(vlanId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!map.has(vlanId)) {
|
||||||
|
map.set(vlanId, map.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy hex palette, still consumed by the pre-redesign Cytoscape node
|
||||||
|
// border styling; removed once the HTML card layer takes over VLAN colour.
|
||||||
|
const legacyPalette = [
|
||||||
|
'#e11d48',
|
||||||
|
'#7c3aed',
|
||||||
|
'#0891b2',
|
||||||
|
'#ca8a04',
|
||||||
|
'#15803d',
|
||||||
|
'#c2410c',
|
||||||
|
'#4f46e5',
|
||||||
|
'#0d9488',
|
||||||
|
'#be185d',
|
||||||
|
'#57534e'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function vlanColor(vlanId: number): string {
|
||||||
|
return legacyPalette[Math.abs(Math.trunc(vlanId)) % legacyPalette.length];
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
checkWritableState as checkWritableStateCore,
|
||||||
|
getWritableState as getWritableStateCore,
|
||||||
|
isWriteEnabled as isWriteEnabledCore,
|
||||||
|
readOrInitializeNetworkFile as readOrInitializeNetworkFileCore,
|
||||||
|
readNetworkFile as readNetworkFileCore,
|
||||||
|
writeNetworkFile as writeNetworkFileCore
|
||||||
|
} from '../shared/networkPersistenceCore.mjs';
|
||||||
|
|
||||||
|
import type { NetworkData } from '../types';
|
||||||
|
|
||||||
|
export interface NetworkFileMetadata {
|
||||||
|
source: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritableState {
|
||||||
|
writable: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkFileBootstrapResult extends NetworkFileMetadata {
|
||||||
|
data: NetworkData;
|
||||||
|
initialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWriteEnabled(): boolean {
|
||||||
|
return isWriteEnabledCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readNetworkFile(): Promise<{ data: NetworkData } & NetworkFileMetadata> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWritableState(): Promise<WritableState> {
|
||||||
|
return (await getWritableStateCore()) as WritableState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkWritableState(): Promise<boolean> {
|
||||||
|
return checkWritableStateCore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import { constants } from 'node:fs';
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
open,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rename,
|
||||||
|
stat,
|
||||||
|
unlink
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
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}
|
||||||
|
*/
|
||||||
|
function resolveDataFilePath() {
|
||||||
|
return path.resolve(process.env.NETWORK_DATA_FILE ?? DATA_FILE_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveBackupDirectory() {
|
||||||
|
return path.resolve(process.env.NETWORK_BACKUP_DIR ?? BACKUP_DIR_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isWriteEnabled() {
|
||||||
|
return !NETWORK_READ_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} error
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatFileSystemErrorCode(error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
return 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} directory
|
||||||
|
* @param {string} label
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
async function assertDirectoryWritable(directory, label) {
|
||||||
|
try {
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
return `${label} directory "${directory}" could not be created (${formatFileSystemErrorCode(error)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(directory, constants.W_OK);
|
||||||
|
} catch (error) {
|
||||||
|
return `${label} directory "${directory}" is not writable (${formatFileSystemErrorCode(error)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dataFilePath
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function readUpdatedAt(dataFilePath) {
|
||||||
|
const dataFileStats = await stat(dataFilePath);
|
||||||
|
return dataFileStats.mtime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<NetworkFileReadState>}
|
||||||
|
*/
|
||||||
|
export async function readNetworkFileState() {
|
||||||
|
const source = resolveDataFilePath();
|
||||||
|
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
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function createBackupIfPresent(dataFilePath, backupDirectory) {
|
||||||
|
try {
|
||||||
|
await access(dataFilePath, constants.F_OK);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(backupDirectory, { recursive: true });
|
||||||
|
const base = path.basename(dataFilePath);
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupFilePath = path.join(backupDirectory, `${base}.${timestamp}.bak`);
|
||||||
|
await copyFile(dataFilePath, backupFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} backupDirectory
|
||||||
|
* @param {string} baseName
|
||||||
|
* @param {number} [keep]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function trimBackups(backupDirectory, baseName, keep = 5) {
|
||||||
|
/** @type {Array<{ name: string; time: number }>} */
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
const files = await readdir(backupDirectory);
|
||||||
|
entries = await Promise.all(
|
||||||
|
files
|
||||||
|
.filter((file) => file.startsWith(`${baseName}.`) && file.endsWith('.bak'))
|
||||||
|
.map(async (file) => {
|
||||||
|
const filePath = path.join(backupDirectory, file);
|
||||||
|
const info = await stat(filePath);
|
||||||
|
return { name: filePath, time: info.mtimeMs };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => b.time - a.time);
|
||||||
|
await Promise.all(entries.slice(keep).map((entry) => unlink(entry.name).catch(() => undefined)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {NetworkData} data
|
||||||
|
* @param {string} source
|
||||||
|
* @param {WriteNetworkFileOptions} [options]
|
||||||
|
* @returns {Promise<NetworkFileMetadata>}
|
||||||
|
*/
|
||||||
|
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 });
|
||||||
|
|
||||||
|
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
|
||||||
|
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
|
||||||
|
const fileHandle = await open(temporaryPath, 'w');
|
||||||
|
try {
|
||||||
|
await fileHandle.writeFile(jsonPayload, 'utf8');
|
||||||
|
await fileHandle.sync();
|
||||||
|
} finally {
|
||||||
|
await fileHandle.close();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options?.beforeCommit?.();
|
||||||
|
if (options?.createBackup ?? true) {
|
||||||
|
await createBackupIfPresent(source, backupDirectory);
|
||||||
|
}
|
||||||
|
await rename(temporaryPath, source);
|
||||||
|
} catch (error) {
|
||||||
|
await unlink(temporaryPath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trimBackups(backupDirectory, path.basename(source), 5);
|
||||||
|
const updatedAt = await readUpdatedAt(source);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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>}
|
||||||
|
*/
|
||||||
|
export async function getWritableState() {
|
||||||
|
if (!isWriteEnabled()) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: 'Writes disabled by NETWORK_READ_ONLY=true.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = resolveDataFilePath();
|
||||||
|
const directory = path.dirname(source);
|
||||||
|
const dataDirectoryError = await assertDirectoryWritable(directory, 'Data');
|
||||||
|
if (dataDirectoryError) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: dataDirectoryError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupDirectory = resolveBackupDirectory();
|
||||||
|
const backupDirectoryError = await assertDirectoryWritable(backupDirectory, 'Backup');
|
||||||
|
if (backupDirectoryError) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: backupDirectoryError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(source, constants.F_OK);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
writable: true,
|
||||||
|
reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-bitwise -- Node fs access mode flags combine bitwise
|
||||||
|
await access(source, constants.R_OK | constants.W_OK);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: `Data file "${source}" exists but is not readable and writable (${formatFileSystemErrorCode(error)}).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
writable: true,
|
||||||
|
reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function checkWritableState() {
|
||||||
|
return (await getWritableState()).writable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{ path: string; message: string }} ValidationIssue
|
||||||
|
* @typedef {{ type?: string; color?: string; lengthM?: number }} CableInfo
|
||||||
|
* @typedef {{ device: string; port: string; cable?: CableInfo }} ConnectedTo
|
||||||
|
* @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port
|
||||||
|
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM
|
||||||
|
* @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware
|
||||||
|
* @typedef {{ name: string; unit: number; heightU?: number }} RackPlacement
|
||||||
|
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[]; rack?: RackPlacement }} Machine
|
||||||
|
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[]; rack?: RackPlacement }} NetworkDevice
|
||||||
|
* @typedef {{ cidr: string; name?: string; vlanId?: number }} Subnet
|
||||||
|
* @typedef {{ name: string; heightU?: number }} RackDefinition
|
||||||
|
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[]; racks?: RackDefinition[] }} NetworkData
|
||||||
|
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
||||||
|
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
||||||
|
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
||||||
|
* @typedef {{ port: Port; originalIndex: number }} PortEntry
|
||||||
|
* @typedef {{ vm: VM; originalIndex: number }} VmEntry
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {T} value
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
|
function deepClone(value) {
|
||||||
|
if (typeof structuredClone === 'function') {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {value is Record<string, unknown>}
|
||||||
|
*/
|
||||||
|
function isRecord(value) {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {ReadStringOptions} [options]
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
function readString(issues, path, value, options) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
if (options?.optional) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
issues.push({ path, message: 'is required' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
issues.push({ path, message: 'must be a string' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!options?.allowEmpty && trimmed.length === 0) {
|
||||||
|
issues.push({ path, message: 'cannot be empty' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {ReadNumberOptions} [options]
|
||||||
|
* @returns {number | undefined}
|
||||||
|
*/
|
||||||
|
function readNumber(issues, path, value, options) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
if (options?.optional) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
issues.push({ path, message: 'is required' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
|
issues.push({ path, message: 'must be a number' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof options?.min === 'number' && value < options.min) {
|
||||||
|
issues.push({ path, message: `must be >= ${options.min}` });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {string} ownerLabel
|
||||||
|
* @returns {Port | null}
|
||||||
|
*/
|
||||||
|
export function normalizePort(issues, path, value, ownerLabel) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: `must be an object (${ownerLabel} port)` });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portName = readString(issues, `${path}.portName`, value.portName);
|
||||||
|
const speedGbps = readNumber(issues, `${path}.speedGbps`, value.speedGbps, {
|
||||||
|
optional: true,
|
||||||
|
min: 0
|
||||||
|
});
|
||||||
|
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let connectedTo;
|
||||||
|
if (value.connectedTo !== undefined) {
|
||||||
|
if (!isRecord(value.connectedTo)) {
|
||||||
|
issues.push({ path: `${path}.connectedTo`, message: 'must be an object' });
|
||||||
|
} else {
|
||||||
|
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
|
||||||
|
const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port);
|
||||||
|
let cable;
|
||||||
|
const cableRaw = value.connectedTo.cable;
|
||||||
|
if (cableRaw !== undefined) {
|
||||||
|
if (!isRecord(cableRaw)) {
|
||||||
|
issues.push({ path: `${path}.connectedTo.cable`, message: 'must be an object' });
|
||||||
|
} else {
|
||||||
|
const cableType = readString(issues, `${path}.connectedTo.cable.type`, cableRaw.type, {
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
});
|
||||||
|
const cableColor = readString(issues, `${path}.connectedTo.cable.color`, cableRaw.color, {
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
});
|
||||||
|
const lengthM = readNumber(
|
||||||
|
issues,
|
||||||
|
`${path}.connectedTo.cable.lengthM`,
|
||||||
|
cableRaw.lengthM,
|
||||||
|
{
|
||||||
|
optional: true,
|
||||||
|
min: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const candidate = {
|
||||||
|
...(cableType ? { type: cableType } : {}),
|
||||||
|
...(cableColor ? { color: cableColor } : {}),
|
||||||
|
...(typeof lengthM === 'number' ? { lengthM } : {})
|
||||||
|
};
|
||||||
|
if (Object.keys(candidate).length > 0) {
|
||||||
|
cable = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (device && port) {
|
||||||
|
connectedTo = { device, port, ...(cable ? { cable } : {}) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!portName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
portName,
|
||||||
|
...(typeof speedGbps === 'number' ? { speedGbps } : {}),
|
||||||
|
...(macAddress ? { macAddress } : {}),
|
||||||
|
...(connectedTo ? { connectedTo } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {RackPlacement | undefined}
|
||||||
|
*/
|
||||||
|
function normalizeRack(issues, path, value) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (rack placement)' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const name = readString(issues, `${path}.name`, value.name);
|
||||||
|
const unit = readNumber(issues, `${path}.unit`, value.unit, { min: 1 });
|
||||||
|
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||||
|
if (!name || typeof unit !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
unit,
|
||||||
|
...(typeof heightU === 'number' ? { heightU } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {PortEntry[]} portEntries
|
||||||
|
* @param {string} path
|
||||||
|
* @param {string} ownerName
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function validatePortUniqueness(issues, portEntries, path, ownerName) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const { port, originalIndex } of portEntries) {
|
||||||
|
const key = port.portName.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
path: `${path}[${originalIndex}].portName`,
|
||||||
|
message: `duplicate port name "${port.portName}" for ${ownerName}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {VM | null}
|
||||||
|
*/
|
||||||
|
export function normalizeVm(issues, path, value) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (vm)' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = readString(issues, `${path}.name`, value.name);
|
||||||
|
const role = readString(issues, `${path}.role`, value.role);
|
||||||
|
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||||
|
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||||
|
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!name || !role || !ipAddress) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
ipAddress,
|
||||||
|
...(iconKey ? { iconKey } : {}),
|
||||||
|
...(macAddress ? { macAddress } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {Machine | null}
|
||||||
|
*/
|
||||||
|
function normalizeMachine(issues, path, value) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (machine)' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineName = readString(issues, `${path}.machineName`, value.machineName);
|
||||||
|
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
|
||||||
|
const role = readString(issues, `${path}.role`, value.role);
|
||||||
|
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
|
||||||
|
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||||
|
const notes = readString(issues, `${path}.notes`, value.notes, {
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
});
|
||||||
|
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||||
|
|
||||||
|
const softwareRaw = value.software;
|
||||||
|
if (!isRecord(softwareRaw)) {
|
||||||
|
issues.push({ path: `${path}.software`, message: 'must be an object' });
|
||||||
|
}
|
||||||
|
const vmRaw = isRecord(softwareRaw) ? softwareRaw.vms : undefined;
|
||||||
|
if (!Array.isArray(vmRaw)) {
|
||||||
|
issues.push({ path: `${path}.software.vms`, message: 'must be an array' });
|
||||||
|
}
|
||||||
|
/** @type {VmEntry[]} */
|
||||||
|
const vmEntries = [];
|
||||||
|
const vms = [];
|
||||||
|
for (const [vmIndex, vmValue] of (Array.isArray(vmRaw) ? vmRaw : []).entries()) {
|
||||||
|
const vm = normalizeVm(issues, `${path}.software.vms[${vmIndex}]`, vmValue);
|
||||||
|
if (vm) {
|
||||||
|
vmEntries.push({ vm, originalIndex: vmIndex });
|
||||||
|
vms.push(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmSeen = new Set();
|
||||||
|
for (const { vm, originalIndex } of vmEntries) {
|
||||||
|
const vmKey = vm.name.toLowerCase();
|
||||||
|
if (vmSeen.has(vmKey)) {
|
||||||
|
issues.push({
|
||||||
|
path: `${path}.software.vms[${originalIndex}].name`,
|
||||||
|
message: `duplicate VM name "${vm.name}" for machine "${machineName ?? 'unknown'}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vmSeen.add(vmKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardwareRaw = value.hardware;
|
||||||
|
if (!isRecord(hardwareRaw)) {
|
||||||
|
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
|
||||||
|
),
|
||||||
|
networkPorts: readNumber(
|
||||||
|
issues,
|
||||||
|
`${path}.hardware.networkPorts`,
|
||||||
|
isRecord(hardwareRaw) ? hardwareRaw.networkPorts : undefined,
|
||||||
|
{ min: 0 }
|
||||||
|
),
|
||||||
|
networkPortSpeedGbps: readNumber(
|
||||||
|
issues,
|
||||||
|
`${path}.hardware.networkPortSpeedGbps`,
|
||||||
|
isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined,
|
||||||
|
{ optional: true, min: 0 }
|
||||||
|
),
|
||||||
|
gpu: readString(
|
||||||
|
issues,
|
||||||
|
`${path}.hardware.gpu`,
|
||||||
|
isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined,
|
||||||
|
{
|
||||||
|
optional: true,
|
||||||
|
allowEmpty: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const portsRaw = value.ports;
|
||||||
|
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
|
||||||
|
issues.push({ path: `${path}.ports`, message: 'must be an array when provided' });
|
||||||
|
}
|
||||||
|
/** @type {PortEntry[]} */
|
||||||
|
const portEntries = [];
|
||||||
|
const ports = [];
|
||||||
|
for (const [portIndex, portValue] of (Array.isArray(portsRaw) ? portsRaw : []).entries()) {
|
||||||
|
const port = normalizePort(issues, `${path}.ports[${portIndex}]`, portValue, 'machine');
|
||||||
|
if (port) {
|
||||||
|
portEntries.push({ port, originalIndex: portIndex });
|
||||||
|
ports.push(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validatePortUniqueness(issues, portEntries, `${path}.ports`, machineName ?? 'machine');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!machineName ||
|
||||||
|
!ipAddress ||
|
||||||
|
!role ||
|
||||||
|
!operatingSystem ||
|
||||||
|
!hardware.cpu ||
|
||||||
|
!hardware.ram ||
|
||||||
|
typeof hardware.networkPorts !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
machineName,
|
||||||
|
ipAddress,
|
||||||
|
role,
|
||||||
|
operatingSystem,
|
||||||
|
...(iconKey ? { iconKey } : {}),
|
||||||
|
...(notes !== undefined ? { notes } : {}),
|
||||||
|
...(rack ? { rack } : {}),
|
||||||
|
software: { vms },
|
||||||
|
hardware: {
|
||||||
|
cpu: hardware.cpu,
|
||||||
|
ram: hardware.ram,
|
||||||
|
networkPorts: hardware.networkPorts,
|
||||||
|
...(typeof hardware.networkPortSpeedGbps === 'number'
|
||||||
|
? { networkPortSpeedGbps: hardware.networkPortSpeedGbps }
|
||||||
|
: {}),
|
||||||
|
...(hardware.gpu ? { gpu: hardware.gpu } : {})
|
||||||
|
},
|
||||||
|
ports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {NetworkDevice | null}
|
||||||
|
*/
|
||||||
|
function normalizeDevice(issues, path, value) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (device)' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
|
||||||
|
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
|
||||||
|
|
||||||
|
const portsRaw = value.ports;
|
||||||
|
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
|
||||||
|
issues.push({ path: `${path}.ports`, message: 'must be an array when provided' });
|
||||||
|
}
|
||||||
|
/** @type {PortEntry[]} */
|
||||||
|
const portEntries = [];
|
||||||
|
const ports = [];
|
||||||
|
for (const [portIndex, portValue] of (Array.isArray(portsRaw) ? portsRaw : []).entries()) {
|
||||||
|
const port = normalizePort(issues, `${path}.ports[${portIndex}]`, portValue, 'device');
|
||||||
|
if (port) {
|
||||||
|
portEntries.push({ port, originalIndex: portIndex });
|
||||||
|
ports.push(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validatePortUniqueness(issues, portEntries, `${path}.ports`, name ?? 'device');
|
||||||
|
|
||||||
|
if (!name || !ipAddress || !type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ipAddress,
|
||||||
|
type,
|
||||||
|
...(iconKey ? { iconKey } : {}),
|
||||||
|
...(notes !== undefined ? { notes } : {}),
|
||||||
|
...(rack ? { rack } : {}),
|
||||||
|
ports
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipv4CidrPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} cidr
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isValidIpv4Cidr(cidr) {
|
||||||
|
const match = ipv4CidrPattern.exec(cidr);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const octets = match.slice(1, 5).map(Number);
|
||||||
|
const prefix = Number(match[5]);
|
||||||
|
return octets.every((octet) => octet <= 255) && prefix <= 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {Subnet | null}
|
||||||
|
*/
|
||||||
|
function normalizeSubnet(issues, path, value) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (subnet)' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidr = readString(issues, `${path}.cidr`, value.cidr);
|
||||||
|
if (cidr && !isValidIpv4Cidr(cidr)) {
|
||||||
|
issues.push({ path: `${path}.cidr`, message: 'must be an IPv4 CIDR like "192.168.1.0/24"' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = readString(issues, `${path}.name`, value.name, { optional: true, allowEmpty: true });
|
||||||
|
const vlanId = readNumber(issues, `${path}.vlanId`, value.vlanId, { optional: true, min: 1 });
|
||||||
|
if (typeof vlanId === 'number' && vlanId > 4094) {
|
||||||
|
issues.push({ path: `${path}.vlanId`, message: 'must be <= 4094' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cidr) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cidr,
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(typeof vlanId === 'number' ? { vlanId } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {RackDefinition | null}
|
||||||
|
*/
|
||||||
|
function normalizeRackDefinition(issues, path, value) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (rack)' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = readString(issues, `${path}.name`, value.name);
|
||||||
|
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...(typeof heightU === 'number' ? { heightU } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {ValidationResult}
|
||||||
|
*/
|
||||||
|
export function validateNetworkData(value) {
|
||||||
|
/** @type {ValidationIssue[]} */
|
||||||
|
const issues = [];
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [{ path: '$', message: 'must be an object with machines/devices arrays' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const machinesRaw = value.machines;
|
||||||
|
if (!Array.isArray(machinesRaw)) {
|
||||||
|
issues.push({ path: '$.machines', message: 'must be an array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicesRaw = value.devices;
|
||||||
|
if (!Array.isArray(devicesRaw)) {
|
||||||
|
issues.push({ path: '$.devices', message: 'must be an array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const machines = [];
|
||||||
|
const machineEntries = [];
|
||||||
|
for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw)
|
||||||
|
? machinesRaw
|
||||||
|
: []
|
||||||
|
).entries()) {
|
||||||
|
const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue);
|
||||||
|
if (machine) {
|
||||||
|
machines.push(machine);
|
||||||
|
machineEntries.push({ machine, originalIndex: machineIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = [];
|
||||||
|
const deviceEntries = [];
|
||||||
|
for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw)
|
||||||
|
? devicesRaw
|
||||||
|
: []
|
||||||
|
).entries()) {
|
||||||
|
const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue);
|
||||||
|
if (device) {
|
||||||
|
devices.push(device);
|
||||||
|
deviceEntries.push({ device, originalIndex: deviceIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenMachineNames = new Set();
|
||||||
|
for (const { machine, originalIndex } of machineEntries) {
|
||||||
|
const key = machine.machineName.toLowerCase();
|
||||||
|
if (seenMachineNames.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
path: `$.machines[${originalIndex}].machineName`,
|
||||||
|
message: `duplicate machine name "${machine.machineName}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenMachineNames.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenDeviceNames = new Set();
|
||||||
|
for (const { device, originalIndex } of deviceEntries) {
|
||||||
|
const key = device.name.toLowerCase();
|
||||||
|
if (seenDeviceNames.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
path: `$.devices[${originalIndex}].name`,
|
||||||
|
message: `duplicate device name "${device.name}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenDeviceNames.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { machine, originalIndex } of machineEntries) {
|
||||||
|
if (seenDeviceNames.has(machine.machineName.toLowerCase())) {
|
||||||
|
issues.push({
|
||||||
|
path: `$.machines[${originalIndex}].machineName`,
|
||||||
|
message: `name "${machine.machineName}" collides with a device name`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnetsRaw = value.subnets;
|
||||||
|
const subnetsProvided = subnetsRaw !== undefined;
|
||||||
|
if (subnetsProvided && !Array.isArray(subnetsRaw)) {
|
||||||
|
issues.push({ path: '$.subnets', message: 'must be an array when provided' });
|
||||||
|
}
|
||||||
|
const subnets = [];
|
||||||
|
const subnetEntries = [];
|
||||||
|
for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw)
|
||||||
|
? subnetsRaw
|
||||||
|
: []
|
||||||
|
).entries()) {
|
||||||
|
const subnet = normalizeSubnet(issues, `$.subnets[${subnetIndex}]`, subnetValue);
|
||||||
|
if (subnet) {
|
||||||
|
subnets.push(subnet);
|
||||||
|
subnetEntries.push({ subnet, originalIndex: subnetIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenCidrs = new Set();
|
||||||
|
for (const { subnet, originalIndex } of subnetEntries) {
|
||||||
|
if (seenCidrs.has(subnet.cidr)) {
|
||||||
|
issues.push({
|
||||||
|
path: `$.subnets[${originalIndex}].cidr`,
|
||||||
|
message: `duplicate subnet CIDR "${subnet.cidr}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenCidrs.add(subnet.cidr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const racksRaw = value.racks;
|
||||||
|
const racksProvided = racksRaw !== undefined;
|
||||||
|
if (racksProvided && !Array.isArray(racksRaw)) {
|
||||||
|
issues.push({ path: '$.racks', message: 'must be an array when provided' });
|
||||||
|
}
|
||||||
|
const racks = [];
|
||||||
|
const rackEntries = [];
|
||||||
|
for (const [rackIndex, rackValue] of (Array.isArray(racksRaw) ? racksRaw : []).entries()) {
|
||||||
|
const rack = normalizeRackDefinition(issues, `$.racks[${rackIndex}]`, rackValue);
|
||||||
|
if (rack) {
|
||||||
|
racks.push(rack);
|
||||||
|
rackEntries.push({ rack, originalIndex: rackIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenRackNames = new Set();
|
||||||
|
for (const { rack, originalIndex } of rackEntries) {
|
||||||
|
const key = rack.name.toLowerCase();
|
||||||
|
if (seenRackNames.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
path: `$.racks[${originalIndex}].name`,
|
||||||
|
message: `duplicate rack name "${rack.name}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenRackNames.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
machines,
|
||||||
|
devices,
|
||||||
|
...(subnetsProvided ? { subnets } : {}),
|
||||||
|
...(racksProvided ? { racks } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: issues.length === 0,
|
||||||
|
errors: issues,
|
||||||
|
...(issues.length === 0 ? { data } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {NetworkData} data
|
||||||
|
* @returns {NetworkData}
|
||||||
|
*/
|
||||||
|
export function normalizeNetworkData(data) {
|
||||||
|
const cloned = deepClone(data);
|
||||||
|
const validated = validateNetworkData(cloned);
|
||||||
|
if (!validated.valid || !validated.data) {
|
||||||
|
return {
|
||||||
|
machines: [],
|
||||||
|
devices: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return validated.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ond-theme';
|
||||||
|
|
||||||
|
function getInitialTheme(): ThemeMode {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: ThemeMode) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThemeStore() {
|
||||||
|
const store = writable<ThemeMode>('light');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: store.subscribe,
|
||||||
|
initialize() {
|
||||||
|
const theme = getInitialTheme();
|
||||||
|
store.set(theme);
|
||||||
|
applyTheme(theme);
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
store.update((current) => {
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
}
|
||||||
|
applyTheme(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
set(theme: ThemeMode) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}
|
||||||
|
applyTheme(theme);
|
||||||
|
store.set(theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeMode = createThemeStore();
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
|
export interface CableInfo {
|
||||||
|
type?: string;
|
||||||
|
color?: string;
|
||||||
|
lengthM?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectedPortRef {
|
export interface ConnectedPortRef {
|
||||||
device: string;
|
device: string;
|
||||||
port: string;
|
port: string;
|
||||||
|
cable?: CableInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Port {
|
export interface Port {
|
||||||
portName: string;
|
portName: string;
|
||||||
speedGbps?: number; // default to 1 if not provided
|
speedGbps?: number; // default to 1 if not provided
|
||||||
|
macAddress?: string;
|
||||||
connectedTo?: ConnectedPortRef;
|
connectedTo?: ConnectedPortRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RackPlacement {
|
||||||
|
name: string;
|
||||||
|
unit: number; // bottom U position, 1-based
|
||||||
|
heightU?: number; // defaults to 1
|
||||||
|
}
|
||||||
|
|
||||||
export interface Hardware {
|
export interface Hardware {
|
||||||
cpu: string;
|
cpu: string;
|
||||||
ram: string;
|
ram: string;
|
||||||
@@ -21,6 +35,8 @@ export interface VM {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
|
iconKey?: string;
|
||||||
|
macAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Software {
|
export interface Software {
|
||||||
@@ -32,20 +48,38 @@ export interface Machine {
|
|||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
role: string;
|
role: string;
|
||||||
operatingSystem: string;
|
operatingSystem: string;
|
||||||
|
iconKey?: string;
|
||||||
|
notes?: string;
|
||||||
software: Software;
|
software: Software;
|
||||||
hardware: Hardware;
|
hardware: Hardware;
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
|
rack?: RackPlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkDevice {
|
export interface NetworkDevice {
|
||||||
name: string;
|
name: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
iconKey?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
|
rack?: RackPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subnet {
|
||||||
|
cidr: string;
|
||||||
|
name?: string;
|
||||||
|
vlanId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RackDefinition {
|
||||||
|
name: string;
|
||||||
|
heightU?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkData {
|
export interface NetworkData {
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
devices: NetworkDevice[];
|
devices: NetworkDevice[];
|
||||||
|
subnets?: Subnet[];
|
||||||
|
racks?: RackDefinition[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
import { validateNetworkData } from '$lib/data/networkSchema';
|
||||||
|
import {
|
||||||
|
getWritableState,
|
||||||
|
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,
|
||||||
|
message: issue.message
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyErrorMessage(reason: string | null): string {
|
||||||
|
if (!reason) {
|
||||||
|
return 'Write API unavailable in current deployment.';
|
||||||
|
}
|
||||||
|
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([
|
||||||
|
readOrInitializeNetworkFile(),
|
||||||
|
getWritableState()
|
||||||
|
]);
|
||||||
|
const validation = validateNetworkData(data);
|
||||||
|
if (!validation.valid || !validation.data) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: 'Stored network data is invalid',
|
||||||
|
details: serializeValidationErrors(validation.errors),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableState.reason
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
data: validation.data,
|
||||||
|
writable: writableState.writable,
|
||||||
|
writableReason: writableState.reason,
|
||||||
|
source,
|
||||||
|
updatedAt
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableStateBeforeWrite.reason
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'Request body must be valid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateNetworkData(payload);
|
||||||
|
if (!validation.valid || !validation.data) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: 'Network data validation failed',
|
||||||
|
details: serializeValidationErrors(validation.errors)
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await writeNetworkFile(validation.data);
|
||||||
|
const writableStateAfterWrite = await getWritableState();
|
||||||
|
return json({
|
||||||
|
data: validation.data,
|
||||||
|
writable: writableStateAfterWrite.writable,
|
||||||
|
writableReason: writableStateAfterWrite.reason,
|
||||||
|
source: metadata.source,
|
||||||
|
updatedAt: metadata.updatedAt
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error && error.message ? error.message : 'Failed to persist network data';
|
||||||
|
return json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,55 +5,36 @@
|
|||||||
"ipAddress": "10.0.0.3",
|
"ipAddress": "10.0.0.3",
|
||||||
"role": "Hypervisor",
|
"role": "Hypervisor",
|
||||||
"operatingSystem": "Proxmox",
|
"operatingSystem": "Proxmox",
|
||||||
"ports": [
|
"iconKey": "homarr:proxmox",
|
||||||
{
|
"notes": "Primary router box; do not power off without failover in place.",
|
||||||
"portName": "eth0",
|
|
||||||
"speedGbps": 1,
|
|
||||||
"connectedTo": {
|
|
||||||
"device": "Gigabit Switch",
|
|
||||||
"port": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"portName": "eth1",
|
|
||||||
"speedGbps": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"portName": "eth2",
|
|
||||||
"speedGbps": 1,
|
|
||||||
"connectedTo": {
|
|
||||||
"device": "Gigabit Switch",
|
|
||||||
"port": "2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"portName": "eth3",
|
|
||||||
"speedGbps": 1,
|
|
||||||
"connectedTo": {
|
|
||||||
"device": "WAN Uplink",
|
|
||||||
"port": "wan"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"software": {
|
"software": {
|
||||||
"vms": [
|
"vms": [
|
||||||
{
|
{
|
||||||
"name": "OpnSense",
|
"name": "OpnSense",
|
||||||
"role": "Router/Firewall/Gateway (DHCP 10.0.0.100-254)",
|
"role": "Router/Firewall/Gateway (DHCP 10.0.0.100-254)",
|
||||||
"ipAddress": "10.0.0.1"
|
"ipAddress": "10.0.0.1",
|
||||||
|
"macAddress": "bc:24:11:5a:2e:01"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "TPLink Omada Controller",
|
"name": "TPLink Omada Controller",
|
||||||
"role": "Network Controller (:8043)",
|
"role": "Network Controller (:8043)",
|
||||||
"ipAddress": "10.0.0.4"
|
"ipAddress": "10.0.0.4"
|
||||||
},
|
},
|
||||||
{ "name": "PiVPN (WireGuard)", "role": "VPN Server", "ipAddress": "10.0.0.5" },
|
{
|
||||||
|
"name": "PiVPN (WireGuard)",
|
||||||
|
"role": "VPN Server",
|
||||||
|
"ipAddress": "10.0.0.5"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "PiHole",
|
"name": "PiHole",
|
||||||
"role": "DNS Ad-blocker (installed, not in active use)",
|
"role": "DNS Ad-blocker (installed, not in active use)",
|
||||||
"ipAddress": "10.0.0.6"
|
"ipAddress": "10.0.0.6"
|
||||||
},
|
},
|
||||||
{ "name": "Dashy", "role": "Dashboard", "ipAddress": "10.0.0.12" },
|
{
|
||||||
|
"name": "Dashy",
|
||||||
|
"role": "Dashboard",
|
||||||
|
"ipAddress": "10.0.0.12"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ProxRouter-Docker",
|
"name": "ProxRouter-Docker",
|
||||||
"role": "Docker host (Nginx reverse proxy, RustDesk, TwinGate)",
|
"role": "Docker host (Nginx reverse proxy, RustDesk, TwinGate)",
|
||||||
@@ -66,6 +47,51 @@
|
|||||||
"ram": "8GB",
|
"ram": "8GB",
|
||||||
"networkPorts": 4,
|
"networkPorts": 4,
|
||||||
"networkPortSpeedGbps": 1
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "eth0",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"macAddress": "84:47:09:1c:aa:10",
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "Gigabit Switch",
|
||||||
|
"port": "1",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"portName": "eth1",
|
||||||
|
"speedGbps": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"portName": "eth2",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "Gigabit Switch",
|
||||||
|
"port": "2",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"portName": "eth3",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "WAN Uplink",
|
||||||
|
"port": "wan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,29 +99,47 @@
|
|||||||
"ipAddress": "10.0.0.9",
|
"ipAddress": "10.0.0.9",
|
||||||
"role": "NAS",
|
"role": "NAS",
|
||||||
"operatingSystem": "Asustor ADM",
|
"operatingSystem": "Asustor ADM",
|
||||||
|
"iconKey": "homarr:asustor",
|
||||||
|
"software": {
|
||||||
|
"vms": []
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "Realtek RTD1296 Quad Core 1.4GHz",
|
||||||
|
"ram": "2GB",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Gigabit Switch",
|
"device": "Gigabit Switch",
|
||||||
"port": "3"
|
"port": "3",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat5e",
|
||||||
|
"color": "red",
|
||||||
|
"lengthM": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"software": { "vms": [] },
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "Realtek RTD1296 Quad Core 1.4GHz",
|
|
||||||
"ram": "2GB",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "Home Assistant Green",
|
"machineName": "Home Assistant Green",
|
||||||
"ipAddress": "10.0.0.13",
|
"ipAddress": "10.0.0.13",
|
||||||
"role": "Smart Home Controller",
|
"role": "Smart Home Controller",
|
||||||
"operatingSystem": "Home Assistant OS",
|
"operatingSystem": "Home Assistant OS",
|
||||||
|
"iconKey": "homarr:home-assistant",
|
||||||
|
"software": {
|
||||||
|
"vms": []
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "Home Assistant Custom SoC",
|
||||||
|
"ram": "Unknown",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -105,20 +149,23 @@
|
|||||||
"port": "4"
|
"port": "4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"software": { "vms": [] },
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "Home Assistant Custom SoC",
|
|
||||||
"ram": "Unknown",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "Plex Server",
|
"machineName": "Plex Server",
|
||||||
"ipAddress": "10.0.0.11",
|
"ipAddress": "10.0.0.11",
|
||||||
"role": "Media Server",
|
"role": "Media Server",
|
||||||
"operatingSystem": "Ubuntu Server",
|
"operatingSystem": "Ubuntu Server",
|
||||||
|
"iconKey": "homarr:plex",
|
||||||
|
"software": {
|
||||||
|
"vms": []
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "Intel N100",
|
||||||
|
"ram": "Unknown",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -128,20 +175,23 @@
|
|||||||
"port": "5"
|
"port": "5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"software": { "vms": [] },
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "Intel N100",
|
|
||||||
"ram": "Unknown",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "Win11 N100",
|
"machineName": "Win11 N100",
|
||||||
"ipAddress": "10.0.0.8",
|
"ipAddress": "10.0.0.8",
|
||||||
"role": "Media Downloader",
|
"role": "Media Downloader",
|
||||||
"operatingSystem": "Windows 11",
|
"operatingSystem": "Windows 11",
|
||||||
|
"iconKey": "homarr:windows-11",
|
||||||
|
"software": {
|
||||||
|
"vms": []
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "Intel N100",
|
||||||
|
"ram": "Unknown",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -151,20 +201,40 @@
|
|||||||
"port": "6"
|
"port": "6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"software": { "vms": [] },
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "Intel N100",
|
|
||||||
"ram": "Unknown",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "Win11 Backblaze NAS",
|
"machineName": "Win11 Backblaze NAS",
|
||||||
"ipAddress": "10.0.0.7",
|
"ipAddress": "10.0.0.7",
|
||||||
"role": "Backup NAS + Hypervisor",
|
"role": "Backup NAS + Hypervisor",
|
||||||
"operatingSystem": "TrueNAS Scale",
|
"operatingSystem": "TrueNAS Scale",
|
||||||
|
"iconKey": "homarr:backblaze",
|
||||||
|
"software": {
|
||||||
|
"vms": [
|
||||||
|
{
|
||||||
|
"name": "Win11NAS",
|
||||||
|
"role": "Backblaze Backup VM",
|
||||||
|
"ipAddress": "10.0.0.24"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MakeMKV",
|
||||||
|
"role": "Blu-ray Ripper",
|
||||||
|
"ipAddress": "10.0.0.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Handbrake",
|
||||||
|
"role": "Video Transcoder",
|
||||||
|
"ipAddress": "10.0.0.15"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "AMD Ryzen 5 4600G",
|
||||||
|
"ram": "16GB",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1,
|
||||||
|
"gpu": "EVGA GeForce GTX 1050 Ti"
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -174,37 +244,14 @@
|
|||||||
"port": "7"
|
"port": "7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"software": {
|
|
||||||
"vms": [
|
|
||||||
{ "name": "Win11NAS", "role": "Backblaze Backup VM", "ipAddress": "10.0.0.24" },
|
|
||||||
{ "name": "MakeMKV", "role": "Blu-ray Ripper", "ipAddress": "10.0.0.14" },
|
|
||||||
{ "name": "Handbrake", "role": "Video Transcoder", "ipAddress": "10.0.0.15" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "AMD Ryzen 5 4600G",
|
|
||||||
"ram": "16GB",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1,
|
|
||||||
"gpu": "EVGA GeForce GTX 1050 Ti"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"machineName": "TrueNAS Host",
|
"machineName": "TrueNAS Host",
|
||||||
"ipAddress": "10.0.0.10",
|
"ipAddress": "10.0.0.10",
|
||||||
"role": "Storage + VM Host",
|
"role": "Storage + VM Host",
|
||||||
"operatingSystem": "TrueNAS Scale",
|
"operatingSystem": "TrueNAS Scale",
|
||||||
"ports": [
|
"iconKey": "homarr:truenas",
|
||||||
{
|
|
||||||
"portName": "eth0",
|
|
||||||
"speedGbps": 1,
|
|
||||||
"connectedTo": {
|
|
||||||
"device": "Gigabit Switch",
|
|
||||||
"port": "8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"software": {
|
"software": {
|
||||||
"vms": [
|
"vms": [
|
||||||
{
|
{
|
||||||
@@ -219,6 +266,21 @@
|
|||||||
"ram": "Unknown",
|
"ram": "Unknown",
|
||||||
"networkPorts": 1,
|
"networkPorts": 1,
|
||||||
"networkPortSpeedGbps": 1
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "eth0",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "Gigabit Switch",
|
||||||
|
"port": "8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 7,
|
||||||
|
"heightU": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -226,6 +288,28 @@
|
|||||||
"ipAddress": "10.0.0.20",
|
"ipAddress": "10.0.0.20",
|
||||||
"role": "AI Dev/Inference",
|
"role": "AI Dev/Inference",
|
||||||
"operatingSystem": "Pop!_OS",
|
"operatingSystem": "Pop!_OS",
|
||||||
|
"iconKey": "homarr:ollama-dark",
|
||||||
|
"software": {
|
||||||
|
"vms": [
|
||||||
|
{
|
||||||
|
"name": "Ollama",
|
||||||
|
"role": "LLM Inference",
|
||||||
|
"ipAddress": "10.0.0.21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bot Training",
|
||||||
|
"role": "AI Training",
|
||||||
|
"ipAddress": "10.0.0.22"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "AMD Ryzen 5 3600",
|
||||||
|
"ram": "32GB",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1,
|
||||||
|
"gpu": "Gigabyte GeForce GTX 1080"
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -236,18 +320,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"software": {
|
"rack": {
|
||||||
"vms": [
|
"name": "Lab Rack",
|
||||||
{ "name": "Ollama", "role": "LLM Inference", "ipAddress": "10.0.0.21" },
|
"unit": 4,
|
||||||
{ "name": "Bot Training", "role": "AI Training", "ipAddress": "10.0.0.22" }
|
"heightU": 3
|
||||||
]
|
|
||||||
},
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "AMD Ryzen 5 3600",
|
|
||||||
"ram": "32GB",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1,
|
|
||||||
"gpu": "Gigabyte GeForce GTX 1080"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -255,6 +331,16 @@
|
|||||||
"ipAddress": "10.0.0.30",
|
"ipAddress": "10.0.0.30",
|
||||||
"role": "Photo Server",
|
"role": "Photo Server",
|
||||||
"operatingSystem": "Linux",
|
"operatingSystem": "Linux",
|
||||||
|
"iconKey": "homarr:immich",
|
||||||
|
"software": {
|
||||||
|
"vms": []
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu": "Unknown",
|
||||||
|
"ram": "Unknown",
|
||||||
|
"networkPorts": 1,
|
||||||
|
"networkPortSpeedGbps": 1
|
||||||
|
},
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -264,14 +350,7 @@
|
|||||||
"port": "10"
|
"port": "10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"software": { "vms": [] },
|
|
||||||
"hardware": {
|
|
||||||
"cpu": "Unknown",
|
|
||||||
"ram": "Unknown",
|
|
||||||
"networkPorts": 1,
|
|
||||||
"networkPortSpeedGbps": 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"devices": [
|
"devices": [
|
||||||
@@ -279,6 +358,7 @@
|
|||||||
"name": "Gigabit Switch",
|
"name": "Gigabit Switch",
|
||||||
"ipAddress": "unknown",
|
"ipAddress": "unknown",
|
||||||
"type": "Network Switch",
|
"type": "Network Switch",
|
||||||
|
"iconKey": "switch",
|
||||||
"notes": "Main 24-port switch. Wireless access point uplinks through this switch.",
|
"notes": "Main 24-port switch. Wireless access point uplinks through this switch.",
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
@@ -286,7 +366,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "ProxRouter",
|
"device": "ProxRouter",
|
||||||
"port": "eth0"
|
"port": "eth0",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -294,7 +379,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "ProxRouter",
|
"device": "ProxRouter",
|
||||||
"port": "eth2"
|
"port": "eth2",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat6",
|
||||||
|
"color": "blue",
|
||||||
|
"lengthM": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -302,7 +392,12 @@
|
|||||||
"speedGbps": 1,
|
"speedGbps": 1,
|
||||||
"connectedTo": {
|
"connectedTo": {
|
||||||
"device": "Asustor NAS",
|
"device": "Asustor NAS",
|
||||||
"port": "eth0"
|
"port": "eth0",
|
||||||
|
"cable": {
|
||||||
|
"type": "Cat5e",
|
||||||
|
"color": "red",
|
||||||
|
"lengthM": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -403,7 +498,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"portName": "20",
|
"portName": "20",
|
||||||
"speedGbps": 1
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "NanoKVM Lite",
|
||||||
|
"port": "port0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"portName": "21",
|
"portName": "21",
|
||||||
@@ -421,7 +520,11 @@
|
|||||||
"portName": "24",
|
"portName": "24",
|
||||||
"speedGbps": 1
|
"speedGbps": 1
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 12
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "WAN Uplink",
|
"name": "WAN Uplink",
|
||||||
@@ -443,6 +546,7 @@
|
|||||||
"name": "HDHomeRun",
|
"name": "HDHomeRun",
|
||||||
"ipAddress": "10.0.0.2",
|
"ipAddress": "10.0.0.2",
|
||||||
"type": "TV Tuner",
|
"type": "TV Tuner",
|
||||||
|
"iconKey": "homarr:hdhomerun",
|
||||||
"ports": [
|
"ports": [
|
||||||
{
|
{
|
||||||
"portName": "eth0",
|
"portName": "eth0",
|
||||||
@@ -452,43 +556,99 @@
|
|||||||
"port": "11"
|
"port": "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "3DS",
|
"name": "3DS",
|
||||||
"ipAddress": "10.0.0.17",
|
"ipAddress": "10.0.0.17",
|
||||||
"type": "Handheld Console",
|
"type": "Handheld Console",
|
||||||
"notes": "Wi-Fi only."
|
"notes": "Wi-Fi only.",
|
||||||
|
"ports": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "2DS",
|
"name": "2DS",
|
||||||
"ipAddress": "10.0.0.18",
|
"ipAddress": "10.0.0.18",
|
||||||
"type": "Handheld Console",
|
"type": "Handheld Console",
|
||||||
"notes": "Wi-Fi only."
|
"notes": "Wi-Fi only.",
|
||||||
|
"ports": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Nintendo Switch",
|
"name": "Nintendo Switch",
|
||||||
"ipAddress": "10.0.0.19",
|
"ipAddress": "10.0.0.19",
|
||||||
"type": "Gaming Console",
|
"type": "Gaming Console",
|
||||||
"notes": "Wi-Fi only."
|
"notes": "Wi-Fi only.",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "port0",
|
||||||
|
"speedGbps": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "NanoKVM Lite",
|
"name": "NanoKVM Lite",
|
||||||
"ipAddress": "10.0.0.26",
|
"ipAddress": "10.0.0.26",
|
||||||
"type": "KVM Device",
|
"type": "KVM Device",
|
||||||
"notes": "Port link not yet modeled."
|
"notes": "Port link not yet modeled.",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "port0",
|
||||||
|
"speedGbps": 1,
|
||||||
|
"connectedTo": {
|
||||||
|
"device": "Gigabit Switch",
|
||||||
|
"port": "20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "UPS Pi",
|
"name": "UPS Pi",
|
||||||
"ipAddress": "10.0.0.27",
|
"ipAddress": "10.0.0.27",
|
||||||
"type": "Power Monitoring Device",
|
"type": "Power Monitoring Device",
|
||||||
"notes": "Port link not yet modeled."
|
"notes": "Port link not yet modeled.",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"portName": "port0",
|
||||||
|
"speedGbps": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 1,
|
||||||
|
"heightU": 2
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Waveshare",
|
"name": "Waveshare",
|
||||||
"ipAddress": "10.0.0.28",
|
"ipAddress": "10.0.0.28",
|
||||||
"type": "Peripheral Device",
|
"type": "Peripheral Device",
|
||||||
"notes": "Port link not yet modeled."
|
"notes": "Port link not yet modeled.",
|
||||||
|
"ports": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subnets": [
|
||||||
|
{
|
||||||
|
"cidr": "10.0.0.0/24",
|
||||||
|
"name": "LAN",
|
||||||
|
"vlanId": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cidr": "10.0.20.0/24",
|
||||||
|
"name": "IoT",
|
||||||
|
"vlanId": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"racks": [
|
||||||
|
{
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"heightU": 12
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<path d="M24 46h24a10 10 0 0 0 2-19 14 14 0 0 0-27-2 9 9 0 0 0 1 21z" fill="#0ea5e9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 169 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect x="10" y="10" width="44" height="30" rx="4" fill="#7c3aed"/>
|
||||||
|
<rect x="24" y="42" width="16" height="6" rx="2" fill="#c4b5fd"/>
|
||||||
|
<rect x="18" y="50" width="28" height="4" rx="2" fill="#a78bfa"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect x="9" y="24" width="46" height="20" rx="5" fill="#2563eb"/>
|
||||||
|
<path d="M18 20c4-4 8-6 14-6s10 2 14 6" stroke="#93c5fd" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M22 24c3-3 6-4 10-4s7 1 10 4" stroke="#bfdbfe" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="20" cy="34" r="2" fill="#dbeafe"/>
|
||||||
|
<circle cx="28" cy="34" r="2" fill="#dbeafe"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect x="10" y="10" width="44" height="18" rx="4" fill="#0ea5e9"/>
|
||||||
|
<rect x="10" y="36" width="44" height="18" rx="4" fill="#0284c7"/>
|
||||||
|
<circle cx="18" cy="19" r="2" fill="#e0f2fe"/>
|
||||||
|
<circle cx="18" cy="45" r="2" fill="#e0f2fe"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect x="12" y="10" width="40" height="44" rx="6" fill="#16a34a"/>
|
||||||
|
<rect x="18" y="18" width="28" height="8" rx="2" fill="#dcfce7"/>
|
||||||
|
<rect x="18" y="30" width="28" height="8" rx="2" fill="#dcfce7"/>
|
||||||
|
<rect x="18" y="42" width="28" height="8" rx="2" fill="#dcfce7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect x="8" y="18" width="48" height="28" rx="6" fill="#f59e0b"/>
|
||||||
|
<rect x="14" y="26" width="8" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="24" y="26" width="8" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="34" y="26" width="8" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="44" y="26" width="6" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="14" y="34" width="8" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="24" y="34" width="8" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="34" y="34" width="8" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
<rect x="44" y="34" width="6" height="4" rx="1" fill="#fff7ed"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |