61 Commits

Author SHA1 Message Date
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
Josh Creek 29a6c417e3 Merge pull request #7 from jcreek/build/releases
build(*): Improve build automation
2026-02-25 18:05:17 +00:00
Josh Creek b02cf604a3 fix(*): Address issues identified in PR comments 2026-02-25 18:01:04 +00:00
Josh Creek c09089b777 build(*): Improve build automation 2026-02-25 17:51:15 +00:00
9346 changed files with 50102 additions and 1379 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+8 -22
View File
@@ -1,14 +1,9 @@
name: Docker name: Docker CI
on: on:
pull_request: pull_request:
branches: branches:
- main - main
push:
branches:
- main
tags:
- "v*.*.*"
env: env:
IMAGE_NAME: jcreek23/open-network-diagram IMAGE_NAME: jcreek23/open-network-diagram
@@ -21,35 +16,26 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
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: |
type=raw,value=latest,enable={{is_default_branch}} type=ref,event=pr
type=sha,prefix=sha- type=sha,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build (PR) / Build+Push (push) - 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: ${{ github.event_name == 'push' }} push: false
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
+91
View File
@@ -0,0 +1,91 @@
name: Release
on:
push:
branches:
- main
env:
IMAGE_NAME: jcreek23/open-network-diagram
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
outputs:
new_release_published: ${{ steps.semantic.outputs.new_release_published }}
new_release_version: ${{ steps.semantic.outputs.new_release_version }}
new_release_major_version: ${{ steps.semantic.outputs.new_release_major_version }}
new_release_minor_version: ${{ steps.semantic.outputs.new_release_minor_version }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Create Git tag + GitHub release
id: semantic
uses: cycjimmy/semantic-release-action@v6
with:
extra_plugins: |
@semantic-release/commit-analyzer
conventional-changelog-conventionalcommits
@semantic-release/release-notes-generator
@semantic-release/github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
needs: release
if: needs.release.outputs.new_release_published == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: docker.io/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ needs.release.outputs.new_release_version }}
type=raw,value=${{ needs.release.outputs.new_release_major_version }}.${{ needs.release.outputs.new_release_minor_version }}
type=sha,prefix=sha-
- name: Build and push release image
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+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/
+81
View File
@@ -0,0 +1,81 @@
{
"branches": ["main"],
"tagFormat": "v${version}",
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{
"type": "build",
"release": "patch"
},
{
"type": "chore",
"release": "patch"
},
{
"type": "ci",
"release": "patch"
},
{
"type": "docs",
"release": "patch"
},
{
"type": "refactor",
"release": "patch"
},
{
"type": "style",
"release": "patch"
},
{
"type": "test",
"release": "patch"
}
]
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{
"type": "build",
"section": "Build"
},
{
"type": "chore",
"section": "Chores"
},
{
"type": "ci",
"section": "CI"
},
{
"type": "docs",
"section": "Docs"
},
{
"type": "refactor",
"section": "Refactors"
},
{
"type": "style",
"section": "Style"
},
{
"type": "test",
"section": "Tests"
}
]
}
}
],
"@semantic-release/github"
]
}
+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"]
+182 -119
View File
@@ -1,143 +1,158 @@
# **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)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/jcreek/OpenNetworkDiagram/docker.yml) [![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)
![Netlify](https://img.shields.io/netlify/3128f05f-831b-412c-ada0-46bc3d6e61d5) [![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)](https://github.com/jcreek/OpenNetworkDiagram/releases)
[![Netlify](https://img.shields.io/netlify/3128f05f-831b-412c-ada0-46bc3d6e61d5)](https://opennetworkdiagram.jcreek.co.uk)
**A declarative, self-hosted containerised tool for visualising and managing home lab & network architecture diagrams.**
Open Network Diagram helps you document your infrastructure in a visual UI while keeping a real JSON source of truth you can version, back up, and reuse.
**A declarative, self-hosted tool for visualising and managing home lab & network architecture diagrams.** - 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.
--- [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)
## **📝 About** ![Open Network Diagram network view with ethernet labels](screenshot1.png)
**Open Network Diagram** is an **open-source, self-hosted tool** for creating **interactive network and infrastructure diagrams** using a **declarative JSON format**. ## Features
**Fully self-hostable via Docker** - Network view with ethernet labels to make physical and logical links easy to read.
**Docker-first deployment target** (Netlify optional for demo hosting) - Non-network view for host-first inventory and service mapping.
**Interactive network visualisation** - Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
**Simple file-based configuration** - Node search that highlights matches by name, IP, role or OS and dims everything else.
**Lightweight Svelte** - 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.
Use it to **document your home lab, office network, or cloud infrastructure** with an easy-to-use web interface. ## Why Home Lab Users Use It
--- - 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.
## **🚀 Quick Start (For Users)** ## 2-Minute Docker Quick Start
### **1️⃣ Create Your Runtime Data File** This is the fastest way to run Open Network Diagram for a home lab.
Copy the template and edit your own network data: 1. Create a local data folder and seed your first `network.json`:
```bash ```bash
cp data/network.json.example data/network.json mkdir -p ond-data
curl -fsSL https://raw.githubusercontent.com/jcreek/OpenNetworkDiagram/main/data/network.json.example -o ond-data/network.json
``` ```
### **2️⃣ Run Open Network Diagram via Docker** 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 \
-e NETWORK_DATA_FILE=/app/data/network.json \
-e NETWORK_BACKUP_DIR=/app/data/.backups \
-v "$(pwd)/ond-data:/app/data" \
jcreek23/open-network-diagram:latest
``` ```
Or pull and run directly: 3. Open the app at `http://localhost:8080`.
4. Edit your topology in the UI. Changes persist to `ond-data/network.json`.
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` ## What It Looks Like
- **`-v .../data:/usr/share/nginx/html/data:ro`** → Uses your local `data/network.json`
### **3️⃣ Open the Web UI** | Network view with ethernet labels | Non-network view with VMs expanded | Modal editing a machine |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram non-network view with VMs expanded](screenshot2.png) | ![Open Network Diagram modal editing a machine](screenshot3.png) |
Visit **`http://localhost:8080`** to view your network diagram. | IPAM panel with subnet utilization | Rack view with shelf items |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) |
### **4️⃣ Modify Your Network (JSON-Based)** ## Docker Compose Option
- Docker runtime reads **`/data/network.json`** from the mounted volume. If you prefer compose:
- 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.
--- ```yaml
services:
open-network-diagram:
image: jcreek23/open-network-diagram:latest
ports:
- '8080:3000'
volumes:
- ./ond-data:/app/data
environment:
NETWORK_DATA_FILE: /app/data/network.json
NETWORK_BACKUP_DIR: /app/data/.backups
restart: unless-stopped
```
## **👩‍💻 Development Setup** Start it with:
### **1️⃣ Clone the Repository** ```bash
docker compose up -d
```
## 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`
- 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 (Docker image build/push)
└── 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.
- Pushes to `main` publish 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 +162,99 @@ 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 }
} }
} }
], ],
"devices": [{ "name": "Switch", "ipAddress": "10.0.0.2", "type": "Nintendo Switch" }] "rack": { "name": "Lab Rack", "unit": 10 }
}
],
"devices": [
{
"name": "Switch",
"ipAddress": "10.0.0.2",
"type": "Network Switch",
"ports": [
{
"portName": "1",
"speedGbps": 1,
"connectedTo": {
"device": "ProxRouter",
"port": "eth0",
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
}
}
],
"rack": { "name": "Lab Rack", "unit": 12 }
}
],
"subnets": [{ "cidr": "10.0.0.0/24", "name": "LAN", "vlanId": 1 }],
"racks": [{ "name": "Lab Rack", "heightU": 12 }]
} }
``` ```
--- Notes on the optional fields:
## **🔜 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 # Starter data template for Docker users
├── third_party/ # Third-party provenance + licensing
├── Dockerfile # Docker build/runtime image
├── server.mjs # Node runtime server (static + API)
├── docker-compose.yml # Local compose example (build from repo)
├── netlify.toml # Netlify build config
└── .github/workflows/ # CI workflows
```
## **🤝 Contributing** ### CI/CD
We welcome contributions! To contribute: - `docker.yml`: validates Docker build on pull requests.
- `release.yml`: semantic release on `main` and Docker Hub publish for tagged releases.
- Docker Hub image: [`jcreek23/open-network-diagram`](https://hub.docker.com/r/jcreek23/open-network-diagram)
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. Commit your changes.
4. Push your branch.
5. Open a pull request.
## **📜 License** ## License
[GNU GPL v3 License](LICENSE) Free to use, modify, and distribute, except distributing closed source versions. [GNU GPL v3](LICENSE)
---
## **📬 Contact**
**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
}
] ]
} }
+5 -2
View File
@@ -5,7 +5,10 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: open-network-diagram:local image: open-network-diagram:local
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'
}
]
} }
}, },
{ {
+9 -4
View File
@@ -7,11 +7,13 @@
"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 DEPLOY_TARGET=netlify vite build",
"start": "node server.mjs",
"preview": "vite preview", "preview": "vite preview",
"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 +29,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 +55,7 @@
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
"dependencies": { "dependencies": {
"cytoscape": "^3.31.2", "cytoscape": "^3.31.2",
"cytoscape-dagre": "^2.5.0" "cytoscape-dagre": "^2.5.0",
"html-to-image": "^1.11.13"
} }
} }
+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: 219 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 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
+233
View File
@@ -0,0 +1,233 @@
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,
readNetworkFile,
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([readNetworkFile(), getWritableState()]);
const validation = validateNetworkData(payload.data);
if (!validation.valid) {
sendJson(response, 500, {
error: 'Stored network data is invalid',
details: validation.errors
});
return;
}
sendJson(response, 200, {
data: validation.data,
writable: writableState.writable,
writableReason: writableState.reason,
source: payload.source,
updatedAt: payload.updatedAt
});
} catch (error) {
sendJson(response, 500, {
error: error instanceof Error ? error.message : 'Failed to read network data'
});
}
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: #2c3440;
--graph-infra-border: #414c5d;
--graph-dumb-border: #414c5d;
--graph-edge-chip-bg: #262d38;
--graph-edge-chip-text: #c3cad4;
--vlan-0: oklch(0.68 0.13 290);
--vlan-1: oklch(0.68 0.13 15);
--vlan-2: oklch(0.68 0.13 175);
--vlan-3: oklch(0.68 0.13 75);
--vlan-4: oklch(0.68 0.13 240);
--vlan-5: oklch(0.68 0.13 125);
--shadow-panel: 0 4px 16px rgb(0 0 0 / 0.35);
--shadow-popover: 0 8px 24px rgb(0 0 0 / 0.45);
--modal-overlay: rgb(8 10 14 / 0.6);
--status-ok: #5cb884;
--status-warn: #f0b429;
}
html,
body {
background: var(--app-bg);
color: var(--panel-contrast);
font-family: var(--font-sans);
}
+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>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let isSampleData = false;
export let writable = false;
export let hasMachines = false;
const dispatch = createEventDispatcher<{
addmachine: void;
connectport: void;
openipam: void;
dismiss: void;
}>();
</script>
<div class="first-run">
<h2>Map your network</h2>
{#if isSampleData}
<p class="sample-note">This is sample data — replace it with your own machines.</p>
{/if}
<div class="actions">
{#if writable}
<button type="button" on:click={() => dispatch('addmachine')}>
<span class="action-glyph" aria-hidden="true">+</span>
<span class="action-text">
<strong>Add a machine</strong>
<span>Servers, NAS boxes, mini PCs — anything with an IP.</span>
</span>
</button>
{/if}
<button type="button" disabled={!hasMachines} on:click={() => dispatch('connectport')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Connect a port</strong>
<span>
{hasMachines
? 'Open a machine and add a port to draw your first cable.'
: 'Add a machine first, then wire up its ports.'}
</span>
</span>
</button>
<button type="button" on:click={() => dispatch('openipam')}>
<span class="action-glyph" aria-hidden="true"></span>
<span class="action-text">
<strong>Open IPAM</strong>
<span>See subnet utilisation and grab the next free IP.</span>
</span>
</button>
</div>
<button type="button" class="dismiss" on:click={() => dispatch('dismiss')}>Dismiss</button>
</div>
<style>
.first-run {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
width: min(92vw, 440px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-popover);
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.sample-note {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--status-warn);
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions > button {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-panel);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.actions > button:hover:not(:disabled) {
border-color: var(--accent);
}
.actions > button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.action-glyph {
width: 28px;
height: 28px;
border-radius: var(--radius-control);
background: var(--surface-2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
flex-shrink: 0;
}
.action-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.action-text strong {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.action-text span {
font-size: 12px;
color: var(--text-2);
line-height: 1.45;
}
.dismiss {
align-self: flex-end;
border: none;
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-control);
}
.dismiss:hover {
background: var(--surface-2);
color: var(--text);
}
</style>
+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>
+45 -21
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">
<slot name="header">
{#if title} {#if title}
<h2>{title}</h2> <h2>{title}</h2>
{/if} {/if}
<button type="button" class="close-button" on:click={closeModal} aria-label="Close">×</button> </slot>
<button type="button" class="close-button" on:click={closeModal} aria-label="Close"
>×</button
>
</header> </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;
}
+141 -22
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,17 +179,24 @@ 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,
iconKey: machine.iconKey,
ip: machine.ipAddress, ip: machine.ipAddress,
role: machine.role, role: machine.role,
os: machine.operatingSystem, os: machine.operatingSystem,
cpu: machine.hardware?.cpu ?? 'Unknown', cpu: machine.hardware?.cpu ?? 'Unknown',
ram: machine.hardware?.ram ?? 'Unknown', ram: machine.hardware?.ram ?? 'Unknown',
gpu: machine.hardware?.gpu ?? undefined, gpu: machine.hardware?.gpu ?? undefined,
notes: machine.notes,
ports: machinePortsList, ports: machinePortsList,
vmCount: machineVms.length, vmCount: machineVms.length,
vms: machineVms vms: machineVms
@@ -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];
}
+39
View File
@@ -0,0 +1,39 @@
import {
checkWritableState as checkWritableStateCore,
getWritableState as getWritableStateCore,
isWriteEnabled as isWriteEnabledCore,
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 function isWriteEnabled(): boolean {
return isWriteEnabledCore();
}
export async function readNetworkFile(): Promise<{ data: NetworkData } & NetworkFileMetadata> {
return (await readNetworkFileCore()) as { data: NetworkData } & NetworkFileMetadata;
}
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();
}
+246
View File
@@ -0,0 +1,246 @@
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 {{ writable: boolean; reason: string | null }} WritableState
*/
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');
/**
* @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<NetworkFileReadResult>}
*/
export async function readNetworkFile() {
const source = resolveDataFilePath();
const raw = await readFile(source, 'utf8');
const parsed = JSON.parse(raw);
const updatedAt = await readUpdatedAt(source);
return {
data: parsed,
source,
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
* @returns {Promise<NetworkFileMetadata>}
*/
export async function writeNetworkFile(data) {
const source = resolveDataFilePath();
const backupDirectory = resolveBackupDirectory();
const directory = path.dirname(source);
await mkdir(directory, { recursive: true });
await createBackupIfPresent(source, backupDirectory);
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
const fileHandle = await open(temporaryPath, 'w');
try {
await fileHandle.writeFile(jsonPayload, 'utf8');
await fileHandle.sync();
} finally {
await fileHandle.close();
}
try {
await rename(temporaryPath, source);
} catch (renameError) {
await unlink(temporaryPath).catch(() => undefined);
throw renameError;
}
await trimBackups(backupDirectory, path.basename(source), 5);
const updatedAt = await readUpdatedAt(source);
return {
source,
updatedAt
};
}
/**
* @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;
}
+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[];
} }
+102
View File
@@ -0,0 +1,102 @@
import { json } from '@sveltejs/kit';
import { validateNetworkData } from '$lib/data/networkSchema';
import {
getWritableState,
readNetworkFile,
writeNetworkFile
} from '$lib/server/networkPersistence';
import type { RequestHandler } from './$types';
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}`;
}
export const GET: RequestHandler = async () => {
try {
const [{ data, source, updatedAt }, writableState] = await Promise.all([
readNetworkFile(),
getWritableState()
]);
const validation = validateNetworkData(data);
if (!validation.valid || !validation.data) {
return json(
{
error: 'Stored network data is invalid',
details: serializeValidationErrors(validation.errors)
},
{ status: 500 }
);
}
return json({
data: validation.data,
writable: writableState.writable,
writableReason: writableState.reason,
source,
updatedAt
});
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to read network data file';
return json({ error: message }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ request }) => {
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 });
}
};
+281 -121
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 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