68 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-15 13:33:29 +00:00
Josh Creek a067cc3231 Merge pull request #21 from jcreek/feat/dark-mode
Feat/dark mode
2026-07-13 22:07:06 +01:00
Josh Creek 68496ae904 feat(*): Improve dark mode contrast 2026-07-13 22:01:28 +01:00
Josh Creek e58e427093 docs(*): Update screenshots 2026-07-13 22:01:13 +01:00
Josh Creek 94bc7085bf Merge pull request #20 from jcreek/feat/redesign
feat(*): Redesign UI to improve UX
2026-07-13 21:29:42 +01:00
Josh Creek 6517fcb373 feat(*): Redesign UI to improve UX 2026-07-13 21:20:04 +01:00
Josh Creek ee143e607a docs(*): Update README and screenshots for the new feature set 2026-07-12 21:18:46 +01:00
Josh Creek fe1e3c2abf feat(*): Collapse the icon picker once an icon is chosen 2026-07-12 21:18:23 +01:00
Josh Creek ec7598e15c feat(*): Make racks first-class with dropdown selection and shelf support 2026-07-12 21:07:27 +01:00
Josh Creek b4f5f12731 feat(*): Add rack elevation view with U positions 2026-07-12 20:24:16 +01:00
Josh Creek b4c7d1bce4 feat(*): Track cable type, colour and length on port connections 2026-07-12 20:22:45 +01:00
Josh Creek 2144eda352 feat(*): Colour nodes by VLAN with a clickable legend filter 2026-07-12 20:21:57 +01:00
Josh Creek acf63a9707 feat(*): Add IPAM panel with subnet utilization and IP suggestions 2026-07-12 20:20:19 +01:00
Josh Creek 336ee5e91b feat(*): Add machine notes and MAC address fields 2026-07-12 20:19:19 +01:00
Josh Creek 9cea45945c feat(*): Add node search with highlight and dim on the diagram 2026-07-12 20:18:18 +01:00
Josh Creek 72fd77b9c9 Merge pull request #19 from jcreek/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-07-12 15:54:09 +01:00
dependabot[bot] b04bc0acc9 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 13:33:35 +00:00
Josh Creek d04536d09d Merge pull request #16 from jcreek/dependabot/github_actions/docker/setup-buildx-action-4
build(deps): bump docker/setup-buildx-action from 3 to 4
2026-03-12 23:52:21 +00:00
Josh Creek ad3dccbdd4 Merge pull request #17 from jcreek/dependabot/github_actions/docker/metadata-action-6
build(deps): bump docker/metadata-action from 5 to 6
2026-03-12 23:52:06 +00:00
Josh Creek 5bed9cafb8 Merge pull request #18 from jcreek/dependabot/github_actions/docker/build-push-action-7
build(deps): bump docker/build-push-action from 6 to 7
2026-03-12 23:51:48 +00:00
dependabot[bot] ca26ea9063 build(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 13:37:56 +00:00
dependabot[bot] b1a3f08d5e build(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 13:37:48 +00:00
dependabot[bot] 9ffd8c4750 build(deps): bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 13:37:44 +00:00
Josh Creek a192bcab06 Merge pull request #10 from jcreek/dependabot/github_actions/actions/setup-node-6
build(deps): bump actions/setup-node from 4 to 6
2026-03-05 21:35:48 +00:00
Josh Creek b107d5d357 Merge pull request #11 from jcreek/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 4 to 6
2026-03-05 21:35:23 +00:00
Josh Creek e6487f7d25 Merge pull request #12 from jcreek/dependabot/github_actions/cycjimmy/semantic-release-action-6
build(deps): bump cycjimmy/semantic-release-action from 5 to 6
2026-03-05 21:34:39 +00:00
Josh Creek 53059caf31 Merge pull request #13 from jcreek/dependabot/github_actions/docker/login-action-4
build(deps): bump docker/login-action from 3 to 4
2026-03-05 21:34:16 +00:00
Josh Creek 808fe5a440 Merge pull request #15 from jcreek/14-read-only-mode
fix(#14): surface explicit writable diagnostics in API and read-only …
2026-03-05 19:00:30 +00:00
Josh Creek c2bffede3e fix(#14): surface explicit writable diagnostics in API and read-only banner 2026-03-05 18:51:04 +00:00
dependabot[bot] 2fd4291bb9 build(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 13:37:51 +00:00
Josh Creek a09c98ec46 build(*): Use correct token 2026-02-25 23:59:10 +00:00
dependabot[bot] bf0eac88f4 build(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:57:56 +00:00
Josh Creek 129041368b build(*): Attempt to diagnose dockerhub action issue 2026-02-25 23:56:47 +00:00
Josh Creek 3615c38794 docs(*): Change wording 2026-02-25 23:46:25 +00:00
Josh Creek 3ce0adfa0b Merge pull request #9 from jcreek/dependabot/github_actions/peter-evans/dockerhub-description-5
build(deps): bump peter-evans/dockerhub-description from 4 to 5
2026-02-25 23:44:15 +00:00
Josh Creek a7dbcf426f build(*): Ensure dockerhub updates overview not short description 2026-02-25 23:42:40 +00:00
dependabot[bot] e7dd300098 build(deps): bump cycjimmy/semantic-release-action from 5 to 6
Bumps [cycjimmy/semantic-release-action](https://github.com/cycjimmy/semantic-release-action) from 5 to 6.
- [Release notes](https://github.com/cycjimmy/semantic-release-action/releases)
- [Changelog](https://github.com/cycjimmy/semantic-release-action/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/cycjimmy/semantic-release-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: cycjimmy/semantic-release-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:39:32 +00:00
dependabot[bot] d2826f4cb3 build(deps): bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:39:25 +00:00
dependabot[bot] b1fb4fc141 build(deps): bump peter-evans/dockerhub-description from 4 to 5
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 4 to 5.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v4...v5)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:39:21 +00:00
Josh Creek 1d656ff120 build(*): Set up docker repository overview syncing from readme 2026-02-25 23:38:43 +00:00
Josh Creek 45203a4c28 feat(*): Enhance example network json 2026-02-25 23:27:16 +00:00
Josh Creek 4cbec53394 docs(*): Improve readme 2026-02-25 23:24:30 +00:00
Josh Creek 74680ca23c Merge pull request #8 from jcreek/5-add-ui-to-edit-config
5 add UI to edit config
2026-02-25 22:28:20 +00:00
Josh Creek 9836bddcf1 fix(#5): preserve original payload indexes for VM and port duplicate errors 2026-02-25 22:21:19 +00:00
Josh Creek 2ee356dfdf fix(#5): preserve original payload indexes in duplicate validation errors 2026-02-25 22:14:54 +00:00
Josh Creek 69a5dedf67 fix(#5): clean up temp file when network data rename fails 2026-02-25 22:05:42 +00:00
Josh Creek 7bfab68d94 fix(#5): avoid clobbering conflicting reciprocal port connections 2026-02-25 22:05:37 +00:00
Josh Creek 6d0b50a4bf fix(#5): guard API handler errors and cap PUT request body size 2026-02-25 22:05:22 +00:00
Josh Creek c4e790cddb fix(#5): include shared runtime modules in docker image 2026-02-25 21:56:24 +00:00
Josh Creek 5954f63f22 refactor(#5): share network validation and persistence runtime 2026-02-25 21:56:13 +00:00
Josh Creek b5d34456af fix(#5): ignore generated vendor manifest in prettier 2026-02-25 21:56:06 +00:00
Josh Creek 726d89946d fix(#5): re-enable unresolved import checks with TS resolver 2026-02-25 21:35:59 +00:00
Josh Creek d333d6af4a fix(#5): run container as non-root user 2026-02-25 21:15:21 +00:00
Josh Creek 494518c2d9 fix(#5): sync lockfile after adding @types/node 2026-02-25 21:06:17 +00:00
Josh Creek 5c3eed296a feat(#5): replace icon key inputs with searchable modal icon picker 2026-02-25 20:58:33 +00:00
Josh Creek b11026cfef feat(#5): add local vendored icon catalog manifest and attribution 2026-02-25 20:58:16 +00:00
Josh Creek 1ef5bae8ee feat(#5): Add icons 2026-02-25 20:21:00 +00:00
Josh Creek 151f942d9d chore(#5): switch to node runtime and update tooling/docs for persistence 2026-02-25 19:33:50 +00:00
Josh Creek a22846993f feat(#5): implement single-page modal editor with live diagram updates 2026-02-25 19:32:46 +00:00
Josh Creek 2f4a784646 feat(#5): add icon presets, dark mode theme store, and icon-aware graph rendering 2026-02-25 19:32:11 +00:00
Josh Creek b9c7d9eee0 feat(#5): add network editor utilities for CRUD and reciprocal links 2026-02-25 19:30:39 +00:00
Josh Creek 7957b15c29 feat(#5): add network-data API with atomic JSON writes and rolling backups 2026-02-25 19:30:20 +00:00
9351 changed files with 50486 additions and 1369 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+8 -4
View File
@@ -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
+12 -8
View File
@@ -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
+1
View File
@@ -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-*
+6
View File
@@ -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/
+16 -12
View File
@@ -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"]
+257 -120
View File
@@ -1,143 +1,221 @@
# **Open Network Diagram** # Open Network Diagram
![Docker Pulls](https://img.shields.io/docker/pulls/jcreek23/open-network-diagram) [![Docker Pulls](https://img.shields.io/docker/pulls/jcreek23/open-network-diagram)](https://hub.docker.com/r/jcreek23/open-network-diagram)
![Release Workflow](https://img.shields.io/github/actions/workflow/status/jcreek/OpenNetworkDiagram/release.yml?branch=main&label=release) [![Release Workflow](https://img.shields.io/github/actions/workflow/status/jcreek/OpenNetworkDiagram/release.yml?branch=main&label=release)](https://github.com/jcreek/OpenNetworkDiagram/actions/workflows/release.yml)
![Docker CI Workflow](https://img.shields.io/github/actions/workflow/status/jcreek/OpenNetworkDiagram/docker.yml?label=docker%20ci) [![Docker CI Workflow](https://img.shields.io/github/actions/workflow/status/jcreek/OpenNetworkDiagram/docker.yml?label=docker%20ci)](https://github.com/jcreek/OpenNetworkDiagram/actions/workflows/docker.yml)
![Latest Release](https://img.shields.io/github/v/release/jcreek/OpenNetworkDiagram?display_name=tag&sort=semver) [![Latest Release](https://img.shields.io/github/v/release/jcreek/OpenNetworkDiagram?display_name=tag&sort=semver)](https://github.com/jcreek/OpenNetworkDiagram/releases)
![Netlify](https://img.shields.io/netlify/3128f05f-831b-412c-ada0-46bc3d6e61d5) [![Netlify](https://img.shields.io/netlify/3128f05f-831b-412c-ada0-46bc3d6e61d5)](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** ![Open Network Diagram network view with ethernet labels](screenshot1.png)
**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 |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram Hosts & VMs view with VMs expanded](screenshot2.png) | ![Open Network Diagram modal editing a machine](screenshot3.png) |
| IPAM panel with subnet utilization | Rack view with shelf items | Dark mode |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) | ![Open Network Diagram dark mode](screenshot6.png) |
| Zero-setup first run | Read-only live demo |
| ------------------------------------------------------------------------ | ------------------------------------------------------------ |
| ![Open Network Diagram zero-setup first-run onboarding](screenshot7.png) | ![Open Network Diagram read-only live demo](screenshot8.png) |
## Docker Compose Option
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)
+21 -1
View File
@@ -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
}
] ]
} }
+6 -2
View File
@@ -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
+7 -10
View File
@@ -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'
}
]
} }
}, },
{ {
+10 -4
View File
@@ -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"
} }
} }
+436 -58
View File
@@ -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:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+197
View File
@@ -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;
});
+17
View File
@@ -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
+244
View File
@@ -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}`);
});
+97
View File
@@ -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);
}
+8
View File
@@ -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 {}
+20
View File
@@ -0,0 +1,20 @@
// Svelte action: invoke the callback when a pointer goes down outside the
// node. Listens in the capture phase so clicks swallowed by the Cytoscape
// canvas still dismiss open popovers.
export default function clickOutside(node: HTMLElement, onOutside: () => void) {
let callback = onOutside;
const handler = (event: PointerEvent) => {
if (!node.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('pointerdown', handler, true);
return {
update(next: () => void) {
callback = next;
},
destroy() {
document.removeEventListener('pointerdown', handler, true);
}
};
}
+815
View File
@@ -0,0 +1,815 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import clickOutside from '$lib/actions/clickOutside';
type DiagramViewMode = 'network' | 'device' | 'rack';
type SaveState = 'saved' | 'saving' | 'unsaved' | 'error';
export let dataSourceLabel = '';
export let saveState: SaveState = 'saved';
export let writable = false;
export let hasLoadedInitialData = false;
export let readOnlyNotice = '';
export let saveError: string | null = null;
export let isLoadingData = false;
export let viewMode: DiagramViewMode = 'network';
export let searchQuery = '';
export let searchCountLabel = '';
export let showEthernetLabels = false;
export let showCableSpeeds = true;
export let showVms = true;
export let hasAnyVmHosts = false;
export let ipamOpen = false;
export let theme: 'light' | 'dark' = 'light';
const dispatch = createEventDispatcher<{
viewchange: DiagramViewMode;
search: void;
searchcycle: { direction: 1 | -1 };
clearsearch: void;
addmachine: void;
adddevice: void;
toggleipam: void;
ethernetlabels: boolean;
cablespeeds: boolean;
showvms: boolean;
exportpng: void;
reload: void;
toggletheme: void;
}>();
let addMenuOpen = false;
let displayMenuOpen = false;
let searchInput: HTMLInputElement;
export function focusSearch() {
searchInput?.focus();
}
$: fileName = dataSourceLabel.split('/').pop() || dataSourceLabel;
$: status = (() => {
if (isLoadingData) {
return { text: 'Loading…', kind: 'muted', title: undefined as string | undefined };
}
if (hasLoadedInitialData && !writable) {
return { text: '⬦ Read-only', kind: 'warn', title: readOnlyNotice };
}
if (saveState === 'saving') {
return { text: 'Saving…', kind: 'muted', title: undefined };
}
if (saveState === 'error') {
return { text: '⚠ Save failed', kind: 'danger', title: saveError ?? undefined };
}
if (saveState === 'unsaved') {
return { text: 'Unsaved changes', kind: 'muted', title: undefined };
}
return { text: '✓ Saved', kind: 'ok', title: undefined };
})();
const views: Array<{ id: DiagramViewMode; label: string }> = [
{ id: 'network', label: 'Network' },
{ id: 'device', label: 'Hosts & VMs' },
{ id: 'rack', label: 'Rack' }
];
function selectView(mode: DiagramViewMode) {
if (mode !== viewMode) {
dispatch('viewchange', mode);
}
}
function onViewSelectChange(event: Event) {
selectView((event.currentTarget as HTMLSelectElement).value as DiagramViewMode);
}
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
dispatch('searchcycle', { direction: event.shiftKey ? -1 : 1 });
} else if (event.key === 'Escape') {
event.preventDefault();
dispatch('clearsearch');
searchInput?.blur();
}
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && (addMenuOpen || displayMenuOpen)) {
addMenuOpen = false;
displayMenuOpen = false;
}
}
</script>
<svelte:window on:keydown={onWindowKeydown} />
<header class="app-bar">
<div class="identity" title={dataSourceLabel}>
<div class="glyph" aria-hidden="true"></div>
<div class="identity-text">
<span class="file-name">{fileName}</span>
<span class="status status-{status.kind}" title={status.title}>{status.text}</span>
</div>
</div>
<div class="views">
<div class="seg" role="radiogroup" aria-label="Diagram view">
{#each views as view (view.id)}
<label class="seg-option" class:checked={viewMode === view.id}>
<input
type="radio"
name="diagram-view"
value={view.id}
checked={viewMode === view.id}
on:change={() => selectView(view.id)}
/>
{view.label}
</label>
{/each}
</div>
<select
class="seg-select"
value={viewMode}
aria-label="Select diagram view"
on:change={onViewSelectChange}
>
{#each views as view (view.id)}
<option value={view.id}>{view.label}</option>
{/each}
</select>
</div>
<div class="actions">
<div class="search" class:has-query={searchQuery.trim()}>
<span class="search-glyph" aria-hidden="true"></span>
<input
type="text"
placeholder="Find name, IP, VM…"
aria-label="Search diagram nodes"
bind:this={searchInput}
bind:value={searchQuery}
on:input={() => dispatch('search')}
on:keydown={onSearchKeydown}
/>
{#if searchQuery.trim()}
<span class="search-count">{searchCountLabel}</span>
<button
type="button"
class="search-clear"
aria-label="Clear search"
title="Clear search"
on:click={() => dispatch('clearsearch')}
>
×
</button>
{:else}
<kbd class="search-kbd" aria-hidden="true">/</kbd>
{/if}
</div>
<div class="menu-anchor" use:clickOutside={() => (addMenuOpen = false)}>
<button
type="button"
class="btn-primary"
disabled={!writable}
title={!writable ? readOnlyNotice : undefined}
aria-haspopup="menu"
aria-expanded={addMenuOpen}
on:click={() => (addMenuOpen = !addMenuOpen)}
>
+ <span class="btn-text">Add</span> <span class="caret" aria-hidden="true"></span>
</button>
{#if addMenuOpen}
<div class="menu" role="menu">
<button
type="button"
role="menuitem"
on:click={() => {
addMenuOpen = false;
dispatch('addmachine');
}}
>
Machine
</button>
<button
type="button"
role="menuitem"
on:click={() => {
addMenuOpen = false;
dispatch('adddevice');
}}
>
Device
</button>
</div>
{/if}
</div>
<button
type="button"
class="btn-secondary"
class:active={ipamOpen}
on:click={() => dispatch('toggleipam')}
>
IPAM
</button>
<div class="divider" aria-hidden="true"></div>
<div class="menu-anchor" use:clickOutside={() => (displayMenuOpen = false)}>
<button
type="button"
class="btn-quiet"
aria-haspopup="menu"
aria-expanded={displayMenuOpen}
on:click={() => (displayMenuOpen = !displayMenuOpen)}
>
<span class="btn-text">Display</span><span class="btn-text-narrow" aria-hidden="true"
></span
>
<span class="caret" aria-hidden="true"></span>
</button>
{#if displayMenuOpen}
<div class="menu display-menu">
<div class="menu-title">Display</div>
<label
class="menu-toggle"
class:disabled={viewMode !== 'network'}
title={viewMode !== 'network'
? 'Ethernet labels are available in Network view.'
: undefined}
>
<span>Ethernet labels</span>
<input
class="toggle-input"
type="checkbox"
checked={showEthernetLabels}
disabled={viewMode !== 'network'}
on:change={(event) => dispatch('ethernetlabels', event.currentTarget.checked)}
/>
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label>
<label
class="menu-toggle"
class:disabled={viewMode !== 'network' || !showEthernetLabels}
title={viewMode !== 'network' || !showEthernetLabels
? 'Cable speeds show inside ethernet labels.'
: undefined}
>
<span>Cable speeds</span>
<input
class="toggle-input"
type="checkbox"
checked={showCableSpeeds}
disabled={viewMode !== 'network' || !showEthernetLabels}
on:change={(event) => dispatch('cablespeeds', event.currentTarget.checked)}
/>
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label>
<label class="menu-toggle" class:disabled={!hasAnyVmHosts}>
<span>Show VMs</span>
<input
class="toggle-input"
type="checkbox"
checked={showVms}
disabled={!hasAnyVmHosts}
on:change={(event) => dispatch('showvms', event.currentTarget.checked)}
/>
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
</label>
<div class="menu-rule" aria-hidden="true"></div>
<button
type="button"
role="menuitem"
disabled={viewMode === 'rack'}
title={viewMode === 'rack' ? 'PNG export is available in the graph views.' : undefined}
on:click={() => {
displayMenuOpen = false;
dispatch('exportpng');
}}
>
Export PNG…
</button>
<button
type="button"
role="menuitem"
disabled={isLoadingData}
on:click={() => {
displayMenuOpen = false;
dispatch('reload');
}}
>
Reload from disk
</button>
</div>
{/if}
</div>
<button
type="button"
class="btn-icon"
aria-label="Toggle dark mode"
title="Toggle dark mode"
on:click={() => dispatch('toggletheme')}
>
{theme === 'dark' ? '☀' : '☾'}
</button>
</div>
</header>
<style>
.app-bar {
display: flex;
align-items: center;
gap: 16px;
height: 48px;
padding: 0 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Left: identity + status */
.identity {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.glyph {
width: 24px;
height: 24px;
border-radius: var(--radius-control);
background: var(--accent);
color: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.identity-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.file-name {
font-size: 13px;
font-weight: 650;
line-height: 1;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: 10.5px;
line-height: 1;
white-space: nowrap;
}
.status-ok {
color: var(--status-ok);
}
.status-warn {
color: var(--status-warn);
}
.status-danger {
color: var(--danger);
}
.status-muted {
color: var(--text-2);
}
/* Centre: segmented view control */
.views {
margin: 0 auto 0 24px;
}
.seg {
display: flex;
background: var(--surface-2);
border-radius: 7px;
padding: 3px;
gap: 2px;
}
.seg-option {
padding: 5px 14px;
border-radius: 5px;
font-size: 13px;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.seg-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.seg-option.checked {
background: var(--surface);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
font-weight: 600;
color: var(--text);
}
.seg-option:focus-within {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.seg-select {
display: none;
height: var(--control-h);
padding: 0 8px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
/* Right cluster */
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.search {
display: flex;
align-items: center;
gap: 7px;
height: var(--control-h);
width: 220px;
padding: 0 10px;
background: var(--surface-2);
border: 1px solid transparent;
border-radius: var(--radius-control);
box-sizing: border-box;
}
.search:focus-within {
border-color: var(--accent);
background: var(--surface);
}
.search-glyph {
color: var(--text-2);
font-size: 13px;
flex-shrink: 0;
}
.search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
}
.search input::placeholder {
color: var(--text-2);
opacity: 0.75;
}
.search-kbd {
font-size: 10px;
color: var(--text-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
background: var(--surface);
font-family: var(--font-mono);
}
.search-count {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
}
.search-clear {
border: none;
background: transparent;
color: var(--text-2);
font-size: 14px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.search-clear:hover {
color: var(--text);
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-quiet,
.btn-icon {
height: var(--control-h);
border-radius: var(--radius-control);
font-size: 13px;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.btn-primary {
padding: 0 13px;
background: var(--accent);
color: var(--surface);
border: none;
font-weight: 600;
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-secondary {
padding: 0 12px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
font-weight: 500;
}
.btn-secondary.active {
border-color: var(--accent);
color: var(--accent);
}
.btn-quiet {
padding: 0 12px;
background: transparent;
color: var(--text-2);
border: none;
font-weight: 500;
}
.btn-quiet:hover,
.btn-icon:hover {
background: var(--surface-2);
}
.btn-icon {
width: var(--control-h);
justify-content: center;
background: transparent;
color: var(--text-2);
border: none;
font-size: 15px;
}
.caret {
font-size: 9px;
opacity: 0.8;
}
.btn-text-narrow {
display: none;
}
.divider {
width: 1px;
height: 20px;
background: var(--border);
}
/* Menus */
.menu-anchor {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 170px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 6px;
display: flex;
flex-direction: column;
z-index: 40;
}
.display-menu {
width: 250px;
}
.menu-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-2);
padding: 6px 10px 4px;
}
.menu > button {
display: flex;
align-items: center;
padding: 7px 10px;
border: none;
background: transparent;
color: var(--text);
font-size: 13px;
font-family: inherit;
text-align: left;
border-radius: var(--radius-control);
cursor: pointer;
}
.menu > button:hover:not(:disabled) {
background: var(--surface-2);
}
.menu > button:disabled {
color: var(--text-2);
opacity: 0.6;
cursor: not-allowed;
}
.menu-rule {
height: 1px;
background: var(--border);
margin: 6px 4px;
}
.menu-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 10px;
border-radius: var(--radius-control);
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.menu-toggle.disabled {
color: var(--text-2);
opacity: 0.6;
cursor: not-allowed;
}
.toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle-track {
display: inline-flex;
width: 32px;
height: 19px;
background: color-mix(in oklab, var(--text-2) 40%, var(--surface-2));
border-radius: 999px;
position: relative;
transition: background 120ms ease;
flex-shrink: 0;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 15px;
height: 15px;
background: var(--surface);
border-radius: 50%;
transition: transform 120ms ease;
}
.toggle-input:checked + .toggle-track {
background: var(--accent);
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(13px);
}
.toggle-input:focus-visible + .toggle-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Responsive */
@media (max-width: 800px) {
.app-bar {
gap: 10px;
padding: 0 10px;
}
.views {
margin-left: 10px;
}
.seg {
display: none;
}
.seg-select {
display: block;
}
.search {
width: 36px;
padding: 0 9px;
transition: width 140ms ease;
}
.search:focus-within,
.search.has-query {
width: 180px;
}
.search-kbd {
display: none;
}
.btn-text {
display: none;
}
.btn-text-narrow {
display: inline;
}
.identity-text {
display: none;
}
}
/* Phone widths: shed the divider and tighten spacing so the single row
never overflows the viewport. */
@media (max-width: 480px) {
.app-bar {
gap: 6px;
padding: 0 8px;
}
.views {
margin-left: 2px;
min-width: 0;
}
.seg-select {
max-width: 108px;
}
.actions {
gap: 4px;
}
.divider {
display: none;
}
.btn-primary,
.btn-secondary,
.btn-quiet {
padding: 0 9px;
}
.caret {
display: none;
}
.search:focus-within,
.search.has-query {
width: 140px;
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let isSampleData = false;
export let writable = false;
export let hasMachines = false;
export let storageReady = false;
const dispatch = createEventDispatcher<{
addmachine: void;
connectport: void;
openipam: void;
dismiss: void;
}>();
</script>
<div class="first-run">
<h2>Map your network</h2>
{#if storageReady}
<p class="storage-note">
Your network data file is ready. Add your first machine to start mapping.
</p>
{:else if isSampleData}
<p class="sample-note">This is sample data — replace it with your own machines.</p>
{/if}
<div class="actions">
{#if writable}
<button type="button" on:click={() => dispatch('addmachine')}>
<span class="action-glyph" aria-hidden="true">+</span>
<span class="action-text">
<strong>Add a machine</strong>
<span>Servers, NAS boxes, mini PCs — anything with an IP.</span>
</span>
</button>
{/if}
<button type="button" disabled={!hasMachines} on:click={() => dispatch('connectport')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Connect a port</strong>
<span>
{hasMachines
? 'Open a machine and add a port to draw your first cable.'
: 'Add a machine first, then wire up its ports.'}
</span>
</span>
</button>
<button type="button" on:click={() => dispatch('openipam')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Open IPAM</strong>
<span>See subnet utilisation and grab the next free IP.</span>
</span>
</button>
</div>
<button type="button" class="dismiss" on:click={() => dispatch('dismiss')}>Dismiss</button>
</div>
<style>
.first-run {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
width: min(92vw, 440px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.sample-note {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--status-warn);
}
.storage-note {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--text-2);
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions > button {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.actions > button:hover:not(:disabled) {
border-color: var(--accent);
}
.actions > button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.action-glyph {
width: 28px;
height: 28px;
border-radius: var(--radius-control);
background: var(--surface-2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
flex-shrink: 0;
}
.action-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.action-text strong {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.action-text span {
font-size: 12px;
color: var(--text-2);
line-height: 1.45;
}
.dismiss {
align-self: flex-end;
border: none;
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-control);
}
.dismiss:hover {
background: var(--surface-2);
color: var(--text);
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { listIconDefinitions } from '$lib/config/iconRegistry';
import clickOutside from '$lib/actions/clickOutside';
// Anchored popover for choosing an icon. The parent positions it (the
// wrapper just needs position: relative); selection is reported via the
// `select` event and the popover closes itself on outside click/Escape.
export let currentIconKey: string | undefined = undefined;
const dispatch = createEventDispatcher<{ select: string; close: void }>();
const iconDefinitions = listIconDefinitions();
const iconResultLimit = 100;
let iconSearch = '';
function focusInput(node: HTMLInputElement) {
node.focus();
}
function normalize(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
$: searchTokens = normalize(iconSearch).split(' ').filter(Boolean);
$: filteredIcons = iconDefinitions.filter((icon) => {
if (searchTokens.length === 0) {
return true;
}
const searchable = normalize(`${icon.label} ${icon.key}`);
return searchTokens.every((token) => searchable.includes(token));
});
$: visibleIcons = filteredIcons.slice(0, iconResultLimit);
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
dispatch('close');
}
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="icon-popover" use:clickOutside={() => dispatch('close')} on:keydown={onKeydown}>
<input
type="text"
placeholder="Search icons…"
aria-label="Search icons"
bind:value={iconSearch}
use:focusInput
/>
<div class="results-meta">
Showing {visibleIcons.length} of {filteredIcons.length} icons
</div>
{#if visibleIcons.length > 0}
<div class="results-grid" role="listbox" aria-label="Icon results">
{#each visibleIcons as icon (icon.key)}
<button
type="button"
role="option"
aria-selected={icon.key === currentIconKey}
class:selected={icon.key === currentIconKey}
title={icon.label}
on:click={() => dispatch('select', icon.key)}
>
<img src={icon.path} alt="" loading="lazy" />
<span>{icon.label}</span>
</button>
{/each}
</div>
{:else}
<div class="no-results">No icons match your search.</div>
{/if}
{#if currentIconKey}
<div class="popover-footer">
<button type="button" class="clear-icon" on:click={() => dispatch('select', '')}>
Remove icon
</button>
</div>
{/if}
</div>
<style>
.icon-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 30;
width: min(420px, 80vw);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
input {
height: var(--control-h);
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--text);
font-size: 13.5px;
font-family: inherit;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: var(--accent);
}
.results-meta {
font-size: 11px;
color: var(--text-2);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 6px;
max-height: 260px;
overflow: auto;
}
.results-grid button {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 8px 4px;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
cursor: pointer;
font-family: inherit;
}
.results-grid button:hover {
background: var(--surface-2);
}
.results-grid button.selected {
border-color: var(--accent);
}
.results-grid img {
width: 28px;
height: 28px;
object-fit: contain;
}
.results-grid span {
font-size: 10.5px;
color: var(--text-2);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-results {
font-size: 12.5px;
color: var(--text-2);
padding: 12px 4px;
}
.popover-footer {
border-top: 1px solid var(--surface-2);
padding-top: 8px;
}
.clear-icon {
border: none;
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-control);
}
.clear-icon:hover {
background: var(--surface-2);
color: var(--danger);
}
</style>
+48 -24
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type cytoscape from 'cytoscape';
import { vlanPaletteColor } from '$lib/graph/vlanPalette';
import type { GraphNodeElement } from '$lib/graph/types';
// HTML card overlay for graph nodes. Cytoscape keeps invisible hit-target
// nodes (interaction, layout, edge anchoring); this layer draws the actual
// cards at the same model coordinates and mirrors pan/zoom with a single
// container transform. pointer-events stay off so Cytoscape owns input.
export let cy: cytoscape.Core | null = null;
export let nodes: GraphNodeElement[] = [];
export let dimmedIds: ReadonlySet<string> = new Set();
export let matchedIds: ReadonlySet<string> = new Set();
export let activeId: string | null = null;
let viewport = { panX: 0, panY: 0, zoom: 1 };
let positionById: Record<string, { x: number; y: number }> = {};
let attachedCy: cytoscape.Core | null = null;
let rafId: number | null = null;
function syncFromCy() {
rafId = null;
if (!attachedCy || attachedCy.destroyed()) {
return;
}
const pan = attachedCy.pan();
viewport = { panX: pan.x, panY: pan.y, zoom: attachedCy.zoom() };
const next: Record<string, { x: number; y: number }> = {};
for (const node of attachedCy.nodes().toArray()) {
const position = node.position();
next[node.id()] = { x: position.x, y: position.y };
}
positionById = next;
}
function scheduleSync() {
if (rafId === null) {
rafId = requestAnimationFrame(syncFromCy);
}
}
function attach(core: cytoscape.Core | null) {
if (core === attachedCy) {
return;
}
if (attachedCy && !attachedCy.destroyed()) {
attachedCy.off('render', scheduleSync);
}
attachedCy = core;
if (attachedCy) {
// 'render' fires on pan, zoom, layout, add/remove and position
// changes, so one rAF-throttled listener keeps cards in lockstep.
attachedCy.on('render', scheduleSync);
scheduleSync();
}
}
$: attach(cy);
// Re-read positions whenever the visible node set changes (refreshGraph
// re-adds elements and re-runs layout synchronously).
$: if (nodes && attachedCy) {
scheduleSync();
}
onDestroy(() => {
attach(null);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
});
function fallbackGlyph(kind: string, deviceClass: string | undefined): string {
if (kind === 'device') {
return deviceClass === 'infrastructure' ? '⇄' : '○';
}
if (kind === 'vm') {
return '◫';
}
return '🖥';
}
// position is passed in from the template (rather than read from
// positionById here) so Svelte's legacy-mode invalidation sees the
// dependency and re-renders cards when positions change.
function cardStyle(
node: GraphNodeElement,
position: { x: number; y: number } | undefined
): string {
const { data } = node;
if (!position) {
return 'display: none;';
}
const parts = [
`left: ${position.x}px`,
`top: ${position.y}px`,
`width: ${data.nodeWidth}px`,
`height: ${data.nodeHeight}px`
];
if (typeof data.vlanIndex === 'number') {
parts.push(`--card-vlan: ${vlanPaletteColor(data.vlanIndex)}`);
}
return `${parts.join('; ')};`;
}
</script>
<div class="card-layer" aria-hidden="true">
<div
class="card-plane"
style={`transform: translate(${viewport.panX}px, ${viewport.panY}px) scale(${viewport.zoom});`}
>
{#each nodes as node (node.data.id)}
{@const data = node.data}
{@const isDumb = data.kind === 'device' && data.deviceClass !== 'infrastructure'}
<div
class="node-card kind-{data.kind}"
class:infra={data.kind === 'device' && data.deviceClass === 'infrastructure'}
class:dumb={isDumb}
class:has-vlan={typeof data.vlanIndex === 'number'}
class:dimmed={dimmedIds.has(data.id)}
class:match={matchedIds.has(data.id)}
class:active={activeId === data.id}
style={cardStyle(node, positionById[node.data.id])}
>
<div class="icon-tile" class:round={isDumb}>
{#if data.iconUrl}
<img src={data.iconUrl} alt="" loading="lazy" />
{:else}
<span class="icon-fallback">{fallbackGlyph(data.kind, data.deviceClass)}</span>
{/if}
</div>
<div class="card-text">
<span class="card-name">{data.rawName ?? data.label}</span>
{#if !isDumb && (data.meta || data.vmCount)}
<span class="card-meta">
{#if data.meta}{data.meta}{/if}{#if data.kind === 'machine' && (data.vmCount ?? 0) > 0}{#if data.meta}&nbsp;·
{/if}<span class="vm-count">{data.vmCount} {data.vmCount === 1 ? 'VM' : 'VMs'}</span
>{/if}
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
<style>
.card-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 5;
}
.card-plane {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.node-card {
position: absolute;
transform: translate(-50%, -50%);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px 0 10px;
background: var(--surface);
border: 1px solid var(--graph-node-border);
border-radius: 9px;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.07);
transition: opacity 120ms ease;
}
.node-card.has-vlan {
border-left: 4px solid var(--card-vlan);
padding-left: 7px;
}
.node-card.kind-vm {
background: var(--surface-2);
gap: 8px;
border-radius: 8px;
}
.node-card.infra {
background: var(--graph-infra-fill);
border-color: var(--graph-infra-border);
}
.node-card.dumb {
border: 1px dashed var(--graph-dumb-border);
border-radius: 999px;
padding: 0 14px 0 8px;
gap: 8px;
}
.node-card.dumb.has-vlan {
border-left: 4px solid var(--card-vlan);
}
.node-card.dimmed {
opacity: 0.15;
}
.node-card.match {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.node-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
}
.icon-tile {
width: 34px;
height: 34px;
border-radius: 7px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.icon-tile.round {
width: 24px;
height: 24px;
border-radius: 50%;
}
.kind-vm .icon-tile {
width: 26px;
height: 26px;
background: var(--surface);
}
.infra .icon-tile {
background: rgb(255 255 255 / 0.12);
}
:global([data-theme='dark']) .infra .icon-tile {
background: rgb(255 255 255 / 0.09);
}
.icon-tile img {
width: 76%;
height: 76%;
object-fit: contain;
}
.icon-fallback {
font-size: 15px;
color: var(--text-2);
}
.infra .icon-fallback {
color: #e7ebf2;
}
.card-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.card-name {
font-size: 13px;
font-weight: 650;
line-height: 1.15;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.infra .card-name {
color: #ffffff;
}
:global([data-theme='dark']) .infra .card-name {
color: var(--text);
}
.dumb .card-name {
font-size: 12.5px;
font-weight: 550;
color: var(--text);
}
.kind-vm .card-name {
font-size: 12px;
}
.card-meta {
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.infra .card-meta {
color: #b6bfcc;
}
.kind-vm .card-meta {
font-size: 10px;
}
.vm-count {
color: var(--accent);
font-weight: 600;
}
</style>
+304
View File
@@ -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>
+32
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+240
View File
@@ -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;
}
+471
View File
@@ -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;
}
+24
View File
@@ -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;
}
+258
View File
@@ -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));
}
+37
View File
@@ -0,0 +1,37 @@
// Reads the graph-facing design tokens off the document root so the
// Cytoscape stylesheet (canvas-drawn edges) always matches the CSS theme.
// The --graph-* custom properties are plain hex on purpose: Cytoscape's
// colour parser doesn't understand oklch() or var().
export interface CanvasTokens {
bgCanvas: string;
edge: string;
border: string;
chipBg: string;
chipText: string;
accent: string;
}
const fallbacks: CanvasTokens = {
bgCanvas: '#eef0f3',
edge: '#8b94a3',
border: '#dce0e6',
chipBg: '#ffffff',
chipText: '#3a4250',
accent: '#3565c4'
};
export function readCanvasTokens(): CanvasTokens {
if (typeof document === 'undefined') {
return fallbacks;
}
const styles = getComputedStyle(document.documentElement);
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
return {
bgCanvas: read('--bg-canvas', fallbacks.bgCanvas),
edge: read('--graph-edge', fallbacks.edge),
border: read('--border', fallbacks.border),
chipBg: read('--graph-edge-chip-bg', fallbacks.chipBg),
chipText: read('--graph-edge-chip-text', fallbacks.chipText),
accent: read('--accent', fallbacks.accent)
};
}
+91
View File
@@ -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;
}
+151 -32
View File
@@ -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 } : {})
} }
}); });
} }
+55
View File
@@ -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 {
+49
View File
@@ -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];
}
+49
View File
@@ -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();
}
+418
View File
@@ -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;
}
+691
View File
@@ -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;
}
+55
View File
@@ -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();
+34
View File
@@ -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[];
} }
+141
View File
@@ -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 });
}
};
+283 -123
View File
@@ -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
} }
] ]
} }
+3
View File
@@ -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

+5
View File
@@ -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

+7
View File
@@ -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

+6
View File
@@ -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

+6
View File
@@ -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

+11
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More