Compare commits

..

54 Commits

Author SHA1 Message Date
Pouzor 49963c79f7 chore: bump version to 1.5.0 2026-03-29 19:35:08 +02:00
Pouzor ea539d6e31 fix: null guards, aria-labels, and missing tests for DetailPanel
- Extract const services = data.services ?? [] for consistent null safety
- Add aria-label to close and delete buttons
- Add tests: close, edit callback, delete (confirm/cancel), add service, remove service, undefined services
2026-03-29 19:28:52 +02:00
Pouzor f657e45995 fix: null guard data.services and aria-label on delete button
Fixes crash when services is undefined on legacy nodes.
Adds aria-label="Delete node" for accessibility.
2026-03-29 19:25:55 +02:00
Pouzor 3fe9fa7ca8 feat: add inline edit for services in detail panel
Replaces service badge with in-place form when pencil icon is clicked.
State is scoped to nodeId so switching nodes auto-resets edit/add forms.
2026-03-29 19:24:22 +02:00
Pouzor f0222247bb feat: add inline edit for services in detail panel 2026-03-29 19:19:29 +02:00
Pouzor 9c92d39629 fix: return 401 (not 500) when bcrypt hash is malformed (#21)
- verify_password catches ValueError from passlib so a mangled hash
  ($ signs stripped by shell/Docker) returns False instead of crashing
- Settings.check_password_hash logs a clear startup error with fix
  instructions when AUTH_PASSWORD_HASH doesn't start with '$2'
2026-03-29 16:03:40 +02:00
Pouzor e4c0d820f4 fix: render snake vs flow edge animations correctly
edges/index.tsx was never committed — both animation modes were
rendering as snake (truthy string check). Now uses animMode to
distinguish 'snake' (moving blob) from 'flow' (continuous flowing dashes).
2026-03-29 15:18:19 +02:00
Remy f9c8e37de3 Merge pull request #22 from Pouzor/feat/front-improvement
feat: Zone improvements + edge animation modes
2026-03-29 15:07:53 +02:00
Pouzor 7ed6b77165 fix: update EdgeModal tests for None/Snake/Flow animation selector 2026-03-29 15:01:20 +02:00
Pouzor 95a3db34f1 fix: move AnimMode type to module scope, fix tsc -b build error 2026-03-29 14:57:25 +02:00
Pouzor 37cb97dca1 fix: add border_width, text_size, label_position to custom_colors type 2026-03-29 14:46:23 +02:00
Pouzor 32b60a201b fix: persist edge animation mode (None/Snake/Flow) end-to-end
- canvasStore.onConnect: include animated in edge data object (was silently dropped)
- Backend schemas: normalize animated bool/int to string ('none'/'snake'/'flow') via field_validator
- ORM model: change animated column from Boolean to String
- DB migration: convert existing 0/1 boolean rows to 'none'/'snake' strings
2026-03-29 14:41:18 +02:00
Pouzor 4ccdbed711 feat: add label position (inside/outside) and text size to Zone modal
- Label position toggle: inside (default) or outside the border
- Outside mode renders the label above/below the zone based on text_position
- Text size selector: 10/12/14/16/18/20px (default 12)
- Both fields persisted in custom_colors (no backend schema change needed)
- 8 new frontend tests, 1 new backend test
2026-03-29 03:04:29 +02:00
Pouzor 38a06682e5 feat: rename Rectangle to Zone, add border width selector
- Rename "Rectangle" → "Zone" in sidebar, add modal and edit modal
- Add border width selector (1–5px, default 2px) to the Zone modal
- Border width persisted in custom_colors.border_width and applied in GroupRectNode
2026-03-29 01:47:04 +01:00
Remy 900cc62b27 Update README.md 2026-03-28 18:37:57 +01:00
Pouzor 343249fbcd fix: update login test to use http error object after network/auth error distinction 2026-03-28 18:30:50 +01:00
Pouzor 4aca82fb1a fix: remove hardcoded CORS_ORIGINS from docker-compose, improve login errors
CORS_ORIGINS was hardcoded in docker-compose.yml, silently overriding .env
and breaking login for users who change the frontend port. It now comes
from .env exclusively, with a clear comment in .env.example.

Login page now distinguishes network errors (CORS/offline) from wrong
credentials, and footer correctly references .env instead of config.yml.
2026-03-28 18:27:54 +01:00
Pouzor bd047e594e fix: restore package-lock.json — revert parseurl/tiny-invariant version corruption from sed bump 2026-03-28 18:08:04 +01:00
Pouzor 61b30a95fe chore: bump version to 1.4.0 2026-03-28 17:59:25 +01:00
Remy 0b97b7127a Merge pull request #14 from Pouzor/feat/liveview
feat: read-only live view at /view?key=<LIVEVIEW_KEY>
2026-03-28 17:53:44 +01:00
Pouzor 2ce942ae61 Update readme 2026-03-28 16:59:33 +01:00
Pouzor 5897be70c2 fix: timing-safe key comparison and network-error state in liveview
Use hmac.compare_digest() to prevent timing-based key enumeration.
Distinguish network failures from invalid-key errors in the frontend.
2026-03-28 15:30:09 +01:00
Pouzor 210304394e feat: read-only live view at /view?key=<LIVEVIEW_KEY>
Implements issue #5. Off by default; set LIVEVIEW_KEY in .env to enable.
No JWT required — key-based auth via ?key= query param.
Returns 403 when disabled or key is wrong.
Read-only ReactFlow canvas (pan/zoom, no editing).
Standalone mode loads from localStorage without a key.
Includes 8 backend tests and 9 frontend tests.
2026-03-28 15:27:54 +01:00
Pouzor b35f34ae73 fix: force frontend builder stage to native platform, fixes QEMU arm64 npm crash 2026-03-28 14:23:56 +01:00
Remy d84692fe4f Merge pull request #13 from Pouzor/feat/resizable-nodes
Feat/resizable nodes + tests
2026-03-28 14:12:23 +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
Remy 52cc5cf666 Merge pull request #10 from ki4hrg/patch-1
Add VLAN_TAG variable with conditional VLAN tag support in interface creation
2026-03-28 01:30:19 +01:00
John Fleischauer 4643aabe28 Update network configuration to include VLAN tag 2026-03-27 13:17:36 -05: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
66 changed files with 4058 additions and 580 deletions
+6
View File
@@ -1,6 +1,7 @@
# Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin
@@ -22,3 +23,8 @@ STATUS_CHECKER_INTERVAL=60
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
MCP_API_KEY=mcp_sk_changeme
MCP_SERVICE_KEY=svc_changeme
# Live view — read-only public canvas at /view?key=<value>
# Off by default. Set to a random secret to enable.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# LIVEVIEW_KEY=
+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
```
+35 -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="48%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
</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,34 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
---
## MCP Server (AI Integration)
## Live View (read-only public canvas)
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.
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
### Activation
Add LIVEVIEW_KEY to your .env:
`LIVEVIEW_KEY=your-secret-key`
Then restart the backend:
`docker compose restart backend`
### Usage
Use this URL to view your canvas:
http://<your-homelab-ip>/view?key=your-secret-key
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
---
## MCP Server (AI Integration) (optionnal)
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 +185,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
```
---
+41
View File
@@ -0,0 +1,41 @@
import hmac
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def liveview_canvas(
key: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse:
"""Read-only public canvas endpoint.
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
Always returns 403 when disabled, regardless of the key provided.
"""
if not settings.liveview_key:
raise HTTPException(status_code=403, detail="Live view is disabled")
if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
)
+20
View File
@@ -1,8 +1,12 @@
import json
import logging
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@@ -19,6 +23,17 @@ class Settings(BaseSettings):
auth_username: str = "admin"
auth_password_hash: str = ""
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
return self
# Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"]
@@ -30,6 +45,11 @@ class Settings(BaseSettings):
# Leave empty to disable MCP service key auth.
mcp_service_key: str = ""
# Live view — optional read-only public canvas endpoint.
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
# Leave unset (or empty) to keep the feature disabled (default).
liveview_key: str | None = None
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
+4 -1
View File
@@ -9,7 +9,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
return bool(pwd_context.verify(plain, hashed))
try:
return bool(pwd_context.verify(plain, hashed))
except ValueError:
return False
def hash_password(password: str) -> str:
+24 -13
View File
@@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator
from contextlib import suppress
from pathlib import Path
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
@@ -26,32 +27,42 @@ async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent)
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(Exception):
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+4 -2
View File
@@ -33,7 +33,7 @@ class Node(Base):
notes: Mapped[str | None] = mapped_column(Text)
pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
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)
@@ -42,6 +42,8 @@ class Node(Base):
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)
@@ -63,7 +65,7 @@ class Edge(Base):
speed: Mapped[str | None] = mapped_column(String)
custom_color: Mapped[str | None] = mapped_column(String)
path_style: Mapped[str | None] = mapped_column(String)
animated: Mapped[bool] = mapped_column(Boolean, default=False)
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+5 -4
View File
@@ -5,7 +5,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, nodes, scan, status
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
from app.db.database import init_db
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Homelable API",
version="1.0.0",
version="1.4.0",
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"])
@@ -40,6 +40,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
@app.get("/api/v1/health")
+10 -2
View File
@@ -1,9 +1,10 @@
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated
class NodeSave(BaseModel):
@@ -28,6 +29,8 @@ class NodeSave(BaseModel):
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
@@ -42,10 +45,15 @@ class EdgeSave(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
animated: bool = False
animated: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = []
+17 -3
View File
@@ -1,6 +1,8 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from app.schemas.utils import normalize_animated
class EdgeBase(BaseModel):
@@ -12,10 +14,15 @@ class EdgeBase(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
animated: bool = False
animated: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
class EdgeCreate(EdgeBase):
pass
@@ -28,10 +35,17 @@ class EdgeUpdate(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
animated: bool | None = None
animated: str | None = None
source_handle: str | None = None
target_handle: str | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str | None:
if v is None:
return None
return normalize_animated(v)
class EdgeResponse(EdgeBase):
id: str
+4
View File
@@ -27,6 +27,8 @@ class NodeBase(BaseModel):
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):
@@ -56,6 +58,8 @@ class NodeUpdate(BaseModel):
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):
+9
View File
@@ -0,0 +1,9 @@
def normalize_animated(v: object) -> str:
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
if v is True or v == 1 or v == '1':
return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none'
if v in ('snake', 'flow'):
return str(v)
return 'none'
+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
+12
View File
@@ -56,3 +56,15 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
settings.mcp_service_key = ""
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
assert res.status_code == 401
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
from app.core.config import settings
original = settings.auth_password_hash
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
try:
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 401
finally:
settings.auth_password_hash = original
+65
View File
@@ -104,6 +104,25 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
"""label_position and text_size are stored in custom_colors and returned unchanged."""
n1 = node_payload(custom_colors={
"border": "#00d4ff",
"border_style": "solid",
"border_width": 3,
"label_position": "outside",
"text_size": 16,
"text_color": "#e6edf3",
})
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
cc = canvas["nodes"][0]["custom_colors"]
assert cc["label_position"] == "outside"
assert cc["text_size"] == 16
assert cc["border_width"] == 3
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
@@ -191,3 +210,49 @@ async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient
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"
+126
View File
@@ -0,0 +1,126 @@
"""
Tests for the /api/v1/liveview read-only canvas endpoint.
The endpoint is:
- Disabled by default (LIVEVIEW_KEY not set) → 403
- Returns 403 for missing or wrong key even when enabled
- Returns canvas data for a valid key (no JWT required)
"""
import pytest
from httpx import AsyncClient
from app.core.config import settings
@pytest.fixture(autouse=True)
def reset_liveview_key():
"""Restore liveview_key after each test so tests are isolated."""
original = settings.liveview_key
yield
settings.liveview_key = original
# ── Disabled (no key configured) ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_by_default(client: AsyncClient):
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
@pytest.mark.asyncio
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
settings.liveview_key = ""
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
# ── Enabled but wrong / missing key ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_wrong_key(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview?key=wrong-key")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
@pytest.mark.asyncio
async def test_liveview_missing_key_param(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
# ── Valid key — no JWT needed ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
settings.liveview_key = "my-secret-key"
res = await client.get("/api/v1/liveview?key=my-secret-key")
assert res.status_code == 200
data = res.json()
assert "nodes" in data
assert "edges" in data
assert "viewport" in data
assert isinstance(data["nodes"], list)
assert isinstance(data["edges"], list)
@pytest.mark.asyncio
async def test_liveview_does_not_require_jwt(client: AsyncClient):
"""Accessing without Authorization header must work when key is correct."""
settings.liveview_key = "open-sesame"
# client has no auth headers set here
res = await client.get("/api/v1/liveview?key=open-sesame")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
"""Canvas saved via POST /canvas/save appears in liveview response."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Save a canvas with one node
payload = {
"nodes": [{
"id": "lv-node-1",
"type": "server",
"label": "Live Node",
"status": "online",
"services": [],
"pos_x": 10,
"pos_y": 20,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Liveview should return the same node
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert len(nodes) == 1
assert nodes[0]["id"] == "lv-node-1"
assert nodes[0]["label"] == "Live Node"
# ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
settings.liveview_key = "was-enabled"
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 200
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
+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
+7 -2
View File
@@ -7,9 +7,8 @@ services:
env_file:
- .env
environment:
# Override env_file values that differ in Docker
# Override env_file: SQLite path must point inside the container volume
SQLITE_PATH: /app/data/homelab.db
CORS_ORIGINS: '["http://localhost:3000"]'
volumes:
- backend_data:/app/data
networks:
@@ -17,6 +16,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

+154 -118
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "1.0.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.0.0",
"version": "1.4.0",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -42,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",
@@ -1484,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",
@@ -1534,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",
@@ -1547,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",
@@ -2840,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",
@@ -3253,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",
@@ -3347,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",
@@ -3820,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",
@@ -3873,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": {
@@ -5025,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==",
@@ -5037,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",
@@ -5055,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",
@@ -6925,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"
@@ -6989,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": {
@@ -7501,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"
@@ -7946,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",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -48,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",
+21 -110
View File
@@ -2,6 +2,7 @@ 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'
@@ -60,76 +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,
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,
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')
@@ -166,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)
@@ -281,9 +182,12 @@ export default function App() {
custom_colors: {
border: data.border_color,
border_style: data.border_style,
border_width: data.border_width,
background: data.background_color,
text_color: data.text_color,
text_position: data.text_position,
text_size: data.text_size,
label_position: data.label_position,
font: data.font,
z_order: data.z_order,
},
@@ -297,6 +201,7 @@ export default function App() {
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
if (!editingGroupRectId) return
snapshotHistory()
const existing = nodes.find((n) => n.id === editingGroupRectId)
updateNode(editingGroupRectId, {
label: data.label,
@@ -304,16 +209,19 @@ export default function App() {
...existing?.data.custom_colors,
border: data.border_color,
border_style: data.border_style,
border_width: data.border_width,
background: data.background_color,
text_color: data.text_color,
text_position: data.text_position,
text_size: data.text_size,
label_position: data.label_position,
font: data.font,
z_order: data.z_order,
},
})
setNodeZIndex(editingGroupRectId, data.z_order - 10)
setEditingGroupRectId(null)
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return
@@ -535,7 +443,7 @@ export default function App() {
open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)}
onSubmit={handleAddGroupRect}
title="Add Rectangle"
title="Add Zone"
/>
{/* key forces re-mount when editing a different rect */}
@@ -556,11 +464,14 @@ export default function App() {
text_position: rc.text_position ?? 'top-left',
border_color: rc.border ?? '#00d4ff',
border_style: rc.border_style ?? 'solid',
border_width: rc.border_width ?? 2,
background_color: rc.background ?? '#00d4ff0d',
text_size: rc.text_size ?? 12,
label_position: rc.label_position ?? 'inside',
z_order: rc.z_order ?? 1,
}
})()}
title="Edit Rectangle"
title="Edit Zone"
/>
{/* key forces re-mount on open so useState captures current theme as original */}
+7
View File
@@ -5,6 +5,9 @@ export const api = axios.create({
baseURL: '/api/v1',
})
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) config.headers.Authorization = `Bearer ${token}`
@@ -44,6 +47,10 @@ export const edgesApi = {
delete: (id: string) => api.delete(`/edges/${id}`),
}
export const liveviewApi = {
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
}
export const scanApi = {
trigger: () => api.post('/scan/trigger'),
pending: () => api.get('/scan/pending'),
+155
View File
@@ -0,0 +1,155 @@
/**
* LiveView — read-only canvas accessible at /view?key=<LIVEVIEW_KEY>.
*
* - Non-standalone: fetches canvas from /api/v1/liveview?key=... (no JWT needed).
* Returns 403 when the feature is disabled or the key is wrong.
* - Standalone: loads canvas from localStorage directly (no key required,
* since there is no backend to validate against).
*
* Pan and zoom work. Editing is fully disabled.
* Clicking a node with an IP opens http://<ip> in a new tab.
*/
import { useCallback, useEffect, useState } from 'react'
import {
ReactFlowProvider,
ReactFlow,
Background,
BackgroundVariant,
Controls,
ConnectionMode,
type Node,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { liveviewApi } from '@/api/client'
import type { NodeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() {
const { nodes, edges, loadCanvas } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required)
// - non-standalone, no ?key= → no-key error immediately
// - non-standalone, key present → loading (API call below)
const [viewState, setViewState] = useState<ViewState>(() => {
if (STANDALONE) return 'ready'
return new URLSearchParams(window.location.search).get('key') ? 'loading' : 'no-key'
})
useEffect(() => {
if (STANDALONE) {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
loadCanvas(savedNodes, savedEdges)
}
} catch {
// empty canvas on parse error — show empty canvas
}
return
}
// Already handled synchronously in useState initializer
const key = new URLSearchParams(window.location.search).get('key')
if (!key) return
liveviewApi.load(key)
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox')
.map((n: ApiNode) => [n.id, n.container_mode !== false])
)
loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
setViewState('ready')
})
.catch((err) => {
if (!err.response) { setViewState('network-error'); return }
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
}, [loadCanvas])
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
const ip = node.data.ip
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
}, [])
if (viewState === 'loading') {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
Loading
</div>
)
}
if (viewState !== 'ready') {
const messages: Record<Exclude<ViewState, 'loading' | 'ready'>, string> = {
disabled: 'Live view is disabled on this instance.',
'invalid-key': 'Invalid or expired live view key.',
'no-key': 'Missing key — use ?key=your-secret in the URL.',
'network-error': 'Could not reach the server. Check your connection.',
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
<div className="text-center space-y-2">
<p className="text-[#f85149] text-lg font-medium">Access Denied</p>
<p className="text-[#8b949e] text-sm">{messages[viewState]}</p>
</div>
</div>
)
}
return (
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
zoomOnScroll
fitView
colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick}
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color={theme.colors.canvasDotColor}
/>
<Controls showInteractive={false} />
</ReactFlow>
</div>
)
}
export default function LiveView() {
return (
<ReactFlowProvider>
<LiveViewCanvas />
</ReactFlowProvider>
)
}
+4 -3
View File
@@ -20,8 +20,9 @@ export function LoginPage() {
try {
const res = await authApi.login(username, password)
login(res.data.access_token)
} catch {
setError('Invalid username or password')
} catch (err: unknown) {
const hasResponse = err && typeof err === 'object' && 'response' in err
setError(hasResponse ? 'Invalid username or password' : 'Could not reach the server — check your CORS_ORIGINS setting')
} finally {
setLoading(false)
}
@@ -95,7 +96,7 @@ export function LoginPage() {
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">config.yml</span>
Credentials configured in <span className="font-mono">.env</span>
</p>
</div>
</div>
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
// ── Mock heavy dependencies ────────────────────────────────────────────────
vi.mock('@xyflow/react', () => ({
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />,
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
vi.mock('@/api/client', () => ({
liveviewApi: { load: vi.fn() },
}))
import { liveviewApi } from '@/api/client'
import LiveView from '../LiveView'
// ── Helpers ────────────────────────────────────────────────────────────────
function setSearch(params: string) {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search: params, pathname: '/view' },
})
}
const canvasPayload = {
data: {
nodes: [{
id: 'n1', type: 'server', label: 'CI Node', status: 'online',
services: [], pos_x: 0, pos_y: 0,
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
}],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('LiveView (non-standalone)', () => {
beforeEach(() => {
vi.mocked(liveviewApi.load).mockReset()
useCanvasStore.setState({ nodes: [], edges: [] })
})
// ── No key ────────────────────────────────────────────────────────────────
it('shows no-key error when ?key= is missing', async () => {
setSearch('')
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText('Access Denied')).toBeDefined()
expect(screen.getByText(/Missing key/)).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
})
// ── Disabled ──────────────────────────────────────────────────────────────
it('shows disabled error when backend returns "Live view is disabled"', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue({
response: { data: { detail: 'Live view is disabled' } },
})
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/disabled on this instance/)).toBeDefined()
})
})
// ── Invalid key ───────────────────────────────────────────────────────────
it('shows invalid-key error when backend returns "Invalid live view key"', async () => {
setSearch('?key=wrong')
vi.mocked(liveviewApi.load).mockRejectedValue({
response: { data: { detail: 'Invalid live view key' } },
})
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Invalid or expired/)).toBeDefined()
})
})
it('shows network-error for non-response errors (offline, CORS, 500)', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})
// ── Valid key → canvas rendered ───────────────────────────────────────────
it('renders the canvas on valid key', async () => {
setSearch('?key=correct-key')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
})
it('loads nodes into the canvas store on success', async () => {
setSearch('?key=secret')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
const { nodes } = useCanvasStore.getState()
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
})
// ── No editing props passed ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => {
setSearch('?key=valid')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
expect(screen.queryByText('Access Denied')).toBeNull()
})
})
// ── Standalone mode ────────────────────────────────────────────────────────
describe('LiveView (standalone — localStorage)', () => {
beforeEach(() => {
localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [] })
vi.mocked(liveviewApi.load).mockReset()
})
it('loads canvas from localStorage without calling the API', async () => {
const stored = {
nodes: [{
id: 'ls-node', type: 'router',
position: { x: 10, y: 20 },
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
}],
edges: [],
}
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
// Stub VITE_STANDALONE before re-importing
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') // no key needed in standalone
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('')
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
})
@@ -0,0 +1,157 @@
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({ response: { status: 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('shows a network error message when no response (e.g. CORS misconfiguration)', async () => {
vi.mocked(authApi.login).mockRejectedValue(new Error('Network Error'))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'admin' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})
it('clears previous error before each new attempt', async () => {
vi.mocked(authApi.login)
.mockRejectedValueOnce({ response: { status: 401 } })
.mockRejectedValueOnce({ response: { status: 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])
})
})
+26 -20
View File
@@ -50,42 +50,48 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
}
// Animated dot: slightly brighter + thicker than the base edge, travels source→target
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
// Normalize animated value — supports legacy boolean (true → 'snake')
const animMode: 'none' | 'snake' | 'flow' =
data?.animated === true || data?.animated === 'snake' ? 'snake' :
data?.animated === 'flow' ? 'flow' : 'none'
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
return (
<>
<BaseEdge id={id} path={edgePath} style={style} />
{data?.animated && (
{animMode === 'snake' && (
<path
d={edgePath}
fill="none"
stroke={dotColor}
strokeWidth={dotWidth}
stroke={animColor}
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
strokeDasharray="20 10000"
strokeLinecap="round"
style={{ pointerEvents: 'none' }}
>
{isBidirectional ? (
<animate
attributeName="stroke-dashoffset"
values="-10000;0;-10000"
keyTimes="0;0.5;1"
dur="20s"
repeatCount="indefinite"
/>
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
) : (
<animate
attributeName="stroke-dashoffset"
from="-10000"
to="0"
dur="10s"
repeatCount="indefinite"
/>
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
)}
</path>
)}
{animMode === 'flow' && (
<path
d={edgePath}
fill="none"
stroke={animColor}
strokeWidth={Math.max(3, (style.strokeWidth as number ?? 2) * 1.8)}
strokeDasharray="6 12"
strokeLinecap="round"
strokeOpacity={0.85}
style={{ pointerEvents: 'none' }}
>
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
</path>
)}
{data?.label && (
<EdgeLabelRenderer>
<div
@@ -1,5 +1,5 @@
import { createElement } from 'react'
import { Handle, Position, type NodeProps, type Node } from '@xyflow/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'
@@ -18,7 +18,7 @@ function formatStorage(gb: number): string {
return `${gb} GB`
}
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
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]
@@ -43,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}
@@ -69,7 +78,7 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
{/* Label + IP */}
<div className="flex flex-col min-w-0">
<div
className="text-xs font-medium leading-tight truncate max-w-[110px]"
className="text-xs font-medium leading-tight truncate"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
@@ -32,12 +32,34 @@ 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 borderWidth = rc.border_width ?? 2
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
const textColor = rc.text_color ?? '#e6edf3'
const textSize: number = rc.text_size ?? 12
const labelPosition: string = rc.label_position ?? 'inside'
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos]
const outsideJustify = textPos.includes('right') ? 'flex-end'
: (textPos.includes('center') || textPos === 'center') ? 'center'
: 'flex-start'
const isOutsideBottom = textPos.startsWith('bottom')
const outsideOffset = textSize + 16
const outsideVertical: React.CSSProperties = isOutsideBottom
? { bottom: -outsideOffset }
: { top: -outsideOffset }
const sharedTextStyle: React.CSSProperties = {
color: textColor,
fontFamily,
fontSize: textSize,
fontWeight: 500,
userSelect: 'none',
whiteSpace: 'pre-wrap',
}
return (
<>
<NodeResizer
@@ -55,6 +77,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
/>
<div
style={{
position: 'relative',
overflow: 'visible',
width: '100%',
height: '100%',
display: 'flex',
@@ -62,12 +86,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
justifyContent: posStyle.justifyContent,
padding: 12,
background: backgroundColor,
border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
borderRadius: 10,
fontFamily,
color: textColor,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box',
cursor: 'default',
}}
@@ -76,8 +96,24 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
setEditingGroupRectId(id)
}}
>
{data.label && (
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
{labelPosition === 'outside' && data.label && (
<span
style={{
position: 'absolute',
...outsideVertical,
left: 0,
right: 0,
display: 'flex',
justifyContent: outsideJustify,
pointerEvents: 'none',
...sharedTextStyle,
}}
>
{data.label}
</span>
)}
{labelPosition === 'inside' && data.label && (
<span style={{ textAlign: posStyle.textAlign, ...sharedTextStyle }}>
{data.label}
</span>
)}
+29 -16
View File
@@ -10,6 +10,14 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
return 'none'
}
interface EdgeModalProps {
open: boolean
onClose: () => void
@@ -25,7 +33,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
const [animated, setAnimated] = useState(initial?.animated ?? false)
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
@@ -37,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor,
path_style: pathStyle,
animated: animated || undefined,
animated: animation !== 'none' ? animation : undefined,
})
onClose()
}
@@ -115,20 +123,25 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Flow Animation</Label>
<button
type="button"
onClick={() => setAnimated((a) => !a)}
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
style={{ background: animated ? '#00d4ff' : '#30363d' }}
aria-pressed={animated}
>
<span
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button
key={mode}
type="button"
onClick={() => setAnimation(mode)}
className="flex-1 py-1 text-xs capitalize transition-colors"
style={{
background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e',
borderRight: i < 2 ? '1px solid #30363d' : undefined,
}}
>
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
@@ -8,13 +8,18 @@ import type { TextPosition } from '@/types'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
export type LabelPosition = 'inside' | 'outside'
export interface GroupRectFormData {
label: string
font: string
text_color: string
text_position: TextPosition
text_size: number
label_position: LabelPosition
border_color: string
border_style: BorderStyle
border_width: number
background_color: string
z_order: number
}
@@ -27,13 +32,38 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] =
{ value: 'none', label: 'None', preview: ' ' },
]
const TEXT_SIZES: { value: number; label: string }[] = [
{ value: 10, label: '10' },
{ value: 12, label: '12' },
{ value: 14, label: '14' },
{ value: 16, label: '16' },
{ value: 18, label: '18' },
{ value: 20, label: '20' },
]
const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [
{ value: 'inside', label: 'Inside' },
{ value: 'outside', label: 'Outside' },
]
const BORDER_WIDTHS: { value: number; label: string }[] = [
{ value: 1, label: '1px' },
{ value: 2, label: '2px' },
{ value: 3, label: '3px' },
{ value: 4, label: '4px' },
{ value: 5, label: '5px' },
]
const DEFAULT_FORM: GroupRectFormData = {
label: '',
font: 'inter',
text_color: '#e6edf3',
text_position: 'top-left',
text_size: 12,
label_position: 'inside',
border_color: '#00d4ff',
border_style: 'solid',
border_width: 2,
background_color: '#00d4ff0d',
z_order: 1,
}
@@ -65,7 +95,7 @@ interface GroupRectModalProps {
title?: string
}
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) {
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
@@ -145,6 +175,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div>
</div>
{/* Label position */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label Position</Label>
<div className="grid grid-cols-2 gap-1">
{LABEL_POSITIONS.map(({ value, label }) => {
const isSelected = form.label_position === value
return (
<button
key={value}
type="button"
onClick={() => set('label_position', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Colors */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
@@ -169,6 +224,32 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div>
</div>
{/* Text size */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Size</Label>
<div className="grid grid-cols-6 gap-1">
{TEXT_SIZES.map(({ value, label }) => {
const isSelected = form.text_size === value
return (
<button
key={value}
type="button"
onClick={() => set('text_size', value)}
className="flex items-center justify-center h-8 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
fontSize: value,
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Border style */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label>
@@ -196,6 +277,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div>
</div>
{/* Border width */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Width</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_WIDTHS.map(({ value, label }) => {
const isSelected = form.border_width === value
return (
<button
key={value}
type="button"
onClick={() => set('border_width', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</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>
@@ -230,7 +336,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
Cancel
</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
{title === 'Add Rectangle' ? 'Add' : 'Save'}
{title === 'Add Zone' ? 'Add' : 'Save'}
</Button>
</div>
</div>
@@ -0,0 +1,190 @@
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')
})
// ── Animation select ──────────────────────────────────────────────────────
it('animation defaults to None — animated omitted from payload', () => {
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].animated).toBeUndefined()
})
it('selecting Snake sends animated: "snake"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Snake'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
it('selecting Flow sends animated: "flow"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Flow'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
})
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Snake'))
fireEvent.click(screen.getByText('None'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
})
it('pre-fills animation from initial "snake" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'snake' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
it('pre-fills animation from legacy initial true (backward compat)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: true }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
// ── 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()
})
})
@@ -13,14 +13,15 @@ describe('GroupRectModal', () => {
it('renders form fields when open', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
expect(screen.getByText('Add Rectangle')).toBeDefined()
expect(screen.getByText('Add Zone')).toBeDefined()
expect(screen.getByText('Text Position')).toBeDefined()
expect(screen.getByText('Border Width')).toBeDefined()
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
})
it('renders Edit Rectangle title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
expect(screen.getByText('Edit Rectangle')).toBeDefined()
it('renders Edit Zone title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
expect(screen.getByText('Edit Zone')).toBeDefined()
})
it('calls onSubmit with form data on submit', () => {
@@ -123,6 +124,124 @@ describe('GroupRectModal', () => {
expect(submitted.border_style).toBe('dotted')
})
it('renders Label Position section with inside/outside options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Label Position')).toBeDefined()
expect(screen.getByText('Inside')).toBeDefined()
expect(screen.getByText('Outside')).toBeDefined()
})
it('defaults label_position to inside', () => {
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.label_position).toBe('inside')
})
it('selects outside label position on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Outside'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('outside')
})
it('pre-fills label_position from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ label_position: 'outside' }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('outside')
})
it('renders Text Size section with 6 options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Text Size')).toBeDefined()
expect(screen.getByText('10')).toBeDefined()
expect(screen.getByText('20')).toBeDefined()
})
it('defaults text_size to 12', () => {
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.text_size).toBe(12)
})
it('selects text size on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('18'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(18)
})
it('pre-fills text_size from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ text_size: 16 }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(16)
})
it('renders Border Width section with 5 options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Border Width')).toBeDefined()
expect(screen.getByText('1px')).toBeDefined()
expect(screen.getByText('3px')).toBeDefined()
expect(screen.getByText('5px')).toBeDefined()
})
it('defaults border_width to 2', () => {
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_width).toBe(2)
})
it('selects border width on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('4px'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(4)
})
it('pre-fills border_width from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ border_width: 5 }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(5)
})
it('toggles border style — clicking selected style deselects back to solid', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -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)
})
})
+153 -70
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus } from 'lucide-react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -10,20 +10,26 @@ interface DetailPanelProps {
onEdit: (id: string) => void
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId)
const [addingService, setAddingService] = useState(false)
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
port: '',
protocol: 'tcp',
service_name: '',
})
const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
if (!node || node.data.type === 'groupRect') return null
const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const { data } = node
const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status]
const host = data.ip ?? data.hostname
@@ -41,14 +47,36 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
}
updateNode(node.id, { services: [...(data.services ?? []), svc] })
setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
setAddingService(false)
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
}
const handleRemoveService = (index: number) => {
const updated = data.services.filter((_, i) => i !== index)
const updated = services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null)
}
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const updated = services.map((svc, i) =>
i === editingIndex
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
: svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
}
return (
@@ -57,6 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button
aria-label="Close panel"
onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
@@ -115,10 +144,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">
Services{data.services.length > 0 ? ` (${data.services.length})` : ''}
Services{services.length > 0 ? ` (${services.length})` : ''}
</span>
<button
onClick={() => setAddingService((v) => !v)}
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
<Plus size={10} /> Add
@@ -127,67 +156,43 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{/* Add service form */}
{addingService && (
<div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={newSvc.service_name}
onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
placeholder="Service name"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex gap-1.5">
<Input
type="number"
value={newSvc.port}
onChange={(e) => setNewSvc((s) => ({ ...s, port: e.target.value }))}
placeholder="Port"
min={1}
max={65535}
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
/>
<select
value={newSvc.protocol}
onChange={(e) => setNewSvc((s) => ({ ...s, protocol: e.target.value as 'tcp' | 'udp' }))}
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={handleAddService}
>
Add
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={() => setAddingService(false)}
>
Cancel
</Button>
</div>
</div>
<ServiceForm
form={newSvc}
onChange={setNewSvc}
onConfirm={handleAddService}
onCancel={() => setAddingForNode(null)}
confirmLabel="Add"
autoFocus
/>
)}
{data.services.length > 0 && (
{services.length > 0 && (
<div className="flex flex-col gap-1.5">
{data.services.map((svc, i) => (
<ServiceBadge
key={`${svc.port}-${svc.protocol}-${i}`}
svc={svc}
host={host}
onRemove={() => handleRemoveService(i)}
/>
))}
{services.map((svc, i) =>
editingIndex === i ? (
<ServiceForm
key={`edit-${i}`}
form={editSvc}
onChange={setEditSvc}
onConfirm={handleSaveEdit}
onCancel={() => setEditingFor(null)}
confirmLabel="Save"
autoFocus
/>
) : (
<ServiceBadge
key={`${svc.port}-${svc.protocol}-${i}`}
svc={svc}
host={host}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
)
)}
</div>
)}
{data.services.length === 0 && !addingService && (
{services.length === 0 && !addingService && (
<p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)}
</div>
@@ -205,7 +210,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit
</Button>
<Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDelete}>
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
<Trash2 size={14} />
</Button>
</div>
@@ -232,6 +237,67 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
)
}
function ServiceForm({
form,
onChange,
onConfirm,
onCancel,
confirmLabel,
autoFocus,
}: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.service_name}
onChange={(e) => onChange({ ...form, service_name: e.target.value })}
placeholder="Service name"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus={autoFocus}
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<div className="flex gap-1.5">
<Input
type="number"
value={form.port}
onChange={(e) => onChange({ ...form, port: e.target.value })}
placeholder="Port"
min={1}
max={65535}
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select
value={form.protocol}
onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })}
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onConfirm}
>
{confirmLabel}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
)
}
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff',
database: '#a855f7',
@@ -241,7 +307,17 @@ const CATEGORY_COLORS: Record<string, string> = {
remote: '#8b949e',
}
function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
function ServiceBadge({
svc,
host,
onEdit,
onRemove,
}: {
svc: ServiceInfo
host?: string
onEdit: () => void
onRemove: () => void
}) {
const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
@@ -261,6 +337,13 @@ function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string
<div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
title="Edit service"
>
<Pencil size={10} />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
+2 -2
View File
@@ -124,7 +124,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={Square} label="Add Rectangle" collapsed={collapsed} onClick={onAddGroupRect} />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
<SidebarItem
icon={hideIp ? EyeOff : Eye}
@@ -303,7 +303,7 @@ function HiddenDevicesPanel() {
}
}, [])
useState(() => { load() })
useEffect(() => { load() }, [load])
const handleIgnore = async (id: string) => {
try {
+4 -4
View File
@@ -72,15 +72,15 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
className="hidden"
onChange={handleFileChange}
/>
<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={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>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
<FileDown size={14} /> YAML
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<HelpCircle size={14} />
</Button>
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types'
@@ -115,4 +115,170 @@ describe('DetailPanel', () => {
expect(screen.getByText('4 TB')).toBeDefined()
})
})
describe('Panel actions', () => {
it('calls setSelectedNode(null) when close button is clicked', () => {
const setSelectedNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
setSelectedNode,
deleteNode: vi.fn(),
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Close panel'))
expect(setSelectedNode).toHaveBeenCalledWith(null)
})
it('calls onEdit with node id when Edit button is clicked', () => {
setupStore({})
const onEdit = vi.fn()
render(<DetailPanel onEdit={onEdit} />)
fireEvent.click(screen.getByRole('button', { name: /edit/i }))
expect(onEdit).toHaveBeenCalledWith('n1')
})
it('calls deleteNode when delete confirmed', () => {
const deleteNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ label: 'My Server' })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode,
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Delete node'))
expect(deleteNode).toHaveBeenCalledWith('n1')
})
it('does not call deleteNode when delete is cancelled', () => {
const deleteNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode,
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Delete node'))
expect(deleteNode).not.toHaveBeenCalled()
})
})
describe('Services — add/remove', () => {
it('shows add form when Add is clicked', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
})
it('calls updateNode with new service on Add confirm', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
const addButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(addButtons[addButtons.length - 1])
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
})
it('calls updateNode without the removed service when X is clicked', () => {
const updateNode = vi.fn()
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Remove service'))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services).toHaveLength(0)
})
it('does not crash when data.services is undefined', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: undefined as unknown as [] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
})
})
describe('Services — edit', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [svc] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
fireEvent.click(editBtn)
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80')
})
it('calls updateNode with updated values on Save', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Edit service'))
const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
})
it('cancels edit without updating', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Edit service'))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(updateNode).not.toHaveBeenCalled()
expect(screen.getByText('nginx')).toBeDefined()
})
})
})
+4 -1
View File
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import LiveView from './components/LiveView.tsx'
const isLiveView = window.location.pathname === '/view'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
{isLiveView ? <LiveView /> : <App />}
</StrictMode>,
)
@@ -98,18 +98,32 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
it('onNodesChange marks unsaved', () => {
it('onNodesChange marks unsaved for position changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }])
useCanvasStore.getState().onNodesChange([{ type: 'position', id: 'n1', dragging: false }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('onEdgesChange marks unsaved', () => {
it('onNodesChange does not mark unsaved for select-only changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('onEdgesChange marks unsaved for remove changes', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onEdgesChange([{ type: 'remove', id: 'e1' }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('onEdgesChange does not mark unsaved for select-only changes', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('onConnect adds an edge between two nodes', () => {
@@ -130,6 +144,13 @@ describe('canvasStore', () => {
expect(edges[0].data?.label).toBe('uplink')
})
it('onConnect preserves animated from edge data', () => {
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', animated: 'snake' })
useCanvasStore.getState().onConnect(conn)
const { edges } = useCanvasStore.getState()
expect(edges[0].data?.animated).toBe('snake')
})
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
useCanvasStore.getState().onConnect(conn)
@@ -140,6 +161,15 @@ describe('canvasStore', () => {
expect(edges[0].type).toBe('cluster')
})
it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState()
expect(nodes.find((n) => n.id === 'parent')).toBeUndefined()
expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
})
it('addNode with parent_id sets parentId and extent', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
@@ -328,4 +358,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()
})
})
+23 -12
View File
@@ -127,13 +127,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
onNodesChange: (changes) =>
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
hasUnsavedChanges: true,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
onEdgesChange: (changes) =>
set((state) => ({
edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: true,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
onConnect: (connection) =>
@@ -150,7 +150,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
sourceHandle: normalizeHandle(extra.sourceHandle),
targetHandle: normalizeHandle(extra.targetHandle),
type: edgeType,
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style },
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated },
}, state.edges),
hasUnsavedChanges: true,
}
@@ -163,10 +163,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
const enriched = node.data.parent_id
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
: node
// Parents must come before children in the array
// Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
if (enriched.parentId) {
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId)
const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length
const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)]
return { nodes, hasUnsavedChanges: true }
}
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
}),
@@ -180,12 +183,20 @@ export const useCanvasStore = create<CanvasState>((set) => ({
})),
deleteNode: (id) =>
set((state) => ({
nodes: state.nodes.filter((n) => n.id !== id),
edges: state.edges.filter((e) => e.source !== id && e.target !== id),
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
hasUnsavedChanges: true,
})),
set((state) => {
const idsToRemove = new Set<string>()
const collect = (nodeId: string) => {
idsToRemove.add(nodeId)
state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
}
collect(id)
return {
nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId,
hasUnsavedChanges: true,
}
}),
updateEdge: (id, data) =>
set((state) => ({
@@ -245,6 +256,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
// React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null })
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] })
},
}))
+4 -1
View File
@@ -72,6 +72,9 @@ export interface NodeData extends Record<string, unknown> {
text_position?: TextPosition
font?: string
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
border_width?: number
label_position?: 'inside' | 'outside'
text_size?: number
z_order?: number
width?: number
height?: number
@@ -88,7 +91,7 @@ export interface EdgeData extends Record<string, unknown> {
speed?: string
custom_color?: string
path_style?: EdgePathStyle
animated?: boolean
animated?: boolean | 'snake' | 'flow' | 'none'
}
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
+1
View File
@@ -15,6 +15,7 @@ export interface YamlNode {
checkMethod?: CheckMethod
checkTarget?: string
notes?: string
links?: YamlNodeConnection[]
parent?: YamlNodeConnection
clusterR?: YamlNodeConnection
clusterL?: YamlNodeConnection
@@ -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)
})
})
+58 -13
View File
@@ -68,26 +68,71 @@ describe('exportCanvasToYaml', () => {
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
})
it('serializes clusterR edge on source node', () => {
const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a')
const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b')
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet', label: '10GbE' })
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 === 'NodeA')!
expect(entryA.clusterR).toEqual({ label: 'NodeB', linkType: 'ethernet', linkLabel: '10GbE' })
const entryA = result.find((e) => e.label === 'PVE1')!
expect(entryA.clusterR).toEqual({ label: 'PVE2', linkType: 'cluster', linkLabel: '10GbE' })
expect(entryA).not.toHaveProperty('links')
})
it('does not duplicate an edge as both clusterR and clusterL', () => {
const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a')
const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b')
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')!
// clusterR on A and clusterL on B would duplicate — only one side should have it
const hasClusterR = 'clusterR' in entryA
const hasClusterL = 'clusterL' in entryB
expect(hasClusterR && hasClusterL).toBe(false)
expect(entryA.links).toHaveLength(1)
expect(entryB).not.toHaveProperty('links')
})
it('excludes groupRect nodes from output', () => {
@@ -136,6 +136,32 @@ describe('parseYamlToCanvas', () => {
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
@@ -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 })
})
})
+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 | 'snake' | 'flow' | 'none'
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,
}
}
+16 -16
View File
@@ -84,35 +84,35 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
if (pEdge) serializedEdges.add(pEdge.id)
}
// Non-parent edges: serialize as clusterR (source side) or clusterL (target side).
// We process source edges as clusterR on this node; target edges as clusterL on this node,
// but only if the edge hasn't been serialized yet (deduplication: source wins).
const sourceEdgesForNode = (edgesBySource.get(node.id) ?? []).filter(
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId && e.source !== node.parentId,
// 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 sourceEdgesForNode) {
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
if (!entry.clusterR) {
entry.clusterR = makeConnection(targetLabel, edgeType, edgeLabel)
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
if (edgeType === 'cluster') {
if (!entry.clusterR) entry.clusterR = conn
} else {
entry.links = [...(entry.links ?? []), conn]
}
// Only first clusterR wins per node; mark all source edges as serialized
serializedEdges.add(e.id)
}
const targetEdgesForNode = (edgesByTarget.get(node.id) ?? []).filter(
(e) => !serializedEdges.has(e.id) && e.source !== node.parentId && e.target !== node.parentId,
// 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 targetEdgesForNode) {
for (const e of incomingClusterEdges) {
const sourceLabel = idToLabel.get(e.source)
if (!sourceLabel) continue
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
const edgeLabel = e.data?.label as string | undefined
if (!entry.clusterL) {
entry.clusterL = makeConnection(sourceLabel, edgeType, edgeLabel)
}
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
serializedEdges.add(e.id)
}
+11
View File
@@ -131,6 +131,17 @@ export function parseYamlToCanvas(
}
}
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) {
+2 -2
View File
@@ -85,7 +85,7 @@ pct create "$CTID" "$TEMPLATE" \
--rootfs "${STORAGE}:${DISK_SIZE}" \
--memory "$RAM" \
--cores "$CORES" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
--ostype debian \
--unprivileged 1 \
--features "nesting=1" \
@@ -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