Compare commits

...

34 Commits

Author SHA1 Message Date
Pouzor 701c9c5bb9 fix: force frontend builder stage to native platform, fixes QEMU arm64 npm crash 2026-03-28 14:23:21 +01:00
Pouzor d9f3477780 fix(ci): inject bcrypt hash via compose environment with $$ escaping, remove dynamic hash generation 2026-03-28 13:03:28 +01:00
Pouzor c9d6642b26 ci: pass DL3008 ignore directly to hadolint action 2026-03-28 12:58:03 +01:00
Pouzor c9e142dbf2 ci: suppress DL3008 hadolint warning (apt version pinning impractical for system tools) 2026-03-28 12:56:11 +01:00
Pouzor 57829d88e5 fix(install): restore nginx reload-or-start fallback broken by shellcheck fix 2026-03-28 12:53:50 +01:00
Pouzor fd8735ce7f fix(ci): quote CTID path, fix bcrypt hash shell expansion, npm audit fix 2026-03-28 12:49:50 +01:00
Pouzor 0bdf835a3d fix(ci): expose backend port in CI, fix shellcheck warnings in lxc-install.sh 2026-03-28 12:45:01 +01:00
Pouzor ae29d2c8f5 fix(tests): correct integration test API shapes and test isolation 2026-03-28 12:36:28 +01:00
Pouzor cc68fcf1c1 test: add deployment test tiers (shellcheck, hadolint, Docker smoke, integration)
Tier 1 — quality.yml: ShellCheck on lxc-install.sh, hadolint on both Dockerfiles
Tier 2 — docker-ci.yml: build images, smoke-test backend health + frontend 200
Tier 3 — test_integration.py: full stack pytest (auth, canvas save/reload, dimensions)
Also adds Docker healthcheck to backend service in docker-compose.yml
2026-03-28 12:33:35 +01:00
Pouzor 4cb164241a test: add standalone mode save/load and demo data validation tests 2026-03-28 12:26:48 +01:00
Pouzor b35b51d5b2 test: add component tests for LoginPage, modals, and CanvasContainer 2026-03-28 12:21:21 +01:00
Pouzor 565f4337c8 fix: extract canvas serializer and add full coverage including width/height 2026-03-28 12:05:54 +01:00
Pouzor 2a9cbc5932 feat: resizable nodes with width/height persistence
Add NodeResizer to BaseNode so users can drag corners to resize any node.
Persist width/height through the full stack: DB model, schemas, canvas
save/load route, and migration for existing databases.

Add tests covering save, update, clear, and load of node dimensions.
2026-03-28 11:57:09 +01:00
Pouzor 09b5317a0c fix: thread-safe signature loading, clear missing-file error, restrict CORS methods 2026-03-27 18:01:43 +01:00
Pouzor 0f643477f6 fix: move service_signatures.json inside app package and bump to 1.3.3
The file was in /app/data/ which gets overwritten by the Docker volume
mount (backend_data:/app/data), making it invisible at runtime and
causing scan failures on fresh installs. Moved to /app/app/data/ so
it stays baked into the image.
2026-03-27 17:54:39 +01:00
Pouzor 861d2822b9 fix: add iputils-ping to Docker image and bump version to 1.3.2
ping was missing from python:3.13-slim — ping check method always
returned offline on fresh Docker installs.
2026-03-27 14:59:58 +01:00
Remy 059bb3daa7 Update README.md 2026-03-27 12:28:12 +01:00
Pouzor c01d87381d add one more screenshot for the edit/show pannel 2026-03-27 12:27:17 +01:00
Pouzor ec0519d2b7 chore: bump version to 1.3.1 2026-03-27 11:26:26 +01:00
Pouzor 1182dbd82d Update first screenshot 2026-03-27 11:25:21 +01:00
Pouzor 821e324111 fix: downgrade eslint to ^9.x — eslint-plugin-react-hooks incompatible with eslint 10 2026-03-27 09:26:54 +01:00
Pouzor ea3adc0f94 update dependency 2026-03-27 09:21:46 +01:00
Pouzor 6f8f0d5e8f docs: split README — move installation to INSTALLATION.md, add network scanner intro, remove proxmox nested nodes section 2026-03-27 09:19:22 +01:00
Pouzor daf3f59590 ui: swap icons between Export and PNG buttons 2026-03-24 01:35:39 +01:00
Pouzor 212eb37e34 ui: place Export next to Import, swap PNG/Export icons 2026-03-24 01:34:13 +01:00
Pouzor 61b8a210fe ui: rename toolbar buttons — YAML export to Export, PNG export to PNG 2026-03-24 01:31:22 +01:00
Pouzor a43ffb813e fix: export all edges via links array — clusterR/clusterL reserved for cluster-type edges only 2026-03-24 01:25:26 +01:00
Remy f469d6c744 Merge pull request #4 from Pouzor/feat/yaml-import
feat: YAML import
2026-03-24 01:18:56 +01:00
Pouzor e7ab9a1d7a feat: add YAML import — merge nodes/edges from .yaml file into canvas 2026-03-24 01:14:54 +01:00
Remy d5b67a770c Merge pull request #3 from Pouzor/feat/yaml-export
feat: YAML export
2026-03-24 01:12:05 +01:00
Pouzor 2e49c14028 feat: add YAML export — serialize canvas nodes/edges to downloadable .yaml file 2026-03-23 21:59:04 +01:00
Pouzor d9787fdcbb security issue 2026-03-21 01:09:06 +01:00
Pouzor 06ec18a137 feat: add hardware specs to nodes (CPU, RAM, Disk) with canvas display
- Add cpu_count, cpu_model, ram_gb, disk_gb, show_hardware fields to NodeData
- NodeModal: collapsible Hardware section with toggle to show specs on canvas
- BaseNode: 2-line hardware section with Cpu/MemoryStick/HardDrive icons, only renders set fields
- DetailPanel: Hardware section with GB/TB formatting
- Backend: schema, model, DB migration, canvas save/load support
- Tests: frontend (NodeModal, DetailPanel) + backend (canvas persist/load)
2026-03-21 00:01:15 +01:00
Pouzor adb4474687 feat: add Docker node type, group rect border style, and categorized node type selector
- Fix DockerNode default icon: use Anchor instead of Package
- Add border_style option to Group Rectangle (solid/dashed/dotted/double/none)
- Group node type selector by category (Hardware, Virtualization, IoT, Generic)
- Add tests for border style feature
2026-03-20 21:09:58 +01:00
57 changed files with 4110 additions and 417 deletions
+83
View File
@@ -0,0 +1,83 @@
name: Docker CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
smoke-and-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ── Write a minimal .env required by env_file: .env in docker-compose.yml ─
# AUTH_PASSWORD_HASH is NOT set here — it's injected via docker-compose.ci.yml
# environment section using $$ escaping to avoid docker-compose $VAR expansion.
- name: Write .env
run: |
{
echo "SECRET_KEY=ci-only-secret-key-not-for-production"
echo "SQLITE_PATH=/app/data/homelab.db"
echo 'CORS_ORIGINS=["http://localhost:3000"]'
echo 'SCANNER_RANGES=["127.0.0.1/32"]'
echo "STATUS_CHECKER_INTERVAL=300"
echo "MCP_API_KEY=ci-mcp-key"
echo "MCP_SERVICE_KEY=ci-svc-key"
} > .env
# ── Build + start backend and frontend (skip mcp) ─────────────────────────
# docker-compose.ci.yml: exposes port 8000 + injects AUTH_* env vars
- name: Build images
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build backend frontend
- name: Start stack
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
# ── Wait for backend to be healthy (max 60 s) ─────────────────────────────
- name: Wait for backend health
run: |
echo "Waiting for backend..."
for i in $(seq 1 30); do
if curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; then
echo "Backend is up after ${i}s"
exit 0
fi
sleep 2
done
echo "Backend did not become healthy in time" >&2
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs backend
exit 1
# ── Smoke: frontend serves HTML ───────────────────────────────────────────
- name: Smoke — frontend returns 200
run: |
STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:3000/)
[ "$STATUS" = "200" ] || { echo "Frontend returned $STATUS"; exit 1; }
# ── Tier 3: integration tests against the live stack ──────────────────────
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install backend test deps
run: pip install --quiet -r backend/requirements.txt
- name: Run integration tests
env:
INTEGRATION_BASE_URL: http://localhost:8000
INTEGRATION_USERNAME: admin
INTEGRATION_PASSWORD: admin
run: |
cd backend
pytest tests/test_integration.py -v
# ── Teardown ──────────────────────────────────────────────────────────────
- name: Dump logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
+19
View File
@@ -7,6 +7,25 @@ on:
branches: [main]
jobs:
lint-scripts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck — lxc-install.sh
uses: ludeeus/action-shellcheck@2.0.0
with:
scandir: './scripts'
- name: Hadolint — Dockerfile.backend
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile.backend
ignore: DL3008
- name: Hadolint — Dockerfile.frontend
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile.frontend
ignore: DL3008
frontend:
runs-on: ubuntu-latest
defaults:
+4
View File
@@ -0,0 +1,4 @@
# DL3008: pinning apt package versions is impractical for system tools (nmap, iputils-ping)
# that have version numbers tied to specific Debian releases.
ignore:
- DL3008
+2 -2
View File
@@ -2,8 +2,8 @@ FROM python:3.13-slim
WORKDIR /app
# Install nmap for network scanning
RUN apt-get update && apt-get install -y --no-install-recommends nmap && rm -rf /var/lib/apt/lists/*
# Install nmap for network scanning + iputils-ping for ping-based status checks
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+3 -1
View File
@@ -1,5 +1,7 @@
# Stage 1: build
FROM node:20-alpine AS builder
# Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent.
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE
+120
View File
@@ -0,0 +1,120 @@
# Homelable — Installation
## Quick Start — Docker
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose up -d
```
Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
>
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
>
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
## Quick Start — Frontend only
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
cd homelable && docker compose up -d
```
## Update (Docker)
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose pull && docker compose up -d
```
## Build from source
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
cp .env.example .env
docker compose up -d
```
---
## Proxmox LXC Install
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
```
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
```bash
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
```
The backend runs as a systemd service, the frontend is served via nginx on port 80.
> To install manually inside an existing Debian/Ubuntu machine or LXC:
> ```bash
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
> ```
### Update (LXC)
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
```bash
sudo bash /opt/homelable/scripts/update.sh
```
Or directly from GitHub:
```bash
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
```
---
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
```env
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
# CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# How often to check node status (seconds)
STATUS_CHECKER_INTERVAL=60
```
All settings are also editable in-app via the **Scan Network** button.
---
## Development Mode
**Backend (Python 3.13):**
```bash
cd backend
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../.env.example .env # edit SECRET_KEY and review defaults
uvicorn app.main:app --reload --port 8000
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
+10 -133
View File
@@ -16,109 +16,15 @@ If you just like the design, you can only run the frontend and export your desig
<p align="center">
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
</p>
---
## Quick Start — Docker
## Installation
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose up -d
```
Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
>
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
>
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
## Quick Start - Front only
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
cd homelable && docker compose up -d
```
### Update
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose pull && docker compose up -d
```
### Build from source
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
cp .env.example .env
docker compose up -d
```
---
## Proxmox LXC Install
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
```
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
```bash
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
```
The backend runs as a systemd service, the frontend is served via nginx on port 80.
> To install manually inside an existing Debian/Ubuntu machine or LXC:
> ```bash
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
> ```
### Update
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
```bash
sudo bash /opt/homelable/scripts/update.sh
```
Or directly from GitHub:
```bash
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
```
---
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
```env
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
# CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# How often to check node status (seconds)
STATUS_CHECKER_INTERVAL=60
```
All settings are also editable in-app via the **Scan Network** button.
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
---
@@ -128,7 +34,8 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
### Triggering a scan
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes. Errors are shown inline and as a toast notification.
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
### macOS / root privileges
@@ -151,19 +58,10 @@ Results are written directly to the database and appear as Pending Devices in th
---
## Proxmox Nested Nodes
Proxmox nodes render as a resizable group container. VM and LXC nodes can be placed inside:
1. Add a **Proxmox VE** node to the canvas
2. Add a **VM** or **LXC** node — select the Proxmox node in the **Parent Proxmox** dropdown
3. The child node appears inside the group and moves with it
4. Select the Proxmox node to reveal resize handles (drag corners to expand)
---
## Node Check Methods
Homelable continuously monitors your nodes and displays their live status (online / offline / unknown) directly on the canvas. Each node can be configured with an independent check method suited to the service it runs.
| Method | Description |
|--------|-------------|
| `ping` | ICMP ping |
@@ -176,9 +74,9 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
---
## MCP Server (AI Integration)
## MCP Server (AI Integration) (optionnal)
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
### What the AI can do
@@ -262,25 +160,4 @@ Or add it manually to `~/.claude.json`:
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`.
- The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
---
## Development Mode
**Backend (Python 3.13):**
```bash
cd backend
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../.env.example .env # edit SECRET_KEY and review defaults
uvicorn app.main:app --reload --port 8000
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
---
+146
View File
@@ -0,0 +1,146 @@
[
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
]
+14
View File
@@ -42,6 +42,20 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+7
View File
@@ -37,6 +37,13 @@ class Node(Base):
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+3 -3
View File
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Homelable API",
version="1.0.0",
version="1.3.3",
lifespan=lifespan,
)
@@ -30,8 +30,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
+7
View File
@@ -23,6 +23,13 @@ class NodeSave(BaseModel):
container_mode: bool = False
custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
width: float | None = None
height: float | None = None
pos_x: float = 0
pos_y: float = 0
+14
View File
@@ -22,6 +22,13 @@ class NodeBase(BaseModel):
container_mode: bool = False
custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
width: float | None = None
height: float | None = None
class NodeCreate(NodeBase):
@@ -46,6 +53,13 @@ class NodeUpdate(BaseModel):
container_mode: bool | None = None
custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool | None = None
width: float | None = None
height: float | None = None
class NodeResponse(NodeBase):
+13 -3
View File
@@ -1,18 +1,28 @@
"""Match nmap scan results against service_signatures.json."""
import json
import re
import threading
from pathlib import Path
from typing import Any
_SIGNATURES: list[dict[str, Any]] | None = None
_LOCK = threading.Lock()
def _load() -> list[dict[str, Any]]:
global _SIGNATURES
if _SIGNATURES is None:
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json"
with open(path) as f:
_SIGNATURES = json.load(f)
with _LOCK:
if _SIGNATURES is None:
path = Path(__file__).parent.parent / "data" / "service_signatures.json"
try:
with open(path) as f:
_SIGNATURES = json.load(f)
except FileNotFoundError as err:
raise FileNotFoundError(
f"service_signatures.json not found at {path}. "
"This file should be bundled with the application."
) from err
return _SIGNATURES
+99
View File
@@ -138,3 +138,102 @@ async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, he
async def test_save_canvas_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
assert res.status_code == 401
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["cpu_count"] == 8
assert node["cpu_model"] == "Intel i7-12700K"
assert node["ram_gb"] == 32.0
assert node["disk_gb"] == 500.0
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=4, ram_gb=16.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["cpu_count"] == 4
assert node["ram_gb"] == 16.0
assert node["cpu_model"] is None
assert node["disk_gb"] is None
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["show_hardware"] is True
async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["show_hardware"] is False
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, ram_gb=32.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["cpu_count"] is None
assert node["ram_gb"] is None
# ── node width / height (resizable nodes) ─────────────────────────────────────
async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict):
n1 = node_payload(width=320.0, height=180.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["width"] == 320.0
assert node["height"] == 180.0
async def test_save_canvas_dimensions_default_null(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["width"] is None
assert canvas["nodes"][0]["height"] is None
async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict):
n1 = node_payload(width=140.0, height=50.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_resized = {**n1, "width": 280.0, "height": 120.0}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["width"] == 280.0
assert node["height"] == 120.0
async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict):
n1 = node_payload(width=300.0, height=200.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "width": None, "height": None}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["width"] is None
assert canvas["nodes"][0]["height"] is None
+185
View File
@@ -0,0 +1,185 @@
"""
Integration tests — run against a live Docker stack.
Skipped unless INTEGRATION_BASE_URL is set (done automatically in docker-ci.yml).
Usage (local):
INTEGRATION_BASE_URL=http://localhost:8000 \
INTEGRATION_USERNAME=admin \
INTEGRATION_PASSWORD=your-password \
pytest backend/tests/test_integration.py -v
"""
import os
import httpx
import pytest
BASE_URL = os.environ.get("INTEGRATION_BASE_URL", "")
USERNAME = os.environ.get("INTEGRATION_USERNAME", "admin")
_PASSWORD_RAW = os.environ.get("INTEGRATION_PASSWORD", "")
pytestmark = pytest.mark.skipif(
not BASE_URL,
reason="INTEGRATION_BASE_URL not set — skipping live-stack tests",
)
def _require_password() -> str:
if not _PASSWORD_RAW:
pytest.fail("INTEGRATION_PASSWORD env var is required for live-stack tests")
return _PASSWORD_RAW
PASSWORD = _PASSWORD_RAW # resolved at call time via _require_password() in fixture
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def token() -> str:
pw = _require_password()
res = httpx.post(
f"{BASE_URL}/api/v1/auth/login",
json={"username": USERNAME, "password": pw},
timeout=10,
)
assert res.status_code == 200, f"Login failed ({res.status_code}): {res.text}"
return res.json()["access_token"]
@pytest.fixture(scope="module")
def auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def restored_canvas(auth):
"""Save the current canvas before the test and restore it afterward."""
before = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
yield
httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=before, headers=auth, timeout=10)
def _save_canvas(auth, nodes, edges=None):
payload = {
"nodes": nodes,
"edges": edges or [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
res = httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=payload, headers=auth, timeout=10)
assert res.status_code == 200, f"Canvas save failed ({res.status_code}): {res.text}"
return res
def _node(node_id: str, label: str, node_type: str = "server", **extra) -> dict:
"""Build a NodeSave-compatible dict (flat API format, not React Flow format)."""
return {
"id": node_id,
"type": node_type,
"label": label,
"status": "unknown",
"services": [],
"pos_x": extra.pop("pos_x", 0),
"pos_y": extra.pop("pos_y", 0),
**extra,
}
# ── Health ────────────────────────────────────────────────────────────────────
def test_health_endpoint():
res = httpx.get(f"{BASE_URL}/api/v1/health", timeout=10)
assert res.status_code == 200
# ── Auth ──────────────────────────────────────────────────────────────────────
def test_login_returns_token():
pw = _require_password()
res = httpx.post(
f"{BASE_URL}/api/v1/auth/login",
json={"username": USERNAME, "password": pw},
timeout=10,
)
assert res.status_code == 200
data = res.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_bad_credentials():
res = httpx.post(
f"{BASE_URL}/api/v1/auth/login",
json={"username": USERNAME, "password": "definitely-wrong"},
timeout=10,
)
assert res.status_code == 401
def test_protected_route_without_token():
res = httpx.get(f"{BASE_URL}/api/v1/canvas", timeout=10)
assert res.status_code == 401
# ── Canvas round-trip ─────────────────────────────────────────────────────────
def test_canvas_load_returns_valid_structure(auth):
res = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10)
assert res.status_code == 200
data = res.json()
assert "nodes" in data
assert "edges" in data
assert isinstance(data["nodes"], list)
assert isinstance(data["edges"], list)
def test_canvas_save_and_reload(auth, restored_canvas):
_save_canvas(auth, [_node("integ-node-1", "CI Server", pos_x=100, pos_y=200)])
data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
assert len(data["nodes"]) == 1
node = data["nodes"][0]
assert node["id"] == "integ-node-1"
assert node["label"] == "CI Server"
assert node["type"] == "server"
assert node["pos_x"] == 100
assert node["pos_y"] == 200
def test_canvas_save_preserves_node_dimensions(auth, restored_canvas):
"""Width/height survive a save→reload cycle through the real DB."""
_save_canvas(auth, [
_node("resized-node", "Big Router", node_type="router", width=320.0, height=150.0)
])
nodes = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()["nodes"]
node = next((n for n in nodes if n["id"] == "resized-node"), None)
assert node is not None
assert node["width"] == 320.0
assert node["height"] == 150.0
def test_canvas_save_with_edge(auth, restored_canvas):
_save_canvas(
auth,
nodes=[
_node("n-src", "Router", node_type="router"),
_node("n-dst", "Server", node_type="server", pos_x=200),
],
edges=[{
"id": "e-eth",
"source": "n-src",
"target": "n-dst",
"type": "ethernet",
}],
)
data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
assert len(data["edges"]) == 1
edge = data["edges"][0]
# EdgeResponse uses source/target (not source_id/target_id)
assert edge["source"] == "n-src"
assert edge["target"] == "n-dst"
assert edge["type"] == "ethernet"
+14
View File
@@ -0,0 +1,14 @@
# CI override — exposes backend port 8000 and injects credentials.
# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
#
# AUTH_PASSWORD_HASH uses $$ escaping: docker-compose converts $$ → $ before
# passing to the container, so the backend receives a valid bcrypt hash.
# This avoids the project .env file being subject to docker-compose $VAR expansion.
services:
backend:
ports:
- "8000:8000"
environment:
AUTH_USERNAME: admin
# bcrypt hash of "admin" — $$ is docker-compose escape for literal $
AUTH_PASSWORD_HASH: $$2b$$12$$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
+6
View File
@@ -17,6 +17,12 @@ services:
# Required for ping-based status checks
cap_add:
- NET_RAW
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
interval: 10s
timeout: 5s
retries: 6
start_period: 15s
mcp:
build:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

+165 -121
View File
@@ -1,24 +1,26 @@
{
"name": "frontend",
"version": "1.0.0",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.0.0",
"version": "1.3.3",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -40,7 +42,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1",
"eslint": "^9.39.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
@@ -1482,6 +1484,37 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
@@ -1532,6 +1565,24 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -1545,6 +1596,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/js": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
@@ -2838,42 +2902,6 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -3008,6 +3036,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3245,45 +3279,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3339,19 +3334,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
@@ -3812,11 +3794,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
@@ -3865,14 +3849,15 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -5017,6 +5002,37 @@
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
@@ -5029,6 +5045,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -5047,6 +5076,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -5474,9 +5516,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -6917,9 +6959,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -6981,16 +7023,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -7493,9 +7537,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -7938,9 +7982,9 @@
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"license": "MIT",
"funding": {
"type": "opencollective",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,12 +19,14 @@
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -46,7 +48,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1",
"eslint": "^9.39.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+35 -103
View File
@@ -2,9 +2,12 @@ import { useEffect, useCallback, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner'
@@ -32,7 +35,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore()
@@ -58,71 +61,8 @@ export default function App() {
toast.success('Canvas saved')
return
}
const nodesToSave = nodes.map((n) => {
if (n.data.type === 'groupRect') {
return {
id: n.id,
type: 'groupRect',
label: n.data.label,
hostname: null,
ip: null,
mac: null,
os: null,
status: 'unknown',
check_method: null,
check_target: null,
services: [],
notes: null,
parent_id: null,
container_mode: false,
custom_icon: null,
pos_x: n.position.x,
pos_y: n.position.y,
// Persist size and all rect config inside custom_colors
custom_colors: {
...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360,
height: n.measured?.height ?? n.height ?? 240,
},
}
}
return {
id: n.id,
type: n.data.type,
label: n.data.label,
hostname: n.data.hostname ?? null,
ip: n.data.ip ?? null,
mac: n.data.mac ?? null,
os: n.data.os ?? null,
status: n.data.status,
check_method: n.data.check_method ?? null,
check_target: n.data.check_target ?? null,
services: n.data.services ?? [],
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
custom_colors: n.data.custom_colors ?? null,
custom_icon: n.data.custom_icon ?? null,
pos_x: n.position.x,
pos_y: n.position.y,
}
})
const edgesToSave = edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.data?.type ?? 'ethernet',
label: e.data?.label ?? null,
vlan_id: e.data?.vlan_id ?? null,
speed: e.data?.speed ?? null,
custom_color: e.data?.custom_color ?? null,
path_style: e.data?.path_style ?? null,
animated: e.data?.animated ?? false,
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
// map them back to their canonical source handle ID so reload works correctly.
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null),
}))
const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge)
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
markSaved()
toast.success('Canvas saved')
@@ -159,44 +99,12 @@ export default function App() {
if (apiNodes.length > 0) {
// Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>(
apiNodes
.filter((n: NodeData & { id: string }) => n.type === 'proxmox')
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
(apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox')
.map((n) => [n.id, n.container_mode !== false])
)
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => {
if (n.type === 'groupRect') {
const w = n.custom_colors?.width ?? 360
const h = n.custom_colors?.height ?? 240
const z = n.custom_colors?.z_order ?? 1
return {
id: n.id,
type: 'groupRect',
position: { x: n.pos_x, y: n.pos_y },
data: n,
width: w,
height: h,
zIndex: z - 10,
}
}
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: n,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === 'proxmox' && n.container_mode !== false ? { width: 300, height: 200 } : {}),
}
})
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string; source_handle?: string; target_handle?: string }) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.type,
sourceHandle: e.source_handle ?? null,
targetHandle: e.target_handle ?? null,
data: e,
}))
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
loadCanvas(rfNodes, rfEdges)
@@ -273,6 +181,7 @@ export default function App() {
services: [],
custom_colors: {
border: data.border_color,
border_style: data.border_style,
background: data.background_color,
text_color: data.text_color,
text_position: data.text_position,
@@ -295,6 +204,7 @@ export default function App() {
custom_colors: {
...existing?.data.custom_colors,
border: data.border_color,
border_style: data.border_style,
background: data.background_color,
text_color: data.text_color,
text_position: data.text_position,
@@ -364,6 +274,25 @@ export default function App() {
toast.success('Markdown table copied to clipboard')
}, [nodes])
const handleExportYaml = useCallback(() => {
if (nodes.length === 0) { toast.error('No nodes to export'); return }
const content = exportCanvasToYaml(nodes, edges)
downloadYaml(content)
toast.success('Canvas exported as YAML')
}, [nodes, edges])
const handleImportYaml = useCallback((content: string) => {
try {
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
snapshotHistory()
loadCanvas(merged, mergedEdges)
markUnsaved()
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
} catch (err) {
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
}
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
const handleExport = useCallback(async () => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
@@ -442,6 +371,8 @@ export default function App() {
onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd}
onExportYaml={handleExportYaml}
onImportYaml={handleImportYaml}
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
@@ -525,6 +456,7 @@ export default function App() {
text_color: rc.text_color ?? '#e6edf3',
text_position: rc.text_position ?? 'top-left',
border_color: rc.border ?? '#00d4ff',
border_style: rc.border_style ?? 'solid',
background_color: rc.background ?? '#00d4ff0d',
z_order: rc.z_order ?? 1,
}
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginPage } from '../LoginPage'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/api/client', () => ({
authApi: {
login: vi.fn(),
},
}))
import { authApi } from '@/api/client'
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
vi.mocked(authApi.login).mockReset()
})
// ── Rendering ────────────────────────────────────────────────────────────
it('renders username and password fields', () => {
render(<LoginPage />)
expect(screen.getByLabelText('Username')).toBeDefined()
expect(screen.getByLabelText('Password')).toBeDefined()
})
it('renders a Sign in button', () => {
render(<LoginPage />)
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.type).toBe('password')
})
it('username field has autocomplete="username"', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.getAttribute('autocomplete')).toBe('username')
})
it('password field has autocomplete="current-password" (supports password managers)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.getAttribute('autocomplete')).toBe('current-password')
})
it('shows a generic error message — no credential enumeration', async () => {
vi.mocked(authApi.login).mockRejectedValue(new Error('401'))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText('Invalid username or password')).toBeDefined()
})
// Must show exactly ONE error — not separate per-field messages (no enumeration)
const errors = document.querySelectorAll('p.text-\\[\\#f85149\\]')
expect(errors.length).toBe(1)
expect(errors[0].textContent).toBe('Invalid username or password')
})
it('clears previous error before each new attempt', async () => {
vi.mocked(authApi.login)
.mockRejectedValueOnce(new Error('401'))
.mockRejectedValueOnce(new Error('401'))
render(<LoginPage />)
const form = screen.getByRole('button', { name: /sign in/i }).closest('form')!
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'bad' } })
fireEvent.submit(form)
await waitFor(() => screen.getByText('Invalid username or password'))
fireEvent.submit(form)
// Error clears while loading (setError('') before try)
await waitFor(() => screen.getByText('Invalid username or password'))
expect(screen.getAllByText('Invalid username or password')).toHaveLength(1)
})
it('disables submit button while loading — prevents double-submit', async () => {
let resolve!: (v: unknown) => void
vi.mocked(authApi.login).mockReturnValue(new Promise((r) => { resolve = r }))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
})
resolve({ data: { access_token: 'tok' } })
})
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'tok' } } as never)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith('admin', 'secret')
})
})
it('stores token in authStore on successful login', async () => {
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'mytoken123' } } as never)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().token).toBe('mytoken123')
})
})
it('token persisted via sessionStorage — not localStorage', () => {
// The authStore uses createJSONStorage(() => sessionStorage)
// Verify the storage key exists in sessionStorage after login
render(<LoginPage />)
// Even before login, the store is backed by sessionStorage
expect(typeof sessionStorage).toBe('object')
// localStorage should NOT contain the auth token
expect(localStorage.getItem('homelable-auth')).toBeNull()
})
it('does not show error on initial render', () => {
render(<LoginPage />)
expect(screen.queryByText('Invalid username or password')).toBeNull()
})
it('requires username (HTML required attribute)', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.required).toBe(true)
})
it('requires password (HTML required attribute)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.required).toBe(true)
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import { CanvasContainer } from '../CanvasContainer'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
// Capture props passed to ReactFlow so we can test the callbacks
let rfProps: Record<string, unknown> = {}
vi.mock('@xyflow/react', () => ({
ReactFlow: (props: Record<string, unknown>) => {
rfProps = props
return <div data-testid="react-flow" />
},
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
function makeNode(id: string): Node<NodeData> {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'unknown', services: [] },
}
}
function makeEdge(id: string): Edge<EdgeData> {
return { id, source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
}
describe('CanvasContainer', () => {
beforeEach(() => {
rfProps = {}
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
useThemeStore.setState({ activeTheme: 'default' })
})
// ── Rendering ─────────────────────────────────────────────────────────────
it('renders without crashing', () => {
const { getByTestId } = render(<CanvasContainer />)
expect(getByTestId('react-flow')).toBeDefined()
})
it('passes nodes from store to ReactFlow', () => {
useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] })
render(<CanvasContainer />)
expect((rfProps.nodes as Node[]).length).toBe(2)
})
it('passes edges from store to ReactFlow', () => {
useCanvasStore.setState({
nodes: [makeNode('n1'), makeNode('n2')],
edges: [makeEdge('e1')],
})
render(<CanvasContainer />)
expect((rfProps.edges as Edge[]).length).toBe(1)
})
// ── Node click → selection ────────────────────────────────────────────────
it('calls setSelectedNode with node id on node click', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node] })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
})
// ── Pane click → deselect ─────────────────────────────────────────────────
it('calls setSelectedNode(null) on pane click', () => {
useCanvasStore.setState({ selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onPaneClick as (...args: unknown[]) => unknown)()
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
// ── Edge double-click ─────────────────────────────────────────────────────
it('calls onEdgeDoubleClick prop when an edge is double-clicked', () => {
const onEdgeDoubleClick = vi.fn()
const edge = makeEdge('e1')
render(<CanvasContainer onEdgeDoubleClick={onEdgeDoubleClick} />)
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
expect(onEdgeDoubleClick).toHaveBeenCalledWith(edge)
})
it('does not throw when onEdgeDoubleClick is not provided', () => {
const edge = makeEdge('e1')
render(<CanvasContainer />)
expect(() => {
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
}).not.toThrow()
})
// ── Connection validation ─────────────────────────────────────────────────
it('isValidConnection returns false for self-connections', () => {
render(<CanvasContainer />)
const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean
expect(isValid({ source: 'n1', target: 'n1' })).toBe(false)
})
it('isValidConnection returns true for different nodes', () => {
render(<CanvasContainer />)
const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean
expect(isValid({ source: 'n1', target: 'n2' })).toBe(true)
})
// ── onConnect prop passthrough ────────────────────────────────────────────
it('passes onConnect prop to ReactFlow', () => {
const onConnect = vi.fn()
render(<CanvasContainer onConnect={onConnect} />)
;(rfProps.onConnect as (...args: unknown[]) => unknown)({ source: 'a', target: 'b', sourceHandle: null, targetHandle: null })
expect(onConnect).toHaveBeenCalledOnce()
})
// ── onNodeDragStart prop passthrough ──────────────────────────────────────
it('passes onNodeDragStart prop to ReactFlow', () => {
const onNodeDragStart = vi.fn()
render(<CanvasContainer onNodeDragStart={onNodeDragStart} />)
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
})
// ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => {
render(<CanvasContainer />)
expect(rfProps.snapToGrid).toBe(true)
})
it('sets snapGrid to [16, 16]', () => {
render(<CanvasContainer />)
expect(rfProps.snapGrid).toEqual([16, 16])
})
})
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
import { type LucideIcon } from 'lucide-react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
@@ -13,7 +13,12 @@ interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon
}
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
}
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
@@ -22,10 +27,11 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
return (
<div
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
className="relative flex flex-col rounded-lg border transition-all duration-200"
style={{
background: colors.background,
borderColor: colors.border,
@@ -37,8 +43,17 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
: 'none',
opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140,
width: width ? '100%' : undefined,
height: height ? '100%' : undefined,
}}
>
<NodeResizer
isVisible={selected}
minWidth={140}
minHeight={50}
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
/>
<Handle
type="source"
position={Position.Top}
@@ -47,36 +62,77 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Icon */}
<div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
style={{
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 15 })}
{/* Main row */}
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
{/* Icon */}
<div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
style={{
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
<div className="flex flex-col min-w-0">
<div
className="text-xs font-medium leading-tight truncate"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label}
</div>
{data.ip && (
<div
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
</div>
)}
</div>
</div>
{/* Details */}
<div className="flex flex-col min-w-0">
<div
className="text-xs font-medium leading-tight truncate max-w-[110px]"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label}
</div>
{data.ip && (
<div
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
{/* Hardware section */}
{showHardware && (
<>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5">
{/* Line 1: CPU */}
{(data.cpu_model || data.cpu_count != null) && (
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" />
{data.cpu_model && (
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
)}
{data.cpu_count != null && (
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
)}
</div>
)}
{/* Line 2: RAM + Disk */}
{(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && (
<span className="flex items-center gap-0.5">
<MemoryStick size={9} className="shrink-0" />
{formatStorage(data.ram_gb)}
</span>
)}
{data.disk_gb != null && (
<span className="flex items-center gap-0.5">
<HardDrive size={9} className="shrink-0" />
{formatStorage(data.disk_gb)}
</span>
)}
</div>
)}
</div>
)}
</div>
</>
)}
{/* Status dot */}
<div
@@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff'
const borderStyle = rc.border_style ?? 'solid'
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
const textColor = rc.text_color ?? '#e6edf3'
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
@@ -61,7 +62,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
justifyContent: posStyle.justifyContent,
padding: 12,
background: backgroundColor,
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
borderRadius: 10,
fontFamily,
color: textColor,
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -22,4 +22,5 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,4 +1,4 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
@@ -17,6 +17,7 @@ export const nodeTypes = {
printer: PrinterNode,
computer: ComputerNode,
cpl: CplNode,
docker: DockerNode,
generic: GenericNode,
groupRect: GroupRectNode,
}
@@ -6,22 +6,34 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
export interface GroupRectFormData {
label: string
font: string
text_color: string
text_position: TextPosition
border_color: string
border_style: BorderStyle
background_color: string
z_order: number
}
const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [
{ value: 'solid', label: 'Solid', preview: '───' },
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
{ value: 'dotted', label: 'Dotted', preview: '···' },
{ value: 'double', label: 'Double', preview: '═══' },
{ value: 'none', label: 'None', preview: ' ' },
]
const DEFAULT_FORM: GroupRectFormData = {
label: '',
font: 'inter',
text_color: '#e6edf3',
text_position: 'top-left',
border_color: '#00d4ff',
border_style: 'solid',
background_color: '#00d4ff0d',
z_order: 1,
}
@@ -157,6 +169,33 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div>
</div>
{/* Border style */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_STYLES.map(({ value, label, preview }) => {
const isSelected = form.border_style === value
return (
<button
key={value}
type="button"
title={label}
onClick={() => set('border_style', value)}
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
<span className="font-mono text-[11px] leading-none">{preview}</span>
<span className="text-[9px]">{label}</span>
</button>
)
})}
</div>
</div>
{/* Z-order */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
+105 -6
View File
@@ -4,12 +4,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
@@ -44,6 +49,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = useState(false)
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
@@ -76,10 +83,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value} className="text-sm">
{label}
</SelectItem>
{NODE_TYPE_GROUPS.map((group, i) => (
<>
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
<SelectGroup key={group.label}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type} value={type} className="text-sm pl-4">
{NODE_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectGroup>
</>
))}
</SelectContent>
</Select>
@@ -315,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)}
</div>
{/* Hardware specs (hidden for groupRect) */}
{form.type !== 'groupRect' && (
<div className="flex flex-col gap-2 col-span-2">
<div className="flex items-center justify-between w-full">
<button
type="button"
onClick={() => setHardwareOpen((o) => !o)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span className="font-medium">Hardware</span>
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{hardwareOpen && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
<button
type="button"
role="switch"
aria-checked={!!form.show_hardware}
onClick={() => set('show_hardware', !form.show_hardware)}
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
>
<span
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
</div>
{hardwareOpen && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">CPU Model</Label>
<Input
value={form.cpu_model ?? ''}
onChange={(e) => set('cpu_model', e.target.value || undefined)}
placeholder="e.g. Intel Xeon E5-2680"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
<Input
type="number"
min={1}
value={form.cpu_count ?? ''}
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
placeholder="e.g. 8"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
<Input
type="number"
min={0}
step={0.5}
value={form.ram_gb ?? ''}
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 32"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
<Input
type="number"
min={0}
step={1}
value={form.disk_gb ?? ''}
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 500"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
</div>
)}
</div>
)}
{/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label>
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { EdgeModal } from '../EdgeModal'
describe('EdgeModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when closed', () => {
const { container } = render(<EdgeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form when open', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Connect Nodes')).toBeDefined()
})
it('uses custom title when provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Link" />)
expect(screen.getByText('Edit Link')).toBeDefined()
})
// ── Submit button label ───────────────────────────────────────────────────
it('shows "Connect" button when onDelete is not provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined()
})
it('shows "Save" button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
// ── Default submit ────────────────────────────────────────────────────────
it('calls onSubmit with default ethernet type', () => {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].type).toBe('ethernet')
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onSubmit with label when filled', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
})
it('omits label from payload when empty', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
})
// ── VLAN ID field ─────────────────────────────────────────────────────────
it('does not show VLAN ID field for ethernet type', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull()
})
it('submits integer vlan_id when type is vlan', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'vlan', vlan_id: 20 }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].vlan_id).toBe(20)
})
it('omits vlan_id from payload for non-vlan types', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].vlan_id).toBeUndefined()
})
// ── Path style ────────────────────────────────────────────────────────────
it('defaults to bezier path style', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('bezier')
})
it('switches path style to smooth on click', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Smooth step'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
})
// ── Animated toggle ───────────────────────────────────────────────────────
it('flow animation defaults to off', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
// animated: false → omitted (falsy || undefined)
expect(onSubmit.mock.calls[0][0].animated).toBeFalsy()
})
it('toggling animation sends animated: true', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// Find the toggle: it's the only button with aria-pressed attribute
const allButtons = screen.getAllByRole('button')
const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))!
expect(toggle).toBeDefined()
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe(true)
})
// ── Pre-fill ──────────────────────────────────────────────────────────────
it('pre-fills label from initial prop', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
expect(input.value).toBe('trunk')
})
it('pre-fills path style from initial prop', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ path_style: 'smooth' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
})
// ── Cancel & Delete ───────────────────────────────────────────────────────
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('shows Delete button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined()
})
it('does not show Delete button without onDelete', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull()
})
it('calls onDelete and onClose when Delete is clicked', () => {
const onDelete = vi.fn()
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -80,4 +80,56 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_position).toBe('bottom-right')
})
it('renders Border Style section', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Border Style')).toBeDefined()
expect(screen.getByTitle('Solid')).toBeDefined()
expect(screen.getByTitle('Dashed')).toBeDefined()
expect(screen.getByTitle('Dotted')).toBeDefined()
expect(screen.getByTitle('Double')).toBeDefined()
expect(screen.getByTitle('None')).toBeDefined()
})
it('defaults border_style to solid', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
it('selects border style on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByTitle('Dashed'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('dashed')
})
it('pre-fills border_style from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ border_style: 'dotted' }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('dotted')
})
it('toggles border style — clicking selected style deselects back to solid', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByTitle('Dotted'))
fireEvent.click(screen.getByTitle('Solid'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
})
@@ -62,4 +62,109 @@ describe('NodeModal', () => {
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce()
})
describe('Hardware section', () => {
it('renders Hardware toggle button', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
})
it('hardware fields are hidden by default', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
})
it('expands hardware fields on toggle click', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
it('submits hardware fields when filled', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.cpu_model).toBe('Intel i7-12700K')
expect(submitted.cpu_count).toBe(12)
expect(submitted.ram_gb).toBe(64)
expect(submitted.disk_gb).toBe(2000)
})
it('auto-expands when initial has hardware data', () => {
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
/>
)
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('hides hardware section for groupRect type', () => {
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ type: 'groupRect' }}
/>
)
expect(screen.queryByText('Hardware')).toBeNull()
})
it('show on node toggle is hidden when section is collapsed', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByText('Show on node')).toBeNull()
})
it('show on node toggle appears when section is expanded', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByText('Show on node')).toBeDefined()
})
it('show_hardware defaults to false', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
})
it('toggling show on node sets show_hardware to true', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('pre-fills show_hardware from initial prop', () => {
const onSubmit = vi.fn()
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
/>
)
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
})
})
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
return {
id: 'dev-1',
ip: '192.168.1.100',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pve.local',
os: 'Linux',
services: [],
suggested_type: 'server',
status: 'pending',
discovered_at: '2024-01-15T10:30:00Z',
...overrides,
}
}
describe('PendingDeviceModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when device is null', () => {
const { container } = render(
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders dialog when device is provided', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('dialog')).toBeDefined()
})
// ── Device info display ───────────────────────────────────────────────────
it('shows hostname as title when available', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// hostname appears in both title and info row — check at least one match
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
})
it('falls back to IP as title when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// IP appears in both title and info row when hostname is absent
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
})
it('shows IP address', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('192.168.1.100')).toBeDefined()
})
it('shows MAC address when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
})
it('shows OS when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Linux')).toBeDefined()
})
it('does not show hostname row when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// "Hostname" label should not appear in the info rows
expect(screen.queryByText('Hostname')).toBeNull()
})
it('shows suggested type when present', () => {
render(
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('proxmox')).toBeDefined()
})
// ── Services ──────────────────────────────────────────────────────────────
it('shows "No services detected" when services list is empty', () => {
render(
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('No services detected')).toBeDefined()
})
it('shows service count and details', () => {
const device = makeDevice({
services: [
{ port: 80, protocol: 'tcp', service_name: 'HTTP', category: 'web' },
{ port: 443, protocol: 'tcp', service_name: 'HTTPS', category: 'web' },
],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Services found (2)')).toBeDefined()
expect(screen.getByText('HTTP')).toBeDefined()
expect(screen.getByText('HTTPS')).toBeDefined()
expect(screen.getByText('80')).toBeDefined()
expect(screen.getByText('443')).toBeDefined()
})
it('shows service category when present', () => {
const device = makeDevice({
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('hypervisor')).toBeDefined()
})
// ── Actions ───────────────────────────────────────────────────────────────
it('calls onApprove with the device and onClose when Approve is clicked', () => {
const device = makeDevice()
const onApprove = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onHide with the device and onClose when Hide is clicked', () => {
const device = makeDevice()
const onHide = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
expect(onHide).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onIgnore with the device and onClose when Delete is clicked', () => {
const device = makeDevice()
const onIgnore = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onIgnore).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ScanConfigModal } from '../ScanConfigModal'
vi.mock('@/api/client', () => ({
scanApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } }
describe('ScanConfigModal', () => {
beforeEach(() => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ScanConfigModal open={false} onClose={vi.fn()} onScanNow={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('loads config from API on open', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => {
expect(scanApi.getConfig).toHaveBeenCalledOnce()
})
const input = await screen.findByDisplayValue('192.168.1.0/24')
expect(input).toBeDefined()
})
it('loads interval from API on open', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByText('Add range'))
const inputs = screen.getAllByPlaceholderText('192.168.1.0/24')
expect(inputs).toHaveLength(2)
})
it('delete button disabled when only one range', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Only 1 range → delete button disabled
const trashButtons = document.querySelectorAll('button[disabled]')
expect(trashButtons.length).toBeGreaterThan(0)
})
it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
})
it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
})
expect(scanApi.saveConfig).not.toHaveBeenCalled()
})
it('saves config and closes on Save click', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce()
})
})
it('shows error toast when save fails', async () => {
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
})
})
it('calls onScanNow after saving on "Scan Now" click', async () => {
const onScanNow = vi.fn()
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalled()
expect(onScanNow).toHaveBeenCalledOnce()
})
})
it('calls onClose when Cancel is clicked', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('strips whitespace from ranges before saving', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('192.168.1.0/24')
// Type a range with surrounding whitespace
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
)
})
})
})
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SearchModal } from '../SearchModal'
import { useCanvasStore } from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const mockFitView = vi.fn()
vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ fitView: mockFitView }),
}))
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id,
type: overrides.type ?? 'server',
position: { x: 0, y: 0 },
data: {
label: id,
type: overrides.type ?? 'server',
status: 'unknown',
services: [],
...overrides,
},
}
}
describe('SearchModal', () => {
beforeEach(() => {
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
mockFitView.mockReset()
})
it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />)
expect(screen.getByText(/type to search/i)).toBeDefined()
})
it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
})
it('filters nodes by label', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
})
it('filters nodes by IP', () => {
useCanvasStore.setState({
nodes: [
makeNode('n1', { label: 'Box A', ip: '192.168.1.10' }),
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
})
it('filters nodes by hostname', () => {
useCanvasStore.setState({
nodes: [
makeNode('n1', { label: 'A', hostname: 'pve.local' }),
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
})
it('excludes groupRect nodes from results', () => {
useCanvasStore.setState({
nodes: [
makeNode('n1', { label: 'Server', type: 'server' }),
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
})
it('limits results to 8 nodes', () => {
useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8)
})
it('selects node and closes on result click', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
expect(mockFitView).toHaveBeenCalledWith(expect.objectContaining({ nodes: [{ id: 'n1' }] }))
expect(onClose).toHaveBeenCalledOnce()
})
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} />)
const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
expect(onClose).toHaveBeenCalledOnce()
})
it('closes on Escape key', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
// The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
expect(onClose).toHaveBeenCalledOnce()
})
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})
})
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ThemeModal } from '../ThemeModal'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEME_ORDER } from '@/utils/themes'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { toast } from 'sonner'
describe('ThemeModal', () => {
beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' })
useCanvasStore.setState({ hasUnsavedChanges: false })
vi.mocked(toast.info).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ThemeModal open={false} onClose={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders all available themes', () => {
render(<ThemeModal open onClose={vi.fn()} />)
// Every theme in THEME_ORDER should have a card rendered
expect(THEME_ORDER.length).toBeGreaterThan(0)
// At minimum the dialog title should be present
expect(screen.getByText('Choose Canvas Style')).toBeDefined()
})
it('shows Apply Style button', () => {
render(<ThemeModal open onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Apply Style' })).toBeDefined()
})
it('shows Cancel button', () => {
render(<ThemeModal open onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
})
it('live-previews theme when a card is clicked', () => {
render(<ThemeModal open onClose={vi.fn()} />)
const initialTheme = useThemeStore.getState().activeTheme
// Click a different theme card (find by button role, pick a non-default one)
const cards = screen.getAllByRole('button').filter((b) =>
b.className.includes('rounded-xl')
)
// Click the second card (first non-selected)
fireEvent.click(cards[1])
// Theme should have changed for live preview
expect(useThemeStore.getState().activeTheme).not.toBe(initialTheme)
})
it('Apply sets theme, marks unsaved, and closes', () => {
const onClose = vi.fn()
render(<ThemeModal open onClose={onClose} />)
// Click a non-default card first
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
fireEvent.click(cards[1])
const previewTheme = useThemeStore.getState().activeTheme
fireEvent.click(screen.getByRole('button', { name: 'Apply Style' }))
expect(useThemeStore.getState().activeTheme).toBe(previewTheme)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
expect(onClose).toHaveBeenCalledOnce()
})
it('Apply shows toast asking user to save canvas', () => {
render(<ThemeModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Apply Style' }))
expect(toast.info).toHaveBeenCalledWith(
expect.stringContaining('save'),
expect.any(Object)
)
})
it('Cancel reverts to original theme and closes', () => {
const onClose = vi.fn()
useThemeStore.setState({ activeTheme: 'default' })
render(<ThemeModal open onClose={onClose} />)
// Preview a different theme
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
fireEvent.click(cards[1])
expect(useThemeStore.getState().activeTheme).not.toBe('default')
// Cancel should revert
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(useThemeStore.getState().activeTheme).toBe('default')
expect(onClose).toHaveBeenCalledOnce()
})
it('Cancel does not mark canvas as unsaved', () => {
render(<ThemeModal open onClose={vi.fn()} />)
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
fireEvent.click(cards[1])
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
})
@@ -100,6 +100,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)}
</div>
{/* Hardware */}
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
</div>
)}
{/* Services */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
@@ -202,6 +213,11 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)
}
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
}
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-between gap-2 items-baseline">
+32 -3
View File
@@ -1,4 +1,5 @@
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react'
import { useRef } from 'react'
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -12,10 +13,25 @@ interface ToolbarProps {
onRedo: () => void
onShortcuts: () => void
onExportMd: () => void
onExportYaml: () => void
onImportYaml: (content: string) => void
}
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore()
const fileInputRef = useRef<HTMLInputElement>(null)
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const content = ev.target?.result
if (typeof content === 'string') onImportYaml(content)
}
reader.readAsText(file)
e.target.value = ''
}
return (
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
@@ -46,9 +62,22 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
<Upload size={14} /> Import
</Button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFileChange}
/>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
<Download size={14} /> Export
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
<FileDown size={14} /> PNG
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD
</Button>
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore')
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: data.type ?? 'server',
position: { x: 0, y: 0 },
data: {
label: 'Test Node',
type: 'server',
status: 'online',
services: [],
...data,
},
}
}
function setupStore(nodeData: Partial<NodeData> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode(nodeData)],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
describe('DetailPanel', () => {
beforeEach(() => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
selectedNodeId: null,
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
})
it('renders nothing when no node is selected', () => {
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
it('renders node label and status', () => {
setupStore({ label: 'My Server', status: 'online' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('My Server')).toBeDefined()
expect(screen.getByText('online')).toBeDefined()
})
it('renders nothing for groupRect nodes', () => {
setupStore({ type: 'groupRect', label: 'Zone' })
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
describe('Hardware section', () => {
it('does not render hardware section when no hardware data', () => {
setupStore({ label: 'Server' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('Hardware')).toBeNull()
})
it('renders hardware section when cpu_count is set', () => {
setupStore({ cpu_count: 8 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('8')).toBeDefined()
})
it('renders cpu_model', () => {
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
})
it('formats ram_gb in GB', () => {
setupStore({ ram_gb: 32 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('32 GB')).toBeDefined()
})
it('formats ram_gb >= 1024 as TB', () => {
setupStore({ ram_gb: 2048 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('2 TB')).toBeDefined()
})
it('formats disk_gb in GB', () => {
setupStore({ disk_gb: 500 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('500 GB')).toBeDefined()
})
it('formats disk_gb >= 1024 as TB', () => {
setupStore({ disk_gb: 1536 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('1.5 TB')).toBeDefined()
})
it('renders all hardware fields together', () => {
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('AMD EPYC')).toBeDefined()
expect(screen.getByText('16')).toBeDefined()
expect(screen.getByText('128 GB')).toBeDefined()
expect(screen.getByText('4 TB')).toBeDefined()
})
})
})
@@ -328,4 +328,40 @@ describe('canvasStore', () => {
useCanvasStore.getState().pasteNodes()
expect(useCanvasStore.getState().nodes).toHaveLength(1)
})
// --- Node resizing (width / height) ---
it('addNode preserves explicit width and height', () => {
const node: Node<NodeData> = { ...makeNode('n1'), width: 280, height: 120 }
useCanvasStore.getState().addNode(node)
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(stored?.width).toBe(280)
expect(stored?.height).toBe(120)
})
it('onNodesChange dimensions change updates width and height', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onNodesChange([
{ type: 'dimensions', id: 'n1', dimensions: { width: 320, height: 180 }, resizing: true },
])
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(node?.measured?.width ?? node?.width).toBeDefined()
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('loadCanvas preserves width and height on resized nodes', () => {
const resized: Node<NodeData> = { ...makeNode('n1'), width: 300, height: 160 }
useCanvasStore.getState().loadCanvas([resized], [])
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(stored?.width).toBe(300)
expect(stored?.height).toBe(160)
})
it('loadCanvas preserves undefined width/height for default-sized nodes', () => {
useCanvasStore.getState().loadCanvas([makeNode('n1')], [])
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(stored?.width).toBeUndefined()
expect(stored?.height).toBeUndefined()
})
})
+8
View File
@@ -13,6 +13,7 @@ export type NodeType =
| 'printer'
| 'computer'
| 'cpl'
| 'docker'
| 'generic'
| 'groupRect'
@@ -55,6 +56,11 @@ export interface NodeData extends Record<string, unknown> {
last_seen?: string
response_time_ms?: number
notes?: string
cpu_count?: number
cpu_model?: string
ram_gb?: number
disk_gb?: number
show_hardware?: boolean
parent_id?: string
container_mode?: boolean
custom_colors?: {
@@ -65,6 +71,7 @@ export interface NodeData extends Record<string, unknown> {
text_color?: string
text_position?: TextPosition
font?: string
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
z_order?: number
width?: number
height?: number
@@ -99,6 +106,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer',
computer: 'Computer',
cpl: 'CPL / Powerline',
docker: 'Docker Host',
generic: 'Generic Device',
groupRect: 'Group Rectangle',
}
+26
View File
@@ -0,0 +1,26 @@
import type { NodeType, EdgeType, CheckMethod } from '@/types'
export interface YamlNodeConnection {
label: string
linkType?: EdgeType
linkLabel?: string
}
export interface YamlNode {
nodeType: NodeType
nodeIcon?: string
label: string
hostname?: string
ipAddress?: string
checkMethod?: CheckMethod
checkTarget?: string
notes?: string
links?: YamlNodeConnection[]
parent?: YamlNodeConnection
clusterR?: YamlNodeConnection
clusterL?: YamlNodeConnection
cpuModel?: string
cpuCore?: number
ram?: number
disk?: number
}
@@ -0,0 +1,384 @@
import { describe, it, expect } from 'vitest'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import {
serializeNode,
serializeEdge,
deserializeApiNode,
deserializeApiEdge,
type ApiNode,
type ApiEdge,
} from '@/utils/canvasSerializer'
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeRfNode(overrides: Partial<Node<NodeData>> = {}): Node<NodeData> {
return {
id: 'n1',
type: 'server',
position: { x: 100, y: 200 },
data: {
label: 'My Server',
type: 'server',
status: 'online',
services: [],
},
...overrides,
}
}
function makeApiNode(overrides: Partial<ApiNode> = {}): ApiNode {
return {
id: 'n1',
type: 'server',
label: 'My Server',
pos_x: 100,
pos_y: 200,
status: 'online',
services: [],
...overrides,
}
}
function makeRfEdge(overrides: Partial<Edge<EdgeData>> = {}): Edge<EdgeData> {
return {
id: 'e1',
source: 'n1',
target: 'n2',
type: 'ethernet',
data: { type: 'ethernet' },
...overrides,
}
}
function makeApiEdge(overrides: Partial<ApiEdge> = {}): ApiEdge {
return {
id: 'e1',
source: 'n1',
target: 'n2',
type: 'ethernet',
...overrides,
}
}
// ── serializeNode — regular nodes ────────────────────────────────────────────
describe('serializeNode — regular node', () => {
it('maps position to pos_x/pos_y', () => {
const result = serializeNode(makeRfNode({ position: { x: 42, y: 99 } }))
expect(result.pos_x).toBe(42)
expect(result.pos_y).toBe(99)
})
it('includes all data fields', () => {
const node = makeRfNode({
data: {
label: 'Router', type: 'router', status: 'online', services: [],
hostname: 'gw.local', ip: '192.168.1.1', mac: 'aa:bb:cc:dd:ee:ff',
os: 'OpenWRT', check_method: 'ping', check_target: '192.168.1.1',
notes: 'main router',
},
})
const result = serializeNode(node)
expect(result.hostname).toBe('gw.local')
expect(result.ip).toBe('192.168.1.1')
expect(result.mac).toBe('aa:bb:cc:dd:ee:ff')
expect(result.os).toBe('OpenWRT')
expect(result.check_method).toBe('ping')
expect(result.check_target).toBe('192.168.1.1')
expect(result.notes).toBe('main router')
})
it('serializes width and height when node has been resized', () => {
const node = makeRfNode({ width: 280, height: 120 })
const result = serializeNode(node)
expect(result.width).toBe(280)
expect(result.height).toBe(120)
})
it('serializes width/height as null when node has default size', () => {
const result = serializeNode(makeRfNode())
expect(result.width).toBeNull()
expect(result.height).toBeNull()
})
it('serializes hardware fields', () => {
const node = makeRfNode({
data: {
label: 'Server', type: 'server', status: 'online', services: [],
cpu_count: 8, cpu_model: 'Intel i7', ram_gb: 32, disk_gb: 500, show_hardware: true,
},
})
const result = serializeNode(node)
expect(result.cpu_count).toBe(8)
expect(result.cpu_model).toBe('Intel i7')
expect(result.ram_gb).toBe(32)
expect(result.disk_gb).toBe(500)
expect(result.show_hardware).toBe(true)
})
it('serializes custom_colors', () => {
const node = makeRfNode({ data: { label: 'S', type: 'server', status: 'unknown', services: [], custom_colors: { border: '#ff0000' } } })
const result = serializeNode(node)
expect(result.custom_colors).toEqual({ border: '#ff0000' })
})
it('serializes parent_id and container_mode', () => {
const node = makeRfNode({ data: { label: 'VM', type: 'vm', status: 'unknown', services: [], parent_id: 'px1', container_mode: false } })
const result = serializeNode(node)
expect(result.parent_id).toBe('px1')
expect(result.container_mode).toBe(false)
})
it('nulls optional fields when absent', () => {
const result = serializeNode(makeRfNode())
expect(result.hostname).toBeNull()
expect(result.ip).toBeNull()
expect(result.mac).toBeNull()
expect(result.os).toBeNull()
expect(result.check_method).toBeNull()
expect(result.check_target).toBeNull()
expect(result.notes).toBeNull()
expect(result.parent_id).toBeNull()
expect(result.cpu_count).toBeNull()
expect(result.cpu_model).toBeNull()
expect(result.ram_gb).toBeNull()
expect(result.disk_gb).toBeNull()
})
})
// ── serializeNode — groupRect ─────────────────────────────────────────────────
describe('serializeNode — groupRect', () => {
it('stores dimensions inside custom_colors', () => {
const node = makeRfNode({
type: 'groupRect',
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
width: 400,
height: 250,
})
const result = serializeNode(node)
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
})
it('falls back to measured dimensions over explicit width/height', () => {
const node: Node<NodeData> = {
...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }),
measured: { width: 420, height: 260 },
}
const result = serializeNode(node)
expect((result.custom_colors as Record<string, unknown>).width).toBe(420)
expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
})
it('falls back to defaults when no dimensions available', () => {
const node = makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] } })
const result = serializeNode(node)
expect((result.custom_colors as Record<string, unknown>).width).toBe(360)
expect((result.custom_colors as Record<string, unknown>).height).toBe(240)
})
it('preserves existing custom_colors fields alongside dimensions', () => {
const node = makeRfNode({
type: 'groupRect',
data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [], custom_colors: { border: '#aaa', z_order: 2 } },
width: 300, height: 200,
})
const result = serializeNode(node)
const cc = result.custom_colors as Record<string, unknown>
expect(cc.border).toBe('#aaa')
expect(cc.z_order).toBe(2)
expect(cc.width).toBe(300)
expect(cc.height).toBe(200)
})
})
// ── serializeEdge ─────────────────────────────────────────────────────────────
describe('serializeEdge', () => {
it('serializes basic fields', () => {
const result = serializeEdge(makeRfEdge())
expect(result.id).toBe('e1')
expect(result.source).toBe('n1')
expect(result.target).toBe('n2')
expect(result.type).toBe('ethernet')
})
it('normalizes top-t handle to top', () => {
const result = serializeEdge(makeRfEdge({ sourceHandle: 'top-t', targetHandle: 'bottom-t' }))
expect(result.source_handle).toBe('top')
expect(result.target_handle).toBe('bottom')
})
it('passes through non-stub handles unchanged', () => {
const result = serializeEdge(makeRfEdge({ sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }))
expect(result.source_handle).toBe('cluster-right')
expect(result.target_handle).toBe('cluster-left')
})
it('serializes optional edge data', () => {
const edge = makeRfEdge({ data: { type: 'vlan', label: 'uplink', vlan_id: 10, custom_color: '#ff0', path_style: 'smooth', animated: true } })
const result = serializeEdge(edge)
expect(result.label).toBe('uplink')
expect(result.vlan_id).toBe(10)
expect(result.custom_color).toBe('#ff0')
expect(result.path_style).toBe('smooth')
expect(result.animated).toBe(true)
})
it('nulls optional fields when absent', () => {
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
expect(result.source_handle).toBeNull()
expect(result.target_handle).toBeNull()
expect(result.label).toBeNull()
expect(result.vlan_id).toBeNull()
expect(result.custom_color).toBeNull()
expect(result.path_style).toBeNull()
})
})
// ── deserializeApiNode — regular nodes ───────────────────────────────────────
describe('deserializeApiNode — regular node', () => {
const emptyMap = new Map<string, boolean>()
it('maps pos_x/pos_y to position', () => {
const result = deserializeApiNode(makeApiNode({ pos_x: 50, pos_y: 75 }), emptyMap)
expect(result.position).toEqual({ x: 50, y: 75 })
})
it('restores width and height when node was resized', () => {
const result = deserializeApiNode(makeApiNode({ width: 280, height: 120 }), emptyMap)
expect(result.width).toBe(280)
expect(result.height).toBe(120)
})
it('leaves width/height undefined for default-sized nodes', () => {
const result = deserializeApiNode(makeApiNode(), emptyMap)
expect(result.width).toBeUndefined()
expect(result.height).toBeUndefined()
})
it('sets parentId and extent for children of container proxmox', () => {
const map = new Map([['px1', true]])
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
expect(result.parentId).toBe('px1')
expect(result.extent).toBe('parent')
})
it('does not set parentId when parent is not in container mode', () => {
const map = new Map([['px1', false]])
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
expect(result.parentId).toBeUndefined()
})
it('sets proxmox dimensions using saved values', () => {
const result = deserializeApiNode(
makeApiNode({ type: 'proxmox', container_mode: true, width: 450, height: 300 }),
emptyMap,
)
expect(result.width).toBe(450)
expect(result.height).toBe(300)
})
it('falls back to 300x200 for proxmox container with no saved dimensions', () => {
const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: true }), emptyMap)
expect(result.width).toBe(300)
expect(result.height).toBe(200)
})
it('does not set dimensions for non-container proxmox', () => {
const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: false }), emptyMap)
expect(result.width).toBeUndefined()
expect(result.height).toBeUndefined()
})
})
// ── deserializeApiNode — groupRect ────────────────────────────────────────────
describe('deserializeApiNode — groupRect', () => {
const emptyMap = new Map<string, boolean>()
it('restores width/height from custom_colors', () => {
const result = deserializeApiNode(
makeApiNode({ type: 'groupRect', custom_colors: { width: 400, height: 250, z_order: 2 } }),
emptyMap,
)
expect(result.width).toBe(400)
expect(result.height).toBe(250)
expect(result.zIndex).toBe(-8)
})
it('defaults to 360x240 when custom_colors has no dimensions', () => {
const result = deserializeApiNode(makeApiNode({ type: 'groupRect' }), emptyMap)
expect(result.width).toBe(360)
expect(result.height).toBe(240)
})
})
// ── deserializeApiEdge ────────────────────────────────────────────────────────
describe('deserializeApiEdge', () => {
it('maps source_handle/target_handle to sourceHandle/targetHandle', () => {
const result = deserializeApiEdge(makeApiEdge({ source_handle: 'top', target_handle: 'bottom' }))
expect(result.sourceHandle).toBe('top')
expect(result.targetHandle).toBe('bottom')
})
it('sets sourceHandle/targetHandle to null when absent', () => {
const result = deserializeApiEdge(makeApiEdge())
expect(result.sourceHandle).toBeNull()
expect(result.targetHandle).toBeNull()
})
it('preserves id, source, target, type', () => {
const result = deserializeApiEdge(makeApiEdge({ id: 'e99', source: 'a', target: 'b', type: 'wifi' }))
expect(result.id).toBe('e99')
expect(result.source).toBe('a')
expect(result.target).toBe('b')
expect(result.type).toBe('wifi')
})
})
// ── Round-trip ────────────────────────────────────────────────────────────────
describe('round-trip: serialize → deserialize', () => {
const emptyMap = new Map<string, boolean>()
it('preserves position through serialize/deserialize', () => {
const node = makeRfNode({ position: { x: 123, y: 456 } })
const serialized = serializeNode(node) as ApiNode
const restored = deserializeApiNode(serialized, emptyMap)
expect(restored.position).toEqual({ x: 123, y: 456 })
})
it('preserves width/height through serialize/deserialize', () => {
const node = makeRfNode({ width: 300, height: 160 })
const serialized = serializeNode(node) as ApiNode
const restored = deserializeApiNode(serialized, emptyMap)
expect(restored.width).toBe(300)
expect(restored.height).toBe(160)
})
it('preserves null width/height for default-sized nodes', () => {
const node = makeRfNode()
const serialized = serializeNode(node) as ApiNode
const restored = deserializeApiNode(serialized, emptyMap)
expect(restored.width).toBeUndefined()
expect(restored.height).toBeUndefined()
})
it('preserves groupRect dimensions through serialize/deserialize', () => {
const node = makeRfNode({
type: 'groupRect',
data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] },
width: 500,
height: 300,
})
const serialized = serializeNode(node) as ApiNode
const restored = deserializeApiNode(serialized, emptyMap)
expect(restored.width).toBe(500)
expect(restored.height).toBe(300)
})
})
@@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest'
import { exportCanvasToYaml } from '../exportYaml'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import yaml from 'js-yaml'
const makeNode = (overrides: Partial<NodeData> = {}, id = '1', parentId?: string): Node<NodeData> => ({
id,
type: overrides.type ?? 'server',
position: { x: 0, y: 0 },
parentId,
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
})
const makeEdge = (id: string, source: string, target: string, data: Partial<EdgeData> = {}): Edge<EdgeData> => ({
id,
source,
target,
data: { type: 'ethernet', ...data } as EdgeData,
})
describe('exportCanvasToYaml', () => {
it('serializes a simple node with basic fields', () => {
const nodes = [makeNode({ label: 'My Server', type: 'server', ip: '192.168.1.10', hostname: 'srv.local' })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
expect(result).toHaveLength(1)
const entry = result[0] as Record<string, unknown>
expect(entry.nodeType).toBe('server')
expect(entry.label).toBe('My Server')
expect(entry.ipAddress).toBe('192.168.1.10')
expect(entry.hostname).toBe('srv.local')
})
it('omits empty/null/undefined optional fields', () => {
const nodes = [makeNode({ label: 'Router', type: 'router', hostname: undefined, ip: undefined, notes: undefined })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
const entry = result[0] as Record<string, unknown>
expect(entry).not.toHaveProperty('hostname')
expect(entry).not.toHaveProperty('ipAddress')
expect(entry).not.toHaveProperty('notes')
})
it('omits hardware specs when zero or falsy', () => {
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 0, ram_gb: 0, disk_gb: 0 })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
const entry = result[0] as Record<string, unknown>
expect(entry).not.toHaveProperty('cpuCore')
expect(entry).not.toHaveProperty('ram')
expect(entry).not.toHaveProperty('disk')
})
it('includes hardware specs when non-zero', () => {
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 16, ram_gb: 64, disk_gb: 2000, cpu_model: 'Intel Xeon' })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
const entry = result[0] as Record<string, unknown>
expect(entry.cpuCore).toBe(16)
expect(entry.ram).toBe(64)
expect(entry.disk).toBe(2000)
expect(entry.cpuModel).toBe('Intel Xeon')
})
it('serializes parent relationship from parentId', () => {
const parent = makeNode({ label: 'Proxmox1', type: 'proxmox' }, 'pve1')
const child = makeNode({ label: 'VM1', type: 'vm' }, 'vm1', 'pve1')
const edge = makeEdge('e1', 'pve1', 'vm1', { type: 'virtual' })
const result = yaml.load(exportCanvasToYaml([parent, child], [edge])) as object[]
const childEntry = (result as Record<string, unknown>[]).find((e) => e.label === 'VM1')!
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
})
it('serializes cluster-type edge as clusterR on source node', () => {
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster', label: '10GbE' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'PVE1')!
expect(entryA.clusterR).toEqual({ label: 'PVE2', linkType: 'cluster', linkLabel: '10GbE' })
expect(entryA).not.toHaveProperty('links')
})
it('serializes cluster-type incoming edge as clusterL on target node', () => {
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'PVE1')!
const entryB = result.find((e) => e.label === 'PVE2')!
// edge serialized as clusterR on A — should NOT also appear as clusterL on B
expect(entryA.clusterR).toBeDefined()
expect(entryB).not.toHaveProperty('clusterL')
})
it('serializes regular ethernet edge in links array on source node', () => {
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
const edge = makeEdge('e1', 'sw', 's1', { type: 'ethernet', label: 'eth0' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'Switch')!
const entryB = result.find((e) => e.label === 'Server1')!
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'ethernet', linkLabel: 'eth0' }])
expect(entryB).not.toHaveProperty('links')
expect(entryA).not.toHaveProperty('clusterR')
})
it('serializes multiple outgoing edges as links array', () => {
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
const s2 = makeNode({ label: 'Server2', type: 'server' }, 's2')
const s3 = makeNode({ label: 'Server3', type: 'server' }, 's3')
const edges = [
makeEdge('e1', 'sw', 's1', { type: 'ethernet' }),
makeEdge('e2', 'sw', 's2', { type: 'ethernet' }),
makeEdge('e3', 'sw', 's3', { type: 'wifi' }),
]
const result = yaml.load(exportCanvasToYaml([sw, s1, s2, s3], edges)) as Record<string, unknown>[]
const swEntry = result.find((e) => e.label === 'Switch')!
const links = swEntry.links as Record<string, unknown>[]
expect(links).toHaveLength(3)
expect(links.map((l) => l.label)).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3']))
// Servers should have no links (edges are on source side)
for (const label of ['Server1', 'Server2', 'Server3']) {
const entry = result.find((e) => e.label === label)!
expect(entry).not.toHaveProperty('links')
}
})
it('does not duplicate a links edge on the target node', () => {
const nodeA = makeNode({ label: 'NodeA', type: 'server' }, 'a')
const nodeB = makeNode({ label: 'NodeB', type: 'server' }, 'b')
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'NodeA')!
const entryB = result.find((e) => e.label === 'NodeB')!
expect(entryA.links).toHaveLength(1)
expect(entryB).not.toHaveProperty('links')
})
it('excludes groupRect nodes from output', () => {
const nodes = [
makeNode({ label: 'Zone', type: 'groupRect' }, '1'),
makeNode({ label: 'Server', type: 'server' }, '2'),
]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
expect(result).toHaveLength(1)
expect((result[0] as Record<string, unknown>).label).toBe('Server')
})
it('roundtrip: all non-empty fields appear in YAML output', () => {
const nodes = [makeNode({
label: 'Full Node',
type: 'server',
ip: '10.0.0.1',
hostname: 'full.local',
check_method: 'ping',
check_target: '10.0.0.1',
notes: 'test notes',
cpu_model: 'AMD EPYC',
cpu_count: 32,
ram_gb: 128,
disk_gb: 4000,
custom_icon: 'star',
})]
const yamlStr = exportCanvasToYaml(nodes, [])
expect(yamlStr).toContain('Full Node')
expect(yamlStr).toContain('10.0.0.1')
expect(yamlStr).toContain('full.local')
expect(yamlStr).toContain('ping')
expect(yamlStr).toContain('test notes')
expect(yamlStr).toContain('AMD EPYC')
expect(yamlStr).toContain('32')
expect(yamlStr).toContain('128')
expect(yamlStr).toContain('4000')
expect(yamlStr).toContain('star')
})
})
@@ -0,0 +1,256 @@
import { describe, it, expect, vi } from 'vitest'
import { parseYamlToCanvas } from '../importYaml'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
// Mock dagre layout to return nodes with predictable positions
vi.mock('../layout', () => ({
applyDagreLayout: (nodes: Node<NodeData>[]) =>
nodes.map((n, i) => ({ ...n, position: { x: i * 200, y: 0 } })),
}))
// Mock uuid to return deterministic ids
let uuidCounter = 0
vi.mock('../uuid', () => ({
generateUUID: () => `test-uuid-${++uuidCounter}`,
}))
beforeEach(() => {
uuidCounter = 0
})
const empty: Node<NodeData>[] = []
const emptyEdges: Edge<EdgeData>[] = []
describe('parseYamlToCanvas', () => {
it('parses a minimal node (only nodeType + label)', () => {
const yaml = `
- nodeType: server
label: "My Server"
`
const { nodes, edges, imported } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(imported).toBe(1)
expect(nodes).toHaveLength(1)
expect(nodes[0].data.label).toBe('My Server')
expect(nodes[0].data.type).toBe('server')
expect(nodes[0].data.status).toBe('unknown')
expect(edges).toHaveLength(0)
})
it('parses all scalar fields', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
hostname: "pve1.local"
ipAddress: "192.168.1.10"
checkMethod: ping
checkTarget: "192.168.1.10"
notes: "main host"
nodeIcon: "custom-icon"
cpuModel: "Intel Xeon"
cpuCore: 16
ram: 64
disk: 2000
`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
const d = nodes[0].data
expect(d.hostname).toBe('pve1.local')
expect(d.ip).toBe('192.168.1.10')
expect(d.check_method).toBe('ping')
expect(d.check_target).toBe('192.168.1.10')
expect(d.notes).toBe('main host')
expect(d.custom_icon).toBe('custom-icon')
expect(d.cpu_model).toBe('Intel Xeon')
expect(d.cpu_count).toBe(16)
expect(d.ram_gb).toBe(64)
expect(d.disk_gb).toBe(2000)
expect(d.show_hardware).toBe(true)
})
it('sets show_hardware only when hardware fields present', () => {
const yaml = `- nodeType: server\n label: "NoHW"\n`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes[0].data.show_hardware).toBeUndefined()
})
it('parent relationship sets parentId and creates an edge', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
- nodeType: vm
label: "VM1"
parent:
label: "PVE1"
linkType: virtual
linkLabel: "hosted"
`
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
const vm = nodes.find((n) => n.data.label === 'VM1')!
const pve = nodes.find((n) => n.data.label === 'PVE1')!
expect(vm.parentId).toBe(pve.id)
expect(vm.data.parent_id).toBe(pve.id)
expect(vm.extent).toBe('parent')
expect(edges).toHaveLength(1)
expect(edges[0].source).toBe(pve.id)
expect(edges[0].target).toBe(vm.id)
expect(edges[0].type).toBe('virtual')
expect(edges[0].data?.label).toBe('hosted')
})
it('clusterR creates an edge from this node to target', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
clusterR:
label: "PVE2"
linkType: ethernet
linkLabel: "10GbE"
- nodeType: proxmox
label: "PVE2"
`
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
expect(edges).toHaveLength(1)
expect(edges[0].source).toBe(pve1.id)
expect(edges[0].target).toBe(pve2.id)
expect(edges[0].type).toBe('ethernet')
})
it('clusterL creates an edge from referenced node to this node', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
- nodeType: proxmox
label: "PVE2"
clusterL:
label: "PVE1"
linkType: cluster
linkLabel: ""
`
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
expect(edges).toHaveLength(1)
expect(edges[0].source).toBe(pve1.id)
expect(edges[0].target).toBe(pve2.id)
})
it('links array creates multiple edges from this node', () => {
const yaml = `
- nodeType: switch
label: "Switch"
links:
- label: "Server1"
linkType: ethernet
- label: "Server2"
linkType: ethernet
- label: "Server3"
linkType: wifi
- nodeType: server
label: "Server1"
- nodeType: server
label: "Server2"
- nodeType: server
label: "Server3"
`
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
const sw = nodes.find((n) => n.data.label === 'Switch')!
expect(edges).toHaveLength(3)
expect(edges.every((e) => e.source === sw.id)).toBe(true)
const targets = edges.map((e) => nodes.find((n) => n.id === e.target)!.data.label)
expect(targets).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3']))
})
it('deduplicates edges when clusterR on A and clusterL on B point to each other', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
clusterR:
label: "PVE2"
linkType: ethernet
- nodeType: proxmox
label: "PVE2"
clusterL:
label: "PVE1"
linkType: ethernet
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
})
it('skips nodes with same label as existing canvas nodes', () => {
const existing: Node<NodeData>[] = [{
id: 'existing-1',
type: 'server',
position: { x: 0, y: 0 },
data: { label: 'ExistingServer', type: 'server', status: 'online', services: [] },
}]
const yaml = `
- nodeType: server
label: "ExistingServer"
- nodeType: router
label: "NewRouter"
`
const { nodes, imported } = parseYamlToCanvas(yaml, existing, emptyEdges)
expect(imported).toBe(1)
expect(nodes.filter((n) => n.data.label === 'ExistingServer')).toHaveLength(1)
expect(nodes.filter((n) => n.data.label === 'NewRouter')).toHaveLength(1)
})
it('merges with existing edges without duplicating', () => {
const existing: Node<NodeData>[] = [
{ id: 'a', type: 'server', position: { x: 0, y: 0 }, data: { label: 'A', type: 'server', status: 'online', services: [] } },
{ id: 'b', type: 'server', position: { x: 0, y: 0 }, data: { label: 'B', type: 'server', status: 'online', services: [] } },
]
const existingEdge: Edge<EdgeData>[] = [{
id: 'e1', source: 'a', target: 'b', type: 'ethernet',
data: { type: 'ethernet' },
}]
const yaml = `
- nodeType: server
label: "A"
clusterR:
label: "B"
linkType: ethernet
`
// A already exists so it's skipped, no new edge created
const { edges } = parseYamlToCanvas(yaml, existing, existingEdge)
expect(edges).toHaveLength(1)
})
it('throws on invalid YAML', () => {
expect(() => parseYamlToCanvas('{invalid: [yaml', empty, emptyEdges)).toThrow()
})
it('throws when YAML is not an array', () => {
const yaml = `nodeType: server\nlabel: oops\n`
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/list/)
})
it('throws when nodeType is missing', () => {
const yaml = `- label: "Missing type"\n`
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/nodeType/)
})
it('throws when label is missing', () => {
const yaml = `- nodeType: server\n`
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/label/)
})
it('warns and skips unknown parent label without crashing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const yaml = `
- nodeType: vm
label: "OrphanVM"
parent:
label: "NonexistentHost"
linkType: virtual
`
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes).toHaveLength(1)
expect(edges).toHaveLength(0)
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
warnSpy.mockRestore()
})
})
@@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => {
expect(keys).toContain('play') // Jellyfin
expect(keys).toContain('shield') // Pi-hole
expect(keys).toContain('anchor') // Portainer
expect(keys).toContain('package') // Docker Host
expect(keys).toContain('key') // Vaultwarden
expect(keys).toContain('database') // DB services
expect(keys).toContain('cctv') // IP Camera / CCTV
@@ -0,0 +1,196 @@
/**
* Standalone mode save/load tests.
*
* In standalone mode (VITE_STANDALONE=true) the canvas is persisted directly
* to localStorage as JSON — no backend involved. The full RF node object is
* serialized as-is, which means width/height survive the round-trip without
* going through serializeNode / deserializeApiNode.
*
* These tests verify that critical node properties (especially width/height
* added for resizable nodes) are not lost through the localStorage cycle, and
* that the demo data is structurally valid.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import { useCanvasStore } from '@/stores/canvasStore'
import { demoNodes, demoEdges } from '@/utils/demoData'
const STORAGE_KEY = 'homelable_canvas'
// Simulates what App.tsx does on Ctrl+S in standalone mode
function standaloneSerialize(nodes: Node<NodeData>[], edges: Edge<EdgeData>[], theme_id = 'default') {
return JSON.stringify({ nodes, edges, theme_id })
}
// Simulates what App.tsx does on load in standalone mode
function standaloneDeserialize(raw: string) {
return JSON.parse(raw) as { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; theme_id: string }
}
function makeNode(id: string, overrides: Partial<Node<NodeData>> = {}): Node<NodeData> {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'unknown', services: [] },
...overrides,
}
}
describe('Standalone localStorage save/load cycle', () => {
beforeEach(() => {
localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [], hasUnsavedChanges: false })
})
// ── width / height round-trip ─────────────────────────────────────────────
it('preserves width and height for resized nodes', () => {
const nodes = [makeNode('n1', { width: 320, height: 180 })]
const raw = standaloneSerialize(nodes, [])
localStorage.setItem(STORAGE_KEY, raw)
const { nodes: loaded } = standaloneDeserialize(localStorage.getItem(STORAGE_KEY)!)
useCanvasStore.getState().loadCanvas(loaded, [])
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(stored?.width).toBe(320)
expect(stored?.height).toBe(180)
})
it('preserves undefined width/height for default-sized nodes', () => {
const nodes = [makeNode('n1')]
const raw = standaloneSerialize(nodes, [])
const { nodes: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, [])
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(stored?.width).toBeUndefined()
expect(stored?.height).toBeUndefined()
})
it('preserves mixed: some nodes resized, others not', () => {
const nodes = [
makeNode('n1', { width: 280, height: 120 }),
makeNode('n2'),
]
const raw = standaloneSerialize(nodes, [])
const { nodes: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, [])
const { nodes: stored } = useCanvasStore.getState()
expect(stored.find((n) => n.id === 'n1')?.width).toBe(280)
expect(stored.find((n) => n.id === 'n2')?.width).toBeUndefined()
})
// ── other node properties ─────────────────────────────────────────────────
it('preserves position through the round-trip', () => {
const nodes = [makeNode('n1', { position: { x: 123, y: 456 } })]
const raw = standaloneSerialize(nodes, [])
const { nodes: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, [])
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 123, y: 456 })
})
it('preserves node data fields through the round-trip', () => {
const nodes = [makeNode('n1', {
data: {
label: 'My Router', type: 'router', status: 'online', services: [],
ip: '192.168.1.1', hostname: 'gw.local',
},
})]
const raw = standaloneSerialize(nodes, [])
const { nodes: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, [])
const stored = useCanvasStore.getState().nodes[0]
expect(stored.data.label).toBe('My Router')
expect(stored.data.ip).toBe('192.168.1.1')
expect(stored.data.hostname).toBe('gw.local')
expect(stored.data.status).toBe('online')
})
it('preserves theme_id through the round-trip', () => {
const raw = standaloneSerialize([], [], 'cyberpunk')
const { theme_id } = standaloneDeserialize(raw)
expect(theme_id).toBe('cyberpunk')
})
it('preserves edge data through the round-trip', () => {
const edges: Edge<EdgeData>[] = [{
id: 'e1', source: 'n1', target: 'n2', type: 'vlan',
data: { type: 'vlan', vlan_id: 20, label: 'VLAN 20' },
}]
const raw = standaloneSerialize([], edges)
const { edges: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas([], loaded)
const stored = useCanvasStore.getState().edges[0]
expect(stored.data?.vlan_id).toBe(20)
expect(stored.data?.label).toBe('VLAN 20')
})
// ── loadCanvas marks clean ────────────────────────────────────────────────
it('loadCanvas sets hasUnsavedChanges to false', () => {
useCanvasStore.setState({ hasUnsavedChanges: true })
const { nodes: loaded } = standaloneDeserialize(standaloneSerialize([makeNode('n1')], []))
useCanvasStore.getState().loadCanvas(loaded, [])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
})
// ── Demo data validation ──────────────────────────────────────────────────────
describe('Demo data (standalone fallback)', () => {
it('all demo nodes have required fields', () => {
for (const n of demoNodes) {
expect(n.id, `${n.id} missing id`).toBeTruthy()
expect(n.type, `${n.id} missing type`).toBeTruthy()
expect(n.position, `${n.id} missing position`).toBeDefined()
expect(n.data.label, `${n.id} missing label`).toBeTruthy()
expect(n.data.type, `${n.id} missing data.type`).toBeTruthy()
expect(n.data.status, `${n.id} missing status`).toBeTruthy()
expect(Array.isArray(n.data.services), `${n.id} services must be array`).toBe(true)
}
})
it('demo nodes have no explicit width/height — render at natural size', () => {
for (const n of demoNodes) {
expect(n.width, `${n.id} should not have explicit width`).toBeUndefined()
expect(n.height, `${n.id} should not have explicit height`).toBeUndefined()
}
})
it('all demo edges reference valid node ids', () => {
const nodeIds = new Set(demoNodes.map((n) => n.id))
for (const e of demoEdges) {
expect(nodeIds.has(e.source), `edge ${e.id} source '${e.source}' not in nodes`).toBe(true)
expect(nodeIds.has(e.target), `edge ${e.id} target '${e.target}' not in nodes`).toBe(true)
}
})
it('demo data loads into store without errors', () => {
useCanvasStore.getState().loadCanvas(demoNodes, demoEdges)
const { nodes, edges } = useCanvasStore.getState()
expect(nodes).toHaveLength(demoNodes.length)
expect(edges).toHaveLength(demoEdges.length)
})
it('demo data round-trips through standalone JSON serialization', () => {
const raw = standaloneSerialize(demoNodes, demoEdges)
const { nodes: loaded, edges: loadedEdges } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, loadedEdges)
const { nodes, edges } = useCanvasStore.getState()
expect(nodes).toHaveLength(demoNodes.length)
expect(edges).toHaveLength(demoEdges.length)
// Positions intact
const router = nodes.find((n) => n.id === 'router-1')!
expect(router.position).toEqual({ x: 300, y: 140 })
})
})
+2 -1
View File
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
@@ -84,6 +84,7 @@ describe('THEMES', () => {
expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeAccents.docker.border).toBe('#2496ED')
expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117')
+172
View File
@@ -0,0 +1,172 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
// ── Types ────────────────────────────────────────────────────────────────────
export interface ApiNode extends Record<string, unknown> {
id: string
type: string
label: string
pos_x: number
pos_y: number
status: string
services: unknown[]
hostname?: string | null
ip?: string | null
mac?: string | null
os?: string | null
check_method?: string | null
check_target?: string | null
notes?: string | null
parent_id?: string | null
container_mode?: boolean
custom_colors?: Record<string, unknown> | null
custom_icon?: string | null
cpu_count?: number | null
cpu_model?: string | null
ram_gb?: number | null
disk_gb?: number | null
show_hardware?: boolean
width?: number | null
height?: number | null
}
export interface ApiEdge {
id: string
source: string
target: string
type: string
label?: string | null
vlan_id?: number | null
speed?: string | null
custom_color?: string | null
path_style?: string | null
animated?: boolean
source_handle?: string | null
target_handle?: string | null
}
// ── Serialization (RF node → API save payload) ───────────────────────────────
export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
if (n.data.type === 'groupRect') {
return {
id: n.id,
type: 'groupRect',
label: n.data.label,
hostname: null,
ip: null,
mac: null,
os: null,
status: 'unknown',
check_method: null,
check_target: null,
services: [],
notes: null,
parent_id: null,
container_mode: false,
custom_icon: null,
pos_x: n.position.x,
pos_y: n.position.y,
custom_colors: {
...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360,
height: n.measured?.height ?? n.height ?? 240,
},
}
}
return {
id: n.id,
type: n.data.type,
label: n.data.label,
hostname: n.data.hostname ?? null,
ip: n.data.ip ?? null,
mac: n.data.mac ?? null,
os: n.data.os ?? null,
status: n.data.status,
check_method: n.data.check_method ?? null,
check_target: n.data.check_target ?? null,
services: n.data.services ?? [],
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
custom_colors: n.data.custom_colors ?? null,
custom_icon: n.data.custom_icon ?? null,
cpu_count: n.data.cpu_count ?? null,
cpu_model: n.data.cpu_model ?? null,
ram_gb: n.data.ram_gb ?? null,
disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false,
width: n.width ?? null,
height: n.height ?? null,
pos_x: n.position.x,
pos_y: n.position.y,
}
}
const normalizeHandle = (h: string | null | undefined): string | null =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
return {
id: e.id,
source: e.source,
target: e.target,
type: e.data?.type ?? 'ethernet',
label: e.data?.label ?? null,
vlan_id: e.data?.vlan_id ?? null,
speed: e.data?.speed ?? null,
custom_color: e.data?.custom_color ?? null,
path_style: e.data?.path_style ?? null,
animated: e.data?.animated ?? false,
source_handle: normalizeHandle(e.sourceHandle),
target_handle: normalizeHandle(e.targetHandle),
}
}
// ── Deserialization (API response → RF node/edge) ────────────────────────────
export function deserializeApiNode(
n: ApiNode,
proxmoxContainerMap: Map<string, boolean>,
): Node<NodeData> {
if (n.type === 'groupRect') {
const w = (n.custom_colors?.width as number | undefined) ?? 360
const h = (n.custom_colors?.height as number | undefined) ?? 240
const z = (n.custom_colors?.z_order as number | undefined) ?? 1
return {
id: n.id,
type: 'groupRect',
position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData,
width: w,
height: h,
zIndex: z - 10,
}
}
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === 'proxmox' && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
: {}),
...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}),
...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}),
}
}
export function deserializeApiEdge(e: ApiEdge): Edge<EdgeData> {
return {
id: e.id,
source: e.source,
target: e.target,
type: e.type,
sourceHandle: e.source_handle ?? null,
targetHandle: e.target_handle ?? null,
data: e as unknown as EdgeData,
}
}
+136
View File
@@ -0,0 +1,136 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, EdgeType } from '@/types'
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
import yaml from 'js-yaml'
/** Build a map of node id → label for edge resolution */
function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
const m = new Map<string, string>()
for (const n of nodes) m.set(n.id, n.data.label)
return m
}
function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection {
return {
label: targetLabel,
linkType: edgeType,
linkLabel: edgeLabel ?? '',
}
}
/**
* Serialize React Flow canvas state to a YAML string.
* Each node becomes one entry; edges are embedded as parent/clusterR/clusterL sub-objects.
* Edge deduplication: each edge is written on exactly one side (source as clusterR, target as clusterL)
* unless the edge type is 'virtual' or there is a parentId relationship, in which case
* it becomes the 'parent' field of the child node.
*/
export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData>[]): string {
const idToLabel = buildIdToLabel(nodes)
// Build per-node edge maps (id → connections)
// We use a Set to track already-serialized edge ids (deduplication).
const serializedEdges = new Set<string>()
// Index edges by source and target for quick lookup
const edgesBySource = new Map<string, Edge<EdgeData>[]>()
const edgesByTarget = new Map<string, Edge<EdgeData>[]>()
for (const e of edges) {
if (!edgesBySource.has(e.source)) edgesBySource.set(e.source, [])
edgesBySource.get(e.source)!.push(e)
if (!edgesByTarget.has(e.target)) edgesByTarget.set(e.target, [])
edgesByTarget.get(e.target)!.push(e)
}
const yamlNodes: YamlNode[] = []
for (const node of nodes) {
const d = node.data
// Skip groupRect nodes — they are canvas decoration only
if (d.type === 'groupRect') continue
const entry: YamlNode = {
nodeType: d.type,
label: d.label,
}
if (d.custom_icon) entry.nodeIcon = d.custom_icon
if (d.hostname) entry.hostname = d.hostname
if (d.ip) entry.ipAddress = d.ip
if (d.check_method && d.check_method !== 'none') entry.checkMethod = d.check_method
if (d.check_target) entry.checkTarget = d.check_target
if (d.notes) entry.notes = d.notes
// Hardware specs — omit zero values
if (d.cpu_model) entry.cpuModel = d.cpu_model
if (d.cpu_count && d.cpu_count > 0) entry.cpuCore = d.cpu_count
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
// Parent relationship: if this node has a parentId in React Flow,
// encode it as a 'parent' connection using any virtual edge between them.
if (node.parentId) {
const parentLabel = idToLabel.get(node.parentId) ?? node.parentId
// Find an edge between parent and this node (either direction)
const parentEdges = [
...(edgesBySource.get(node.parentId) ?? []).filter((e) => e.target === node.id),
...(edgesByTarget.get(node.parentId) ?? []).filter((e) => e.source === node.id),
]
const pEdge = parentEdges[0]
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
const linkLabel = pEdge?.data?.label ?? ''
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
if (pEdge) serializedEdges.add(pEdge.id)
}
// Outgoing edges (this node is the source):
// - cluster type → clusterR (Proxmox cluster link, directional)
// - everything else → links array (supports multiple connections)
const outgoingEdges = (edgesBySource.get(node.id) ?? []).filter(
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId,
)
for (const e of outgoingEdges) {
const targetLabel = idToLabel.get(e.target)
if (!targetLabel) continue
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
const edgeLabel = e.data?.label as string | undefined
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
if (edgeType === 'cluster') {
if (!entry.clusterR) entry.clusterR = conn
} else {
entry.links = [...(entry.links ?? []), conn]
}
serializedEdges.add(e.id)
}
// Incoming cluster edges not yet serialized → clusterL
const incomingClusterEdges = (edgesByTarget.get(node.id) ?? []).filter(
(e) => !serializedEdges.has(e.id) && (e.data?.type as EdgeType) === 'cluster',
)
for (const e of incomingClusterEdges) {
const sourceLabel = idToLabel.get(e.source)
if (!sourceLabel) continue
const edgeLabel = e.data?.label as string | undefined
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
serializedEdges.add(e.id)
}
yamlNodes.push(entry)
}
return yaml.dump(yamlNodes, { lineWidth: -1, noRefs: true })
}
/** Trigger a browser file download with the given YAML content */
export function downloadYaml(content: string, filename = 'homelable-export.yaml'): void {
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+170
View File
@@ -0,0 +1,170 @@
import yaml from 'js-yaml'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
import { generateUUID } from '@/utils/uuid'
import { applyDagreLayout } from '@/utils/layout'
/**
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas.
* - Nodes with the same label as an existing node are skipped (no duplicates).
* - Positions are computed via dagre auto-layout over the full merged set.
*/
export function parseYamlToCanvas(
yamlString: string,
existingNodes: Node<NodeData>[],
existingEdges: Edge<EdgeData>[],
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; imported: number } {
const raw = yaml.load(yamlString)
if (!Array.isArray(raw)) {
throw new Error('YAML must be a list of node objects (top-level array)')
}
const entries = raw as unknown[]
// Build lookup: label → existing node id (existing canvas + nodes being added)
const labelToId = new Map<string, string>()
for (const n of existingNodes) {
labelToId.set(n.data.label, n.id)
}
// First pass: validate and create nodes (without positions — dagre will assign them)
const newNodes: Node<NodeData>[] = []
const yamlNodes: YamlNode[] = []
for (const entry of entries) {
const raw = entry as Record<string, unknown>
if (!raw.nodeType || typeof raw.nodeType !== 'string') {
throw new Error(`Each YAML entry must have a "nodeType" string field`)
}
if (!raw.label || typeof raw.label !== 'string') {
throw new Error(`Each YAML entry must have a "label" string field`)
}
const yn = raw as unknown as YamlNode
// Skip if a node with this label already exists on the canvas
if (labelToId.has(yn.label)) {
console.warn(`[importYaml] Skipping duplicate label: "${yn.label}"`)
continue
}
const id = generateUUID()
labelToId.set(yn.label, id)
const hasHardware = !!(yn.cpuModel || yn.cpuCore || yn.ram || yn.disk)
const data: NodeData = {
label: yn.label,
type: yn.nodeType,
status: 'unknown',
services: [],
...(yn.hostname ? { hostname: yn.hostname } : {}),
...(yn.ipAddress ? { ip: yn.ipAddress } : {}),
...(yn.checkMethod ? { check_method: yn.checkMethod } : {}),
...(yn.checkTarget ? { check_target: yn.checkTarget } : {}),
...(yn.notes ? { notes: yn.notes } : {}),
...(yn.nodeIcon ? { custom_icon: yn.nodeIcon } : {}),
...(yn.cpuModel ? { cpu_model: yn.cpuModel } : {}),
...(yn.cpuCore ? { cpu_count: yn.cpuCore } : {}),
...(yn.ram ? { ram_gb: yn.ram } : {}),
...(yn.disk ? { disk_gb: yn.disk } : {}),
...(hasHardware ? { show_hardware: true } : {}),
}
newNodes.push({
id,
type: yn.nodeType,
position: { x: 0, y: 0 },
data,
})
yamlNodes.push(yn)
}
// Second pass: apply parent relationships (parentId / parent_id)
const newEdges: Edge<EdgeData>[] = []
// Track edge pairs to deduplicate (store as "sourceId|targetId")
const edgePairs = new Set<string>(
existingEdges.map((e) => `${e.source}|${e.target}`)
)
function addEdgeIfNew(
sourceId: string,
targetId: string,
conn: YamlNodeConnection,
) {
const key = `${sourceId}|${targetId}`
const reverseKey = `${targetId}|${sourceId}`
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
edgePairs.add(key)
const edgeType = conn.linkType ?? 'ethernet'
newEdges.push({
id: generateUUID(),
source: sourceId,
target: targetId,
type: edgeType,
data: {
type: edgeType,
...(conn.linkLabel ? { label: conn.linkLabel } : {}),
},
})
}
for (let i = 0; i < newNodes.length; i++) {
const node = newNodes[i]
const yn = yamlNodes[i]
if (yn.parent) {
const parentId = labelToId.get(yn.parent.label)
if (!parentId) {
console.warn(`[importYaml] parent label not found: "${yn.parent.label}" — skipping relationship`)
} else {
// Set React Flow parentId for nesting
node.data = { ...node.data, parent_id: parentId }
node.parentId = parentId
node.extent = 'parent'
// Also create an edge
addEdgeIfNew(parentId, node.id, yn.parent)
}
}
if (yn.links) {
for (const link of yn.links) {
const targetId = labelToId.get(link.label)
if (!targetId) {
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
} else {
addEdgeIfNew(node.id, targetId, link)
}
}
}
if (yn.clusterR) {
const targetId = labelToId.get(yn.clusterR.label)
if (!targetId) {
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
} else {
addEdgeIfNew(node.id, targetId, yn.clusterR)
}
}
if (yn.clusterL) {
const sourceId = labelToId.get(yn.clusterL.label)
if (!sourceId) {
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
} else {
addEdgeIfNew(sourceId, node.id, yn.clusterL)
}
}
}
// Merge and apply layout
const mergedNodes = [...existingNodes, ...newNodes]
const mergedEdges = [...existingEdges, ...newEdges]
const laidOut = applyDagreLayout(mergedNodes, mergedEdges)
return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length }
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
// Transfers & sync
Download, Upload, RefreshCw,
// Containers & Dev
Anchor, GitBranch, Terminal, Code2, Settings,
Anchor, Package, GitBranch, Terminal, Code2, Settings,
// Communications
Mail, MessageSquare, Phone,
// Misc devices
@@ -98,6 +98,7 @@ export const ICON_REGISTRY: IconEntry[] = [
// --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
+5
View File
@@ -56,6 +56,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
},
@@ -109,6 +110,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
},
@@ -162,6 +164,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
},
@@ -215,6 +218,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
},
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
},
+1 -1
View File
@@ -115,7 +115,7 @@ for i in $(seq 1 20); do
done
# ── Grant NET_RAW for nmap (ping-based checks) ─────────────────────────────────
echo "lxc.cap.keep = net_raw net_bind_service" >> /etc/pve/lxc/${CTID}.conf 2>/dev/null || true
echo "lxc.cap.keep = net_raw net_bind_service" >> "/etc/pve/lxc/${CTID}.conf" 2>/dev/null || true
# ── Bootstrap curl then run the installer ─────────────────────────────────────
step "Running Homelable installer inside container $CTID..."
+8 -3
View File
@@ -10,7 +10,6 @@ INSTALL_DIR=/opt/homelable
DATA_DIR=/opt/homelable/data
SERVICE_USER=homelable
REPO_URL="https://github.com/Pouzor/homelable.git"
RAW="https://raw.githubusercontent.com/Pouzor/homelable/main"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
@@ -20,7 +19,12 @@ error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
# ── Detect OS ─────────────────────────────────────────────────────────────────
[[ -f /etc/os-release ]] && . /etc/os-release || error "Cannot detect OS"
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
. /etc/os-release
else
error "Cannot detect OS"
fi
info "Detected: $PRETTY_NAME"
[[ "$ID" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
@@ -118,7 +122,8 @@ sed \
ln -sf /etc/nginx/sites-available/homelable /etc/nginx/sites-enabled/homelable
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx || systemctl start nginx
nginx -t
systemctl reload nginx || systemctl start nginx
# ── Enable & start ────────────────────────────────────────────────────────────
systemctl daemon-reload