Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49963c79f7 | |||
| ea539d6e31 | |||
| f657e45995 | |||
| 3fe9fa7ca8 | |||
| f0222247bb | |||
| 9c92d39629 | |||
| e4c0d820f4 | |||
| f9c8e37de3 | |||
| 7ed6b77165 | |||
| 95a3db34f1 | |||
| 37cb97dca1 | |||
| 32b60a201b | |||
| 4ccdbed711 | |||
| 38a06682e5 | |||
| 900cc62b27 | |||
| 343249fbcd | |||
| 4aca82fb1a | |||
| bd047e594e | |||
| 61b30a95fe | |||
| 0b97b7127a | |||
| 2ce942ae61 | |||
| 5897be70c2 | |||
| 210304394e | |||
| b35f34ae73 | |||
| d84692fe4f | |||
| d9f3477780 | |||
| c9d6642b26 | |||
| c9e142dbf2 | |||
| 57829d88e5 | |||
| fd8735ce7f | |||
| 0bdf835a3d | |||
| ae29d2c8f5 | |||
| cc68fcf1c1 | |||
| 4cb164241a | |||
| b35b51d5b2 | |||
| 565f4337c8 | |||
| 2a9cbc5932 | |||
| 52cc5cf666 | |||
| 4643aabe28 | |||
| 09b5317a0c | |||
| 0f643477f6 | |||
| 861d2822b9 | |||
| 059bb3daa7 | |||
| c01d87381d | |||
| ec0519d2b7 | |||
| 1182dbd82d | |||
| 821e324111 | |||
| ea3adc0f94 | |||
| 6f8f0d5e8f | |||
| daf3f59590 | |||
| 212eb37e34 | |||
| 61b8a210fe | |||
| a43ffb813e | |||
| f469d6c744 | |||
| e7ab9a1d7a | |||
| d5b67a770c | |||
| 2e49c14028 | |||
| d9787fdcbb | |||
| 06ec18a137 | |||
| adb4474687 | |||
| 2008f9467a | |||
| d9ac9462a8 | |||
| e14a9e87aa | |||
| e5d7260696 | |||
| df3b7a8cb0 | |||
| 426af29180 | |||
| f36bdfe878 | |||
| 300567c88d | |||
| e41dbe579c | |||
| e1d16b86e3 | |||
| 593335648f | |||
| e3f8c27a04 | |||
| ff69856d31 | |||
| 7935b671d3 | |||
| f8cadba17b | |||
| 5f7cb1bf11 | |||
| 3fb3bf016b | |||
| ba032a45af | |||
| 68b35a0c30 | |||
| 41cfccbd37 | |||
| 55a842cdad | |||
| 7074c5387b | |||
| 15f210470a | |||
| 271bbf2d01 | |||
| 8affcda09d | |||
| 92d505f78c | |||
| 16de7cd390 | |||
| 0fb091b12c | |||
| 1f1dfd807b | |||
| 1e72366d03 |
@@ -1,6 +1,7 @@
|
|||||||
# Backend - server-side only (NEVER commit .env)
|
# Backend - server-side only (NEVER commit .env)
|
||||||
SECRET_KEY=change_me_in_production
|
SECRET_KEY=change_me_in_production
|
||||||
SQLITE_PATH=./data/homelab.db
|
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"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
|
||||||
# Auth — default credentials: admin / admin
|
# Auth — default credentials: admin / admin
|
||||||
@@ -15,3 +16,15 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
|||||||
|
|
||||||
# Status checker interval in seconds
|
# Status checker interval in seconds
|
||||||
STATUS_CHECKER_INTERVAL=60
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
|
||||||
|
# MCP server — used by the mcp service (port 8001)
|
||||||
|
# MCP_API_KEY: authenticates AI clients (Claude Code, etc.) → MCP server
|
||||||
|
# MCP_SERVICE_KEY: authenticates MCP server → backend (never exposed externally)
|
||||||
|
# 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=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,6 +7,25 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
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:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ htmlcov/
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
Ideas.md
|
||||||
|
|||||||
@@ -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
@@ -2,8 +2,8 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install nmap for network scanning
|
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
# Stage 1: build
|
# 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
|
ARG VITE_STANDALONE=false
|
||||||
ENV VITE_STANDALONE=$VITE_STANDALONE
|
ENV VITE_STANDALONE=$VITE_STANDALONE
|
||||||
|
|||||||
+120
@@ -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
|
||||||
|
```
|
||||||
@@ -4,95 +4,27 @@ Homelable is a self-hosted infrastructure visualization solution. It provides a
|
|||||||
|
|
||||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
|
|
||||||
|
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Screenshots
|
||||||
|
|
||||||
```bash
|
<p align="center">
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
cd homelable && docker compose up -d
|
<img src="docs/homelable2.png" alt="Homelable node detail" 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%" />
|
||||||
Open **http://localhost:3000** — login with `admin` / `admin`.
|
</p>
|
||||||
|
|
||||||
> 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
|
## Installation
|
||||||
|
|
||||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||||
|
|
||||||
```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)
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,7 +34,8 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
|
|||||||
|
|
||||||
### Triggering a scan
|
### 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
|
### macOS / root privileges
|
||||||
|
|
||||||
@@ -125,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
|
## 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 |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `ping` | ICMP ping |
|
| `ping` | ICMP ping |
|
||||||
@@ -150,23 +74,115 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Mode
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
**Backend (Python 3.13):**
|
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.
|
||||||
```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:**
|
### Activation
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| | Action |
|
||||||
|
|---|---|
|
||||||
|
| **Read** | List all nodes, edges, full canvas, pending devices, scan history |
|
||||||
|
| **Write** | Add / update / delete nodes and edges, trigger a network scan, approve or hide discovered devices |
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
**1. Add the keys to your `.env`:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Authenticates AI clients (Claude Code, etc.) → MCP server
|
||||||
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
|
|
||||||
|
# Authenticates MCP server → backend (internal Docker network only, never exposed)
|
||||||
|
MCP_SERVICE_KEY=svc_changeme
|
||||||
|
|
||||||
|
# Generate both with:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
No plain-text passwords involved — `AUTH_PASSWORD_HASH` is only used for the web UI login.
|
||||||
|
|
||||||
|
**2. Start the MCP service:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d mcp
|
||||||
|
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Configure your AI client:**
|
||||||
|
|
||||||
|
**Claude Code** — run this command in your terminal:
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
|
||||||
|
--header "X-API-Key: mcp_sk_yourkey"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add it manually to `~/.claude.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"homelable": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"homelable": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example prompts
|
||||||
|
|
||||||
|
- *"What nodes are currently offline?"*
|
||||||
|
- *"Add a new LXC container named `pihole` at 192.168.1.5, connected to my switch."*
|
||||||
|
- *"Trigger a network scan on 192.168.1.0/24 and show me the pending devices."*
|
||||||
|
- *"Show me the full canvas topology."*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The MCP server is **not** intended to be exposed to the internet — keep port 8001 firewalled to your LAN.
|
||||||
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+21
-3
@@ -1,12 +1,30 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
|
|
||||||
bearer = HTTPBearer()
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
x_mcp_service_key: str | None = Header(default=None),
|
||||||
|
) -> str:
|
||||||
|
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||||
|
if x_mcp_service_key is not None:
|
||||||
|
if not settings.mcp_service_key:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||||
|
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||||
|
return "__mcp_service__"
|
||||||
|
|
||||||
|
# 2. Standard JWT bearer token
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
username = decode_token(credentials.credentials)
|
username = decode_token(credentials.credentials)
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
|
|||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
if not token or not decode_token(token):
|
# Accept first so we can send a close frame with a reason code
|
||||||
await websocket.close(code=1008) # Policy Violation
|
|
||||||
return
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
token = payload.get("token", "")
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
token = ""
|
||||||
|
if not token or not decode_token(token):
|
||||||
|
await websocket.close(code=1008) # Policy Violation
|
||||||
|
return
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
_connections.append(websocket)
|
_connections.append(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
@@ -19,12 +23,33 @@ class Settings(BaseSettings):
|
|||||||
auth_username: str = "admin"
|
auth_username: str = "admin"
|
||||||
auth_password_hash: str = ""
|
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
|
||||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||||
|
|
||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
|
# 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:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
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:
|
def hash_password(password: str) -> str:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
@@ -26,20 +27,42 @@ async def init_db() -> None:
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Add columns introduced after initial schema (idempotent)
|
# 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")
|
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")
|
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")
|
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")
|
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")
|
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")
|
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")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||||
|
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]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -33,10 +33,17 @@ class Node(Base):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text)
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||||
pos_y: 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)
|
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
@@ -58,6 +65,7 @@ class Edge(Base):
|
|||||||
speed: Mapped[str | None] = mapped_column(String)
|
speed: Mapped[str | None] = mapped_column(String)
|
||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: Mapped[str | None] = mapped_column(String)
|
path_style: Mapped[str | None] = mapped_column(String)
|
||||||
|
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
+5
-4
@@ -5,7 +5,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.0.0",
|
version="1.4.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
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(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
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(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
|
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
from app.schemas.nodes import NodeResponse
|
||||||
|
from app.schemas.utils import normalize_animated
|
||||||
|
|
||||||
|
|
||||||
class NodeSave(BaseModel):
|
class NodeSave(BaseModel):
|
||||||
@@ -23,6 +24,13 @@ class NodeSave(BaseModel):
|
|||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
cpu_count: int | None = None
|
||||||
|
cpu_model: str | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
show_hardware: bool = False
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
@@ -37,9 +45,15 @@ class EdgeSave(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_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):
|
class CanvasSaveRequest(BaseModel):
|
||||||
nodes: list[NodeSave] = []
|
nodes: list[NodeSave] = []
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
from app.schemas.utils import normalize_animated
|
||||||
|
|
||||||
|
|
||||||
class EdgeBase(BaseModel):
|
class EdgeBase(BaseModel):
|
||||||
@@ -12,9 +14,15 @@ class EdgeBase(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_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):
|
class EdgeCreate(EdgeBase):
|
||||||
pass
|
pass
|
||||||
@@ -27,9 +35,17 @@ class EdgeUpdate(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: str | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_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):
|
class EdgeResponse(EdgeBase):
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ class NodeBase(BaseModel):
|
|||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
cpu_count: int | None = None
|
||||||
|
cpu_model: str | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
show_hardware: bool = False
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -42,9 +49,17 @@ class NodeUpdate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
pos_x: float | None = None
|
pos_x: float | None = None
|
||||||
pos_y: float | None = None
|
pos_y: float | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
container_mode: bool | None = None
|
container_mode: bool | None = None
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
cpu_count: int | None = None
|
||||||
|
cpu_model: str | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
show_hardware: bool | None = None
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
"""Match nmap scan results against service_signatures.json."""
|
"""Match nmap scan results against service_signatures.json."""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SIGNATURES: list[dict[str, Any]] | None = None
|
_SIGNATURES: list[dict[str, Any]] | None = None
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _load() -> list[dict[str, Any]]:
|
def _load() -> list[dict[str, Any]]:
|
||||||
global _SIGNATURES
|
global _SIGNATURES
|
||||||
if _SIGNATURES is None:
|
if _SIGNATURES is None:
|
||||||
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json"
|
with _LOCK:
|
||||||
with open(path) as f:
|
if _SIGNATURES is None:
|
||||||
_SIGNATURES = json.load(f)
|
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
|
return _SIGNATURES
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@@ -28,3 +29,42 @@ async def test_health_is_public(client: AsyncClient):
|
|||||||
res = await client.get("/api/v1/health")
|
res = await client.get("/api/v1/health")
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json() == {"status": "ok"}
|
assert res.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP service key auth ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def with_service_key():
|
||||||
|
from app.core.config import settings
|
||||||
|
settings.mcp_service_key = "test-service-key"
|
||||||
|
yield "test-service-key"
|
||||||
|
settings.mcp_service_key = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_key_grants_access(client: AsyncClient, with_service_key):
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": with_service_key})
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_key_wrong_value(client: AsyncClient, with_service_key):
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "wrong-key"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
||||||
|
from app.core.config import settings
|
||||||
|
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
|
||||||
|
|||||||
@@ -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"}
|
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):
|
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload()
|
n1 = node_payload()
|
||||||
n2 = node_payload()
|
n2 = node_payload()
|
||||||
@@ -138,3 +157,102 @@ async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, he
|
|||||||
async def test_save_canvas_requires_auth(client: AsyncClient):
|
async def test_save_canvas_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["cpu_count"] == 8
|
||||||
|
assert node["cpu_model"] == "Intel i7-12700K"
|
||||||
|
assert node["ram_gb"] == 32.0
|
||||||
|
assert node["disk_gb"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(cpu_count=4, ram_gb=16.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["cpu_count"] == 4
|
||||||
|
assert node["ram_gb"] == 16.0
|
||||||
|
assert node["cpu_model"] is None
|
||||||
|
assert node["disk_gb"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["show_hardware"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["show_hardware"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["cpu_count"] is None
|
||||||
|
assert node["ram_gb"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── node width / height (resizable nodes) ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(width=320.0, height=180.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["width"] == 320.0
|
||||||
|
assert node["height"] == 180.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_dimensions_default_null(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["width"] is None
|
||||||
|
assert canvas["nodes"][0]["height"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(width=140.0, height=50.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_resized = {**n1, "width": 280.0, "height": 120.0}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["width"] == 280.0
|
||||||
|
assert node["height"] == 120.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(width=300.0, height=200.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_cleared = {**n1, "width": None, "height": None}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["width"] is None
|
||||||
|
assert canvas["nodes"][0]["height"] is None
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -102,6 +102,16 @@ async def test_update_node_container_mode(client: AsyncClient, headers: dict):
|
|||||||
assert res.json()["container_mode"] is True
|
assert res.json()["container_mode"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||||
|
parent = await client.post("/api/v1/nodes", json={"type": "proxmox", "label": "PVE", "status": "unknown"}, headers=headers)
|
||||||
|
parent_id = parent.json()["id"]
|
||||||
|
child = await client.post("/api/v1/nodes", json={"type": "lxc", "label": "Child", "status": "unknown"}, headers=headers)
|
||||||
|
child_id = child.json()["id"]
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": parent_id}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["parent_id"] == parent_id
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_requires_auth(client: AsyncClient):
|
async def test_create_node_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -22,24 +22,33 @@ def _make_token() -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_websocket_rejected_without_token():
|
def test_websocket_rejected_without_token():
|
||||||
"""Connection with no token must be closed before being accepted."""
|
"""Connection that sends no token field must be closed with 1008."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({})) # missing token field
|
||||||
|
ws.receive_text() # triggers WebSocketDisconnect from server close
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_rejected_with_invalid_token():
|
def test_websocket_rejected_with_invalid_token():
|
||||||
"""Connection with a garbage token must be closed."""
|
"""Connection that sends a garbage token must be closed."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({"token": "not-a-valid-jwt"}))
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejected_with_malformed_json():
|
||||||
|
"""Connection that sends non-JSON as auth must be closed."""
|
||||||
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
|
ws.send_text("not-json")
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_accepted_with_valid_token():
|
def test_websocket_accepted_with_valid_token():
|
||||||
"""Connection with a valid JWT must be accepted and kept open."""
|
"""Connection that sends a valid JWT as first message must be accepted."""
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
|
with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
# Connection is open — we can send a ping and it should not raise
|
ws.send_text(json.dumps({"token": token}))
|
||||||
|
# Connection is open — subsequent messages should not raise
|
||||||
ws.send_text("ping")
|
ws.send_text("ping")
|
||||||
# Server keeps the connection open (no disconnect expected)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
+23
-2
@@ -7,9 +7,8 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
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
|
SQLITE_PATH: /app/data/homelab.db
|
||||||
CORS_ORIGINS: '["http://localhost:3000"]'
|
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
@@ -17,6 +16,28 @@ services:
|
|||||||
# Required for ping-based status checks
|
# Required for ping-based status checks
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ./mcp
|
||||||
|
dockerfile: Dockerfile.mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: "http://backend:8000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- homelable
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,16 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy WebSocket (must be before /api/ to take priority)
|
||||||
|
location /api/v1/status/ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy API to backend
|
# Proxy API to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
@@ -11,7 +21,7 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy WebSocket
|
# Proxy legacy /ws/ path
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 614 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 339 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
+3
-2
@@ -2,9 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<meta name="description" content="Homelable — Visual homelab infrastructure map with live monitoring" />
|
||||||
|
<title>Homelable</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+171
-127
@@ -1,24 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
@@ -1482,6 +1484,37 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||||
@@ -1532,6 +1565,24 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||||
"version": "14.0.0",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||||
@@ -1545,6 +1596,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.39.4",
|
"version": "9.39.4",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
||||||
@@ -2838,42 +2902,6 @@
|
|||||||
"path-browserify": "^1.0.1"
|
"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": {
|
"node_modules/@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
@@ -3008,6 +3036,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-yaml": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3245,45 +3279,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"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": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -3339,19 +3334,6 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||||
@@ -3812,11 +3794,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"license": "MIT"
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
@@ -3865,14 +3849,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^4.0.2"
|
||||||
"concat-map": "0.0.1"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -5017,6 +5002,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/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/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",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
@@ -5029,6 +5045,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/espree": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||||
@@ -5047,6 +5076,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/esprima": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||||
@@ -5474,9 +5516,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.4",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -5826,9 +5868,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.5",
|
"version": "4.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -6917,9 +6959,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -6981,16 +7023,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "10.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||||
"dev": true,
|
"license": "BlueOak-1.0.0",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^5.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
@@ -7493,9 +7537,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7938,9 +7982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router/node_modules/path-to-regexp": {
|
"node_modules/router/node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -8770,9 +8814,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.22.0",
|
"version": "7.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,12 +19,14 @@
|
|||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
|
||||||
|
<!-- House silhouette -->
|
||||||
|
<path d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Door -->
|
||||||
|
<rect x="27" y="40" width="10" height="12" rx="1"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Network nodes -->
|
||||||
|
<!-- Center node (hub) -->
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff"/>
|
||||||
|
|
||||||
|
<!-- Left node -->
|
||||||
|
<circle cx="22" cy="38" r="2" fill="#39d353"/>
|
||||||
|
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Right node -->
|
||||||
|
<circle cx="42" cy="38" r="2" fill="#39d353"/>
|
||||||
|
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Top node (inside roof area) -->
|
||||||
|
<circle cx="32" cy="24" r="2" fill="#a855f7"/>
|
||||||
|
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" stroke-width="1" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Glow effect on center node -->
|
||||||
|
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" stroke-width="1.5" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
+208
-76
@@ -2,7 +2,12 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -14,8 +19,13 @@ import { LoginPage } from '@/components/LoginPage'
|
|||||||
import { NodeModal } from '@/components/modals/NodeModal'
|
import { NodeModal } from '@/components/modals/NodeModal'
|
||||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { canvasApi } from '@/api/client'
|
import { canvasApi } from '@/api/client'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
@@ -25,13 +35,18 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
@@ -41,53 +56,20 @@ export default function App() {
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges }))
|
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map((n) => ({
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
id: n.id,
|
const edgesToSave = edges.map(serializeEdge)
|
||||||
type: n.data.type,
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
||||||
label: n.data.label,
|
|
||||||
hostname: n.data.hostname ?? null,
|
|
||||||
ip: n.data.ip ?? null,
|
|
||||||
mac: n.data.mac ?? null,
|
|
||||||
os: n.data.os ?? null,
|
|
||||||
status: n.data.status,
|
|
||||||
check_method: n.data.check_method ?? null,
|
|
||||||
check_target: n.data.check_target ?? null,
|
|
||||||
services: n.data.services ?? [],
|
|
||||||
notes: n.data.notes ?? null,
|
|
||||||
parent_id: n.data.parent_id ?? null,
|
|
||||||
container_mode: n.data.container_mode ?? false,
|
|
||||||
custom_colors: n.data.custom_colors ?? null,
|
|
||||||
custom_icon: n.data.custom_icon ?? null,
|
|
||||||
pos_x: n.position.x,
|
|
||||||
pos_y: n.position.y,
|
|
||||||
}))
|
|
||||||
const edgesToSave = edges.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
type: e.data?.type ?? 'ethernet',
|
|
||||||
label: e.data?.label ?? null,
|
|
||||||
vlan_id: e.data?.vlan_id ?? null,
|
|
||||||
speed: e.data?.speed ?? null,
|
|
||||||
custom_color: e.data?.custom_color ?? null,
|
|
||||||
path_style: e.data?.path_style ?? null,
|
|
||||||
// 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),
|
|
||||||
}))
|
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} })
|
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
toast.error('Save failed')
|
||||||
}
|
}
|
||||||
}, [nodes, edges, markSaved])
|
}, [nodes, edges, markSaved, activeTheme])
|
||||||
|
|
||||||
// Keep a ref so the keydown handler always calls the latest version
|
// Keep a ref so the keydown handler always calls the latest version
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
@@ -99,7 +81,8 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
|
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
|
||||||
|
if (theme_id) setTheme(theme_id)
|
||||||
loadCanvas(savedNodes, savedEdges)
|
loadCanvas(savedNodes, savedEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
@@ -116,52 +99,55 @@ export default function App() {
|
|||||||
if (apiNodes.length > 0) {
|
if (apiNodes.length > 0) {
|
||||||
// Build a map of proxmox container mode to know if children should be nested
|
// Build a map of proxmox container mode to know if children should be nested
|
||||||
const proxmoxContainerMap = new Map<string, boolean>(
|
const proxmoxContainerMap = new Map<string, boolean>(
|
||||||
apiNodes
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: NodeData & { id: string }) => n.type === 'proxmox')
|
.filter((n) => n.type === 'proxmox')
|
||||||
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
|
.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 }) => {
|
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
return {
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
id: n.id,
|
if (savedTheme) setTheme(savedTheme)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
loadCanvas(rfNodes, rfEdges)
|
loadCanvas(rfNodes, rfEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||||
}, [isAuthenticated, loadCanvas])
|
}, [isAuthenticated, loadCanvas, setTheme])
|
||||||
|
|
||||||
// Ctrl+S
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
|
const undoRef = useRef(undo)
|
||||||
|
const redoRef = useRef(redo)
|
||||||
|
const copyRef = useRef(copySelectedNodes)
|
||||||
|
const pasteRef = useRef(pasteNodes)
|
||||||
|
useEffect(() => { undoRef.current = undo }, [undo])
|
||||||
|
useEffect(() => { redoRef.current = redo }, [redo])
|
||||||
|
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
||||||
|
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
e.preventDefault()
|
// Ignore shortcuts when typing in an input/textarea
|
||||||
handleSaveRef.current()
|
const tag = (e.target as HTMLElement).tagName
|
||||||
}
|
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable
|
||||||
|
|
||||||
|
if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||||
|
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||||
|
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
||||||
|
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
const id = crypto.randomUUID()
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isProxmox = data.type === 'proxmox'
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
// Children position is relative to parent; place near top-left with padding
|
// Children position is relative to parent; place near top-left with padding
|
||||||
@@ -179,7 +165,70 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
}, [addNode, nodes])
|
}, [addNode, nodes, snapshotHistory])
|
||||||
|
|
||||||
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
|
const newNode: Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 200, y: 200 },
|
||||||
|
data: {
|
||||||
|
label: data.label,
|
||||||
|
type: 'groupRect',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
zIndex: data.z_order - 10,
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
}, [addNode, snapshotHistory])
|
||||||
|
|
||||||
|
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
|
if (!editingGroupRectId) return
|
||||||
|
snapshotHistory()
|
||||||
|
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
||||||
|
updateNode(editingGroupRectId, {
|
||||||
|
label: data.label,
|
||||||
|
custom_colors: {
|
||||||
|
...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, snapshotHistory])
|
||||||
|
|
||||||
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
|
if (!editingGroupRectId) return
|
||||||
|
snapshotHistory()
|
||||||
|
deleteNode(editingGroupRectId)
|
||||||
|
setEditingGroupRectId(null)
|
||||||
|
}, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
const handleEditNode = useCallback((id: string) => {
|
const handleEditNode = useCallback((id: string) => {
|
||||||
setEditNodeId(id)
|
setEditNodeId(id)
|
||||||
@@ -187,6 +236,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
||||||
if (!editNodeId) return
|
if (!editNodeId) return
|
||||||
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||||
@@ -216,7 +266,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEditNodeId(null)
|
setEditNodeId(null)
|
||||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect])
|
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
const handleAutoLayout = useCallback(() => {
|
||||||
const laid = applyDagreLayout(nodes, edges)
|
const laid = applyDagreLayout(nodes, edges)
|
||||||
@@ -224,6 +274,32 @@ export default function App() {
|
|||||||
toast.success('Canvas auto-arranged')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [nodes, edges, loadCanvas])
|
||||||
|
|
||||||
|
const handleExportMd = useCallback(async () => {
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
if (!md) { toast.error('No nodes to export'); return }
|
||||||
|
await navigator.clipboard.writeText(md)
|
||||||
|
toast.success('Markdown table copied to clipboard')
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
|
const handleExportYaml = useCallback(() => {
|
||||||
|
if (nodes.length === 0) { toast.error('No nodes to export'); return }
|
||||||
|
const content = exportCanvasToYaml(nodes, edges)
|
||||||
|
downloadYaml(content)
|
||||||
|
toast.success('Canvas exported as YAML')
|
||||||
|
}, [nodes, edges])
|
||||||
|
|
||||||
|
const handleImportYaml = useCallback((content: string) => {
|
||||||
|
try {
|
||||||
|
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
||||||
|
snapshotHistory()
|
||||||
|
loadCanvas(merged, mergedEdges)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -241,6 +317,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
||||||
if (!pendingConnection) return
|
if (!pendingConnection) return
|
||||||
|
snapshotHistory()
|
||||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
@@ -255,7 +332,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
}, [pendingConnection, onConnect, nodes, updateNode])
|
}, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
@@ -263,15 +340,17 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
updateEdge(editEdgeId, data)
|
updateEdge(editEdgeId, data)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, updateEdge])
|
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDelete = useCallback(() => {
|
const handleEdgeDelete = useCallback(() => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
deleteEdge(editEdgeId)
|
deleteEdge(editEdgeId)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, deleteEdge])
|
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||||
|
|
||||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||||
@@ -284,6 +363,7 @@ export default function App() {
|
|||||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
onNodeApproved={setEditNodeId}
|
||||||
@@ -293,10 +373,17 @@ export default function App() {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onAutoLayout={handleAutoLayout}
|
onAutoLayout={handleAutoLayout}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
|
onChangeStyle={() => setThemeModalOpen(true)}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
onExportMd={handleExportMd}
|
||||||
|
onExportYaml={handleExportYaml}
|
||||||
|
onImportYaml={handleImportYaml}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
|
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||||
</div>
|
</div>
|
||||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,6 +439,51 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GroupRectModal
|
||||||
|
open={addGroupRectOpen}
|
||||||
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
|
onSubmit={handleAddGroupRect}
|
||||||
|
title="Add Zone"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* key forces re-mount when editing a different rect */}
|
||||||
|
<GroupRectModal
|
||||||
|
key={editingGroupRectId ?? 'rect-edit'}
|
||||||
|
open={!!editingGroupRectId}
|
||||||
|
onClose={() => setEditingGroupRectId(null)}
|
||||||
|
onSubmit={handleUpdateGroupRect}
|
||||||
|
onDelete={handleDeleteGroupRect}
|
||||||
|
initial={(() => {
|
||||||
|
const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null
|
||||||
|
if (!n) return undefined
|
||||||
|
const rc = n.data.custom_colors ?? {}
|
||||||
|
return {
|
||||||
|
label: n.data.label,
|
||||||
|
font: rc.font ?? 'inter',
|
||||||
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
|
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 Zone"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||||
|
<ThemeModal
|
||||||
|
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||||
|
open={themeModalOpen}
|
||||||
|
onClose={() => setThemeModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export const api = axios.create({
|
|||||||
baseURL: '/api/v1',
|
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) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = useAuthStore.getState().token
|
const token = useAuthStore.getState().token
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
@@ -44,6 +47,10 @@ export const edgesApi = {
|
|||||||
delete: (id: string) => api.delete(`/edges/${id}`),
|
delete: (id: string) => api.delete(`/edges/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const liveviewApi = {
|
||||||
|
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
||||||
|
}
|
||||||
|
|
||||||
export const scanApi = {
|
export const scanApi = {
|
||||||
trigger: () => api.post('/scan/trigger'),
|
trigger: () => api.post('/scan/trigger'),
|
||||||
pending: () => api.get('/scan/pending'),
|
pending: () => api.get('/scan/pending'),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,8 +20,9 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await authApi.login(username, password)
|
const res = await authApi.login(username, password)
|
||||||
login(res.data.access_token)
|
login(res.data.access_token)
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
setError('Invalid username or password')
|
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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,7 @@ export function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
import { nodeTypes } from './nodes/nodeTypes'
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
@@ -18,15 +20,19 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
|
onNodeDragStart?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode,
|
setSelectedNode,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
|
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
setSelectedNode(node.id)
|
setSelectedNode(node.id)
|
||||||
}, [setSelectedNode])
|
}, [setSelectedNode])
|
||||||
@@ -39,9 +45,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
onEdgeDoubleClick?.(edge)
|
onEdgeDoubleClick?.(edge)
|
||||||
}, [onEdgeDoubleClick])
|
}, [onEdgeDoubleClick])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -51,12 +56,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[16, 16]}
|
snapGrid={[16, 16]}
|
||||||
fitView
|
fitView
|
||||||
colorMode="dark"
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
isValidConnection={(connection) => connection.source !== connection.target}
|
isValidConnection={(connection) => connection.source !== connection.target}
|
||||||
>
|
>
|
||||||
@@ -64,7 +71,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
variant={BackgroundVariant.Dots}
|
variant={BackgroundVariant.Dots}
|
||||||
gap={24}
|
gap={24}
|
||||||
size={1}
|
size={1}
|
||||||
color="#30363d"
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<Controls />
|
<Controls />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,10 +3,13 @@ import {
|
|||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
|
useStore,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
type Edge,
|
type Edge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { EdgeData, EdgeType } from '@/types'
|
import type { EdgeData, EdgeType } from '@/types'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
|
|
||||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
|
|
||||||
@@ -15,42 +18,89 @@ function getVlanColor(vlanId?: number): string {
|
|||||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDGE_STYLES: Record<EdgeType, React.CSSProperties> = {
|
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||||
ethernet: { stroke: '#30363d', strokeWidth: 2 },
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
wifi: { stroke: '#00d4ff', strokeWidth: 1.5, strokeDasharray: '6 3' },
|
const theme = THEMES[activeTheme]
|
||||||
iot: { stroke: '#e3b341', strokeWidth: 1.5, strokeDasharray: '2 4' },
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
vlan: { strokeWidth: 2.5 },
|
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||||
virtual: { stroke: '#8b949e', strokeWidth: 1, strokeDasharray: '4 4' },
|
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||||
cluster: { stroke: '#ff6e00', strokeWidth: 2.5, strokeDasharray: '8 3' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
|
||||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
||||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||||
: getBezierPath(pathArgs)
|
: getBezierPath(pathArgs)
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
|
const edgeColors = theme.colors.edgeColors
|
||||||
|
|
||||||
|
const BASE_STYLES: Record<EdgeType, React.CSSProperties> = {
|
||||||
|
ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 },
|
||||||
|
wifi: { stroke: edgeColors.wifi, strokeWidth: 1.5, strokeDasharray: '6 3' },
|
||||||
|
iot: { stroke: edgeColors.iot, strokeWidth: 1.5, strokeDasharray: '2 4' },
|
||||||
|
vlan: { strokeWidth: 2.5 },
|
||||||
|
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
||||||
|
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
||||||
|
}
|
||||||
|
|
||||||
const customColor = data?.custom_color as string | undefined
|
const customColor = data?.custom_color as string | undefined
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
...EDGE_STYLES[edgeType],
|
...BASE_STYLES[edgeType],
|
||||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||||
...(customColor ? { stroke: customColor } : {}),
|
...(customColor ? { stroke: customColor } : {}),
|
||||||
...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}),
|
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} />
|
<BaseEdge id={id} path={edgePath} style={style} />
|
||||||
|
{animMode === 'snake' && (
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
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" 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 && (
|
{data?.label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
background: '#161b22',
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: '#8b949e',
|
color: theme.colors.edgeLabelColor,
|
||||||
border: '1px solid #30363d',
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.label as string}
|
{data.label as string}
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
import { createElement } from 'react'
|
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 { type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData, NodeStatus } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
const STATUS_COLORS: Record<NodeStatus, string> = {
|
import { THEMES } from '@/utils/themes'
|
||||||
online: '#39d353',
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
offline: '#f85149',
|
import { maskIp } from '@/utils/maskIp'
|
||||||
pending: '#e3b341',
|
|
||||||
unknown: '#8b949e',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
function formatStorage(gb: number): string {
|
||||||
|
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||||
|
return `${gb} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
||||||
const colors = resolveNodeColors(data)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
|
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
@@ -36,30 +43,96 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
|
width: width ? '100%' : undefined,
|
||||||
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" />
|
<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}
|
||||||
|
id="top"
|
||||||
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Main row */}
|
||||||
<div
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
{/* Icon */}
|
||||||
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
|
<div
|
||||||
>
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
{createElement(resolvedIcon, { size: 15 })}
|
style={{
|
||||||
|
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||||
|
background: theme.colors.nodeIconBackground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{createElement(resolvedIcon, { size: 15 })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + IP */}
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-xs font-medium leading-tight truncate"
|
||||||
|
style={{ color: theme.colors.nodeLabelColor }}
|
||||||
|
title={data.label}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
</div>
|
||||||
|
{data.ip && (
|
||||||
|
<div
|
||||||
|
className="font-mono text-[10px] truncate"
|
||||||
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
|
title={data.ip}
|
||||||
|
>
|
||||||
|
{hideIp ? maskIp(data.ip) : data.ip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Hardware section */}
|
||||||
<div className="flex flex-col min-w-0">
|
{showHardware && (
|
||||||
<div className="text-xs font-medium leading-tight truncate max-w-[110px]" title={data.label}>
|
<>
|
||||||
{data.label}
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
</div>
|
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||||
{data.ip && (
|
{/* Line 1: CPU */}
|
||||||
<div className="font-mono text-[10px] text-[#8b949e] truncate" title={data.ip}>
|
{(data.cpu_model || data.cpu_count != null) && (
|
||||||
{data.ip}
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
|
<Cpu size={9} className="shrink-0" />
|
||||||
|
{data.cpu_model && (
|
||||||
|
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
||||||
|
)}
|
||||||
|
{data.cpu_count != null && (
|
||||||
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Line 2: RAM + Disk */}
|
||||||
|
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
|
{data.ram_gb != null && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<MemoryStick size={9} className="shrink-0" />
|
||||||
|
{formatStorage(data.ram_gb)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.disk_gb != null && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<HardDrive size={9} className="shrink-0" />
|
||||||
|
{formatStorage(data.disk_gb)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div
|
<div
|
||||||
@@ -68,7 +141,12 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
title={data.status}
|
title={data.status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" />
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="bottom"
|
||||||
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { NodeData, TextPosition } from '@/types'
|
||||||
|
|
||||||
|
const FONT_FAMILIES: Record<string, string> = {
|
||||||
|
inter: 'Inter, sans-serif',
|
||||||
|
mono: '"JetBrains Mono", monospace',
|
||||||
|
serif: 'Georgia, serif',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlignStyle {
|
||||||
|
alignItems: string
|
||||||
|
justifyContent: string
|
||||||
|
textAlign: React.CSSProperties['textAlign']
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
||||||
|
'top-left': { alignItems: 'flex-start', justifyContent: 'flex-start', textAlign: 'left' },
|
||||||
|
'top-center': { alignItems: 'flex-start', justifyContent: 'center', textAlign: 'center' },
|
||||||
|
'top-right': { alignItems: 'flex-start', justifyContent: 'flex-end', textAlign: 'right' },
|
||||||
|
'middle-left': { alignItems: 'center', justifyContent: 'flex-start', textAlign: 'left' },
|
||||||
|
'center': { alignItems: 'center', justifyContent: 'center', textAlign: 'center' },
|
||||||
|
'middle-right': { alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right' },
|
||||||
|
'bottom-left': { alignItems: 'flex-end', justifyContent: 'flex-start', textAlign: 'left' },
|
||||||
|
'bottom-center': { alignItems: 'flex-end', justifyContent: 'center', textAlign: 'center' },
|
||||||
|
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
|
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||||
|
|
||||||
|
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
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={80}
|
||||||
|
minHeight={60}
|
||||||
|
handleStyle={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#00d4ff',
|
||||||
|
border: '1px solid #0d1117',
|
||||||
|
}}
|
||||||
|
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: posStyle.alignItems,
|
||||||
|
justifyContent: posStyle.justifyContent,
|
||||||
|
padding: 12,
|
||||||
|
background: backgroundColor,
|
||||||
|
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingGroupRectId(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,34 +1,46 @@
|
|||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData, NodeStatus } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<NodeStatus, string> = {
|
|
||||||
online: '#39d353',
|
|
||||||
offline: '#f85149',
|
|
||||||
pending: '#e3b341',
|
|
||||||
unknown: '#8b949e',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||||
const { data, selected } = props
|
const { data, selected } = props
|
||||||
const colors = resolveNodeColors(data)
|
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const theme = THEMES[activeTheme]
|
||||||
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
|
|
||||||
// Render as a regular node when container mode is disabled
|
// Render as a regular node when container mode is disabled
|
||||||
if (data.container_mode === false) {
|
if (data.container_mode === false) {
|
||||||
|
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseNode {...props} icon={Layers} />
|
<BaseNode {...props} icon={Layers} />
|
||||||
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
<Handle
|
||||||
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id="cluster-left"
|
||||||
|
title="Same cluster"
|
||||||
|
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="cluster-right"
|
||||||
|
title="Same cluster"
|
||||||
|
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
const glow = colors.border
|
const glow = colors.border
|
||||||
|
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -37,7 +49,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
minHeight={160}
|
minHeight={160}
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||||
handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }}
|
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Group border */}
|
{/* Group border */}
|
||||||
@@ -56,38 +68,78 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
||||||
style={{ background: isOnline ? `${glow}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${glow}33` : '#30363d'}` }}
|
style={{
|
||||||
|
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||||
|
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
|
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
|
||||||
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
|
style={{
|
||||||
|
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||||
|
background: theme.colors.nodeIconBackground,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Layers size={12} />
|
<Layers size={12} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? glow : '#c9d1d9' }}>
|
<span
|
||||||
|
className="text-[11px] font-semibold leading-tight truncate"
|
||||||
|
style={{ color: isOnline ? glow : theme.colors.nodeLabelColor }}
|
||||||
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
{data.ip && (
|
{data.ip && (
|
||||||
<span className="font-mono text-[9px] text-[#8b949e] truncate">{data.ip}</span>
|
<span
|
||||||
|
className="font-mono text-[9px] truncate"
|
||||||
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
|
>
|
||||||
|
{data.ip}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} title={data.status} />
|
<div
|
||||||
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
title={data.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inner area — React Flow places children here */}
|
{/* Inner area — React Flow places children here */}
|
||||||
<div className="flex-1 relative" />
|
<div className="flex-1 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" />
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Top}
|
||||||
|
id="top"
|
||||||
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" />
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="bottom"
|
||||||
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
{/* Cluster handles — left/right for same-cluster links */}
|
{/* Cluster handles */}
|
||||||
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
<Handle
|
||||||
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id="cluster-left"
|
||||||
|
title="Same cluster"
|
||||||
|
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="cluster-right"
|
||||||
|
title="Same cluster"
|
||||||
|
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -22,4 +22,5 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
|||||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||||
|
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
@@ -16,5 +17,7 @@ export const nodeTypes = {
|
|||||||
printer: PrinterNode,
|
printer: PrinterNode,
|
||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
|
docker: DockerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
|
groupRect: GroupRectNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
|||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
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 {
|
interface EdgeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -25,6 +33,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
|
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
@@ -36,6 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
|
animated: animation !== 'none' ? animation : undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -113,6 +123,27 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [
|
||||||
|
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||||
|
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||||
|
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||||
|
{ value: 'double', label: 'Double', preview: '═══' },
|
||||||
|
{ value: 'none', label: 'None', preview: ' ' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||||
|
{ value: 'mono', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
|
||||||
|
{ value: 'top-left', label: '↖' },
|
||||||
|
{ value: 'top-center', label: '↑' },
|
||||||
|
{ value: 'top-right', label: '↗' },
|
||||||
|
{ value: 'middle-left', label: '←' },
|
||||||
|
{ value: 'center', label: '·' },
|
||||||
|
{ value: 'middle-right', label: '→' },
|
||||||
|
{ value: 'bottom-left', label: '↙' },
|
||||||
|
{ value: 'bottom-center', label: '↓' },
|
||||||
|
{ value: 'bottom-right', label: '↘' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface GroupRectModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: GroupRectFormData) => void
|
||||||
|
onDelete?: () => void
|
||||||
|
initial?: Partial<GroupRectFormData>
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(form)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ key: 'text_color' as const, label: 'Text' },
|
||||||
|
{ key: 'border_color' as const, label: 'Border' },
|
||||||
|
{ key: 'background_color' as const, label: 'Background' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||||
|
<Input
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => set('label', e.target.value)}
|
||||||
|
placeholder="Zone name…"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
{FONTS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text position 3×3 grid */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text Position</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{TEXT_POSITIONS.map(({ value, label }) => {
|
||||||
|
const isSelected = form.text_position === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
title={value}
|
||||||
|
onClick={() => set('text_position', value)}
|
||||||
|
className="h-8 rounded text-base transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{colorFields.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
|
<label
|
||||||
|
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||||
|
style={{ borderColor: '#30363d' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form[key]}
|
||||||
|
onChange={(e) => set(key, e.target.value)}
|
||||||
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||||
|
</label>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||||
|
const isSelected = form.border_style === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
onClick={() => set('border_style', value)}
|
||||||
|
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||||
|
<span className="text-[9px]">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
||||||
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)} className="text-sm font-mono">
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
||||||
|
onClick={() => { onDelete(); onClose() }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||||
|
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
|
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||||
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
|
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||||
|
]
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
|
|
||||||
@@ -43,13 +48,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
|
const [labelError, setLabelError] = useState(false)
|
||||||
|
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
|
||||||
|
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.label?.trim()) return
|
if (!form.label?.trim()) {
|
||||||
|
setLabelError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLabelError(false)
|
||||||
onSubmit(form)
|
onSubmit(form)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -71,10 +83,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{NODE_TYPES.map(([value, label]) => (
|
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||||
<SelectItem key={value} value={value} className="text-sm">
|
<>
|
||||||
{label}
|
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||||
</SelectItem>
|
<SelectGroup key={group.label}>
|
||||||
|
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||||
|
{group.label}
|
||||||
|
</SelectLabel>
|
||||||
|
{group.types.map((type) => (
|
||||||
|
<SelectItem key={type} value={type} className="text-sm pl-4">
|
||||||
|
{NODE_TYPE_LABELS[type]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -167,11 +189,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.label ?? ''}
|
value={form.label ?? ''}
|
||||||
onChange={(e) => set('label', e.target.value)}
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||||
placeholder="My Server"
|
placeholder="My Server"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hostname */}
|
{/* Hostname */}
|
||||||
@@ -310,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware specs (hidden for groupRect) */}
|
||||||
|
{form.type !== 'groupRect' && (
|
||||||
|
<div className="flex flex-col gap-2 col-span-2">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHardwareOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Hardware</span>
|
||||||
|
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
{hardwareOpen && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!form.show_hardware}
|
||||||
|
onClick={() => set('show_hardware', !form.show_hardware)}
|
||||||
|
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||||
|
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
||||||
|
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hardwareOpen && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
||||||
|
<Input
|
||||||
|
value={form.cpu_model ?? ''}
|
||||||
|
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
||||||
|
placeholder="e.g. Intel Xeon E5-2680"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.cpu_count ?? ''}
|
||||||
|
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||||
|
placeholder="e.g. 8"
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.5}
|
||||||
|
value={form.ram_gb ?? ''}
|
||||||
|
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
placeholder="e.g. 32"
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={form.disk_gb ?? ''}
|
||||||
|
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
placeholder="e.g. 500"
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useReactFlow } from '@xyflow/react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
interface SearchModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
||||||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
|
n.data.hostname?.toLowerCase().includes(q)
|
||||||
|
).slice(0, 8)
|
||||||
|
|
||||||
|
const handleSelect = useCallback((nodeId: string) => {
|
||||||
|
setSelectedNode(nodeId)
|
||||||
|
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||||
|
onClose()
|
||||||
|
setQuery('')
|
||||||
|
}, [fitView, setSelectedNode, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||||
|
<Search size={16} className="text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search nodes by label, IP, hostname…"
|
||||||
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||||
|
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ul className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{results.map((node) => (
|
||||||
|
<li
|
||||||
|
key={node.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
|
onClick={() => handleSelect(node.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||||
|
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||||
|
{node.data.ip && (
|
||||||
|
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.length > 0 && results.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{
|
||||||
|
group: 'Canvas',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Ctrl', 'S'], description: 'Save canvas' },
|
||||||
|
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
|
||||||
|
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
|
||||||
|
{ keys: ['Ctrl', 'K'], description: 'Search nodes' },
|
||||||
|
{ keys: ['?'], description: 'Show this help' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Nodes',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Ctrl', 'C'], description: 'Copy selected nodes' },
|
||||||
|
{ keys: ['Ctrl', 'V'], description: 'Paste nodes' },
|
||||||
|
{ keys: ['Del'], description: 'Delete selected node/edge' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Navigation',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Scroll'], description: 'Zoom in / out' },
|
||||||
|
{ keys: ['Space', '+', 'Drag'], description: 'Pan canvas' },
|
||||||
|
{ keys: ['Ctrl', 'Shift', 'F'], description: 'Fit view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ShortcutsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutsModal({ open, onClose }: ShortcutsModalProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{SHORTCUTS.map((group) => (
|
||||||
|
<div key={group.group}>
|
||||||
|
<p className="text-xs text-[#00d4ff] font-semibold mb-2 uppercase tracking-wide">
|
||||||
|
{group.group}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div key={item.description} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">{item.description}</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{item.keys.map((k, i) => (
|
||||||
|
k === '+' ? (
|
||||||
|
<span key={i} className="text-xs text-muted-foreground">+</span>
|
||||||
|
) : (
|
||||||
|
<kbd key={k} className="text-[11px] text-foreground border border-border rounded px-1.5 py-0.5 font-mono bg-[#0d1117]">
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
// Node-type accent colors to display as preview swatches
|
||||||
|
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
||||||
|
|
||||||
|
interface ThemeCardProps {
|
||||||
|
themeId: ThemeId
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
||||||
|
const preset = THEMES[themeId]
|
||||||
|
const c = preset.colors
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
||||||
|
style={{
|
||||||
|
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||||
|
background: c.canvasBackground,
|
||||||
|
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Selected checkmark */}
|
||||||
|
{selected && (
|
||||||
|
<span
|
||||||
|
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
||||||
|
style={{ background: c.nodeAccents.isp.border }}
|
||||||
|
>
|
||||||
|
<Check size={10} style={{ color: c.canvasBackground }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mini canvas preview */}
|
||||||
|
<div
|
||||||
|
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||||
|
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||||
|
>
|
||||||
|
{/* Node accent dots */}
|
||||||
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
|
{PREVIEW_TYPES.map((type) => (
|
||||||
|
<span
|
||||||
|
key={type}
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: c.nodeAccents[type].border }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Edge line */}
|
||||||
|
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
||||||
|
{/* Wifi dashed line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
width: '55%',
|
||||||
|
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div
|
||||||
|
className="text-xs font-semibold leading-tight"
|
||||||
|
style={{ color: c.nodeLabelColor }}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
||||||
|
style={{ color: c.nodeSubtextColor }}
|
||||||
|
>
|
||||||
|
{preset.description}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||||
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
|
const { markUnsaved } = useCanvasStore()
|
||||||
|
|
||||||
|
// Capture the theme that was active when the modal opened
|
||||||
|
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||||
|
const [selected, setSelected] = useState<ThemeId>(activeTheme)
|
||||||
|
|
||||||
|
const handleSelect = (id: ThemeId) => {
|
||||||
|
setSelected(id)
|
||||||
|
// Live-preview the selected theme on the canvas
|
||||||
|
setTheme(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
setTheme(selected)
|
||||||
|
markUnsaved()
|
||||||
|
onClose()
|
||||||
|
toast.info('Style applied — save your canvas to make it permanent', {
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// Revert to the original theme
|
||||||
|
setTheme(originalTheme)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-3 py-1">
|
||||||
|
{THEME_ORDER.map((id) => (
|
||||||
|
<ThemeCard
|
||||||
|
key={id}
|
||||||
|
themeId={id}
|
||||||
|
selected={selected === id}
|
||||||
|
onClick={() => handleSelect(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
style={
|
||||||
|
selected !== 'default'
|
||||||
|
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
Apply Style
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { GroupRectModal, type GroupRectFormData } from '../GroupRectModal'
|
||||||
|
|
||||||
|
describe('GroupRectModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<GroupRectModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 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', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
const input = screen.getByPlaceholderText('Zone name…')
|
||||||
|
fireEvent.change(input, { target: { value: 'DMZ' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.label).toBe('DMZ')
|
||||||
|
expect(submitted.font).toBe('inter')
|
||||||
|
expect(submitted.z_order).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Delete button when onDelete is provided', () => {
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||||
|
expect(screen.getByText('Delete')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDelete and onClose when Delete is clicked', () => {
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||||
|
fireEvent.click(screen.getByText('Delete'))
|
||||||
|
expect(onDelete).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills form from initial prop', () => {
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ label: 'Pre-filled', z_order: 5, font: 'mono' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const input = screen.getByPlaceholderText('Zone name…') as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Pre-filled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects text position button', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
// Click bottom-right (↘)
|
||||||
|
fireEvent.click(screen.getByTitle('bottom-right'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.text_position).toBe('bottom-right')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Border Style section', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Border Style')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Solid')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Dashed')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Dotted')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Double')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('None')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults border_style to solid', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('solid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects border style on click', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByTitle('Dashed'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('dashed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills border_style from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initial={{ border_style: 'dotted' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('dotted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('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} />)
|
||||||
|
fireEvent.click(screen.getByTitle('Dotted'))
|
||||||
|
fireEvent.click(screen.getByTitle('Solid'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('solid')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { NodeModal } from '../NodeModal'
|
||||||
|
|
||||||
|
describe('NodeModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders form fields when open', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
||||||
|
expect(screen.getByText('Add Node')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onSubmit when label is empty and shows error', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByText('Label is required')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with form data when label is filled', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears label error when user starts typing', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(screen.getByText('Label is required')).toBeDefined()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
||||||
|
expect(screen.queryByText('Label is required')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills form from initial prop', () => {
|
||||||
|
render(
|
||||||
|
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
|
||||||
|
)
|
||||||
|
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Pre-filled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Save button text when title is Edit Node', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
|
||||||
|
expect(screen.getByText('Save')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hardware section', () => {
|
||||||
|
it('renders Hardware toggle button', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hardware fields are hidden by default', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands hardware fields on toggle click', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits hardware fields when filled', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0]
|
||||||
|
expect(submitted.cpu_model).toBe('Intel i7-12700K')
|
||||||
|
expect(submitted.cpu_count).toBe(12)
|
||||||
|
expect(submitted.ram_gb).toBe(64)
|
||||||
|
expect(submitted.disk_gb).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-expands when initial has hardware data', () => {
|
||||||
|
render(
|
||||||
|
<NodeModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides hardware section for groupRect type', () => {
|
||||||
|
render(
|
||||||
|
<NodeModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ type: 'groupRect' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Hardware')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show on node toggle is hidden when section is collapsed', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Show on node')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show on node toggle appears when section is expanded', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
expect(screen.getByText('Show on node')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show_hardware defaults to false', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggling show on node sets show_hardware to true', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills show_hardware from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(
|
||||||
|
<NodeModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
|
||||||
|
|
||||||
|
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
|
||||||
|
return {
|
||||||
|
id: 'dev-1',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
mac: 'aa:bb:cc:dd:ee:ff',
|
||||||
|
hostname: 'pve.local',
|
||||||
|
os: 'Linux',
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'server',
|
||||||
|
status: 'pending',
|
||||||
|
discovered_at: '2024-01-15T10:30:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PendingDeviceModal', () => {
|
||||||
|
// ── Visibility ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('renders nothing when device is null', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dialog when device is provided', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('dialog')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Device info display ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows hostname as title when available', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
// hostname appears in both title and info row — check at least one match
|
||||||
|
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to IP as title when hostname is null', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
// IP appears in both title and info row when hostname is absent
|
||||||
|
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows IP address', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('192.168.1.100')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows MAC address when present', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows OS when present', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Linux')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show hostname row when hostname is null', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
// "Hostname" label should not appear in the info rows
|
||||||
|
expect(screen.queryByText('Hostname')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows suggested type when present', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('proxmox')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Services ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows "No services detected" when services list is empty', () => {
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('No services detected')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows service count and details', () => {
|
||||||
|
const device = makeDevice({
|
||||||
|
services: [
|
||||||
|
{ port: 80, protocol: 'tcp', service_name: 'HTTP', category: 'web' },
|
||||||
|
{ port: 443, protocol: 'tcp', service_name: 'HTTPS', category: 'web' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Services found (2)')).toBeDefined()
|
||||||
|
expect(screen.getByText('HTTP')).toBeDefined()
|
||||||
|
expect(screen.getByText('HTTPS')).toBeDefined()
|
||||||
|
expect(screen.getByText('80')).toBeDefined()
|
||||||
|
expect(screen.getByText('443')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows service category when present', () => {
|
||||||
|
const device = makeDevice({
|
||||||
|
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('hypervisor')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onApprove with the device and onClose when Approve is clicked', () => {
|
||||||
|
const device = makeDevice()
|
||||||
|
const onApprove = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||||
|
expect(onApprove).toHaveBeenCalledWith(device)
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onHide with the device and onClose when Hide is clicked', () => {
|
||||||
|
const device = makeDevice()
|
||||||
|
const onHide = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
|
||||||
|
expect(onHide).toHaveBeenCalledWith(device)
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onIgnore with the device and onClose when Delete is clicked', () => {
|
||||||
|
const device = makeDevice()
|
||||||
|
const onIgnore = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onIgnore).toHaveBeenCalledWith(device)
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ScanConfigModal } from '../ScanConfigModal'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
saveConfig: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } }
|
||||||
|
|
||||||
|
describe('ScanConfigModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||||
|
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ScanConfigModal open={false} onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads config from API on open', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.getConfig).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
expect(input).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads interval from API on open', async () => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
const input = await screen.findByDisplayValue('120')
|
||||||
|
expect(input).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a new empty range on "Add range" click', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.click(screen.getByText('Add range'))
|
||||||
|
const inputs = screen.getAllByPlaceholderText('192.168.1.0/24')
|
||||||
|
expect(inputs).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete button disabled when only one range', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
// Only 1 range → delete button disabled
|
||||||
|
const trashButtons = document.querySelectorAll('button[disabled]')
|
||||||
|
expect(trashButtons.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can remove a range when more than one exist', async () => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
// Both trash buttons should be enabled
|
||||||
|
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
|
||||||
|
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast and does not save when all ranges are empty', async () => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
|
||||||
|
})
|
||||||
|
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves config and closes on Save click', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when save fails', async () => {
|
||||||
|
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
||||||
|
const onScanNow = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.saveConfig).toHaveBeenCalled()
|
||||||
|
expect(onScanNow).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips whitespace from ranges before saving', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
// Type a range with surrounding whitespace
|
||||||
|
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.saveConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { SearchModal } from '../SearchModal'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const mockFitView = vi.fn()
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
useReactFlow: () => ({ fitView: mockFitView }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SearchModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||||
|
mockFitView.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
render(<SearchModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders search input when open', () => {
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Type to search" hint when query is empty', () => {
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText(/type to search/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no results message when query has no matches', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
||||||
|
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters nodes by label', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
||||||
|
})
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
||||||
|
expect(screen.getByText('My Router')).toBeDefined()
|
||||||
|
expect(screen.queryByText('NAS Server')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters nodes by IP', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
makeNode('n1', { label: 'Box A', ip: '192.168.1.10' }),
|
||||||
|
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
||||||
|
expect(screen.getByText('Box A')).toBeDefined()
|
||||||
|
expect(screen.queryByText('Box B')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters nodes by hostname', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
makeNode('n1', { label: 'A', hostname: 'pve.local' }),
|
||||||
|
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
||||||
|
expect(screen.getByText('A')).toBeDefined()
|
||||||
|
expect(screen.queryByText('B')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes from results', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
makeNode('n1', { label: 'Server', type: 'server' }),
|
||||||
|
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
||||||
|
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits results to 8 nodes', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
||||||
|
})
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
||||||
|
const items = screen.getAllByText(/Server \d/)
|
||||||
|
expect(items).toHaveLength(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects node and closes on result click', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
||||||
|
render(<SearchModal open onClose={onClose} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
||||||
|
fireEvent.click(screen.getByText('Proxmox'))
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||||
|
expect(mockFitView).toHaveBeenCalledWith(expect.objectContaining({ nodes: [{ id: 'n1' }] }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects first result and closes on Enter key', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
||||||
|
render(<SearchModal open onClose={onClose} />)
|
||||||
|
const input = screen.getByPlaceholderText(/search nodes/i)
|
||||||
|
fireEvent.change(input, { target: { value: 'switch' } })
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape key', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<SearchModal open onClose={onClose} />)
|
||||||
|
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes when clicking backdrop', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<SearchModal open onClose={onClose} />)
|
||||||
|
// The backdrop is the fixed inset div — clicking it fires onClose
|
||||||
|
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
||||||
|
fireEvent.click(backdrop)
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close when clicking inside the search box', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<SearchModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('search is case-insensitive', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
||||||
|
render(<SearchModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
||||||
|
expect(screen.getByText('My NAS')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ShortcutsModal } from '../ShortcutsModal'
|
||||||
|
|
||||||
|
describe('ShortcutsModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ShortcutsModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders shortcut groups when open', () => {
|
||||||
|
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Keyboard Shortcuts')).toBeDefined()
|
||||||
|
expect(screen.getByText('Canvas')).toBeDefined()
|
||||||
|
expect(screen.getByText('Nodes')).toBeDefined()
|
||||||
|
expect(screen.getByText('Navigation')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows key shortcuts in kbd elements', () => {
|
||||||
|
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||||
|
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when backdrop clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||||
|
fireEvent.click(container.firstChild as HTMLElement)
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when X button clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons[0])
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
import { X, Edit, Trash2, ExternalLink } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
|
||||||
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
onEdit: (id: string) => void
|
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) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode } = useCanvasStore()
|
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
|
||||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||||
|
|
||||||
if (!node) return null
|
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 { data } = node
|
||||||
|
const services = data.services ?? []
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
|
const host = data.ip ?? data.hostname
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
@@ -22,12 +39,53 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddService = () => {
|
||||||
|
const port = parseInt(newSvc.port, 10)
|
||||||
|
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||||
|
const svc: ServiceInfo = {
|
||||||
|
port,
|
||||||
|
protocol: newSvc.protocol,
|
||||||
|
service_name: newSvc.service_name.trim(),
|
||||||
|
}
|
||||||
|
updateNode(node.id, { services: [...services, svc] })
|
||||||
|
setNewSvc(EMPTY_FORM)
|
||||||
|
setAddingForNode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveService = (index: number) => {
|
||||||
|
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 (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<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>
|
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Close panel"
|
||||||
onClick={() => setSelectedNode(null)}
|
onClick={() => setSelectedNode(null)}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@@ -47,31 +105,98 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||||||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
||||||
{data.hostname && <DetailRow label="Hostname" value={data.hostname} mono />}
|
{data.hostname && (
|
||||||
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
||||||
|
<a
|
||||||
|
href={`http://${data.hostname}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
|
||||||
|
title={data.hostname}
|
||||||
|
>
|
||||||
|
{data.hostname}
|
||||||
|
<ExternalLink size={10} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && (
|
{data.last_seen && (
|
||||||
<DetailRow
|
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
|
||||||
label="Last Seen"
|
|
||||||
value={new Date(data.last_seen).toLocaleString()}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Hardware */}
|
||||||
{data.services.length > 0 && (
|
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||||
<div className="text-xs text-muted-foreground mb-2">Services ({data.services.length})</div>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||||
<div className="flex flex-col gap-1.5">
|
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
||||||
{data.services.map((svc) => (
|
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
||||||
<ServiceBadge key={`${svc.port}-${svc.protocol}`} svc={svc} ip={data.ip} />
|
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
||||||
))}
|
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<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{services.length > 0 ? ` (${services.length})` : ''}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add service form */}
|
||||||
|
{addingService && (
|
||||||
|
<ServiceForm
|
||||||
|
form={newSvc}
|
||||||
|
onChange={setNewSvc}
|
||||||
|
onConfirm={handleAddService}
|
||||||
|
onCancel={() => setAddingForNode(null)}
|
||||||
|
confirmLabel="Add"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{services.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{services.length === 0 && !addingService && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{data.notes && (
|
{data.notes && (
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
@@ -82,20 +207,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||||
<Button
|
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1 gap-1.5"
|
|
||||||
onClick={() => onEdit(node.id)}
|
|
||||||
>
|
|
||||||
<Edit size={14} /> Edit
|
<Edit size={14} /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +218,11 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatStorage(gb: number): string {
|
||||||
|
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||||
|
return `${gb} GB`
|
||||||
|
}
|
||||||
|
|
||||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
@@ -117,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> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
web: '#00d4ff',
|
web: '#00d4ff',
|
||||||
database: '#a855f7',
|
database: '#a855f7',
|
||||||
@@ -126,39 +307,50 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
remote: '#8b949e',
|
remote: '#8b949e',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServiceUrl(svc: ServiceInfo, ip?: string): string | null {
|
function ServiceBadge({
|
||||||
if (!ip) return null
|
svc,
|
||||||
const name = svc.service_name.toLowerCase()
|
host,
|
||||||
const isHttps = name.includes('https') || name.includes('ssl') || svc.port === 443 || svc.port === 8443
|
onEdit,
|
||||||
const isHttp = name.includes('http') || [80, 8080, 8000, 3000, 8888, 9000, 8090, 7080].includes(svc.port)
|
onRemove,
|
||||||
if (isHttps) return `https://${ip}:${svc.port}`
|
}: {
|
||||||
if (isHttp) return `http://${ip}:${svc.port}`
|
svc: ServiceInfo
|
||||||
return null
|
host?: string
|
||||||
}
|
onEdit: () => void
|
||||||
|
onRemove: () => void
|
||||||
function ServiceBadge({ svc, ip }: { svc: ServiceInfo; ip?: string }) {
|
}) {
|
||||||
const url = getServiceUrl(svc, ip)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
|
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: '#21262d',
|
background: '#21262d',
|
||||||
borderColor: '#30363d',
|
borderColor: '#30363d',
|
||||||
...(url ? { cursor: 'pointer' } : {}),
|
cursor: url ? 'pointer' : 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
className="shrink-0 w-1.5 h-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
{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"
|
||||||
|
title="Remove service"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye } from 'lucide-react'
|
||||||
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
@@ -30,18 +31,20 @@ interface ScanRun {
|
|||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
|
onAddGroupRect: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
onNodeApproved: (nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||||
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
const onlineCount = nodes.filter((n) => n.data.status === 'online').length
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
const offlineCount = nodes.filter((n) => n.data.status === 'offline').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -68,13 +71,8 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 px-3 py-4 border-b border-border">
|
<div className="flex items-center px-3 py-4 border-b border-border overflow-hidden">
|
||||||
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[#00d4ff]/10 text-[#00d4ff] shrink-0">
|
<Logo size={28} showText={!collapsed} />
|
||||||
<Network size={16} />
|
|
||||||
</div>
|
|
||||||
{!collapsed && (
|
|
||||||
<span className="font-semibold text-sm tracking-wide text-foreground">Homelable</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Views */}
|
{/* Views */}
|
||||||
@@ -110,7 +108,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
|
|||||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span className="text-foreground font-mono">{nodes.length}</span>
|
<span className="text-foreground font-mono">{networkNodes.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[#39d353]">Online</span>
|
<span className="text-[#39d353]">Online</span>
|
||||||
@@ -126,7 +124,15 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
<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={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
|
<SidebarItem
|
||||||
|
icon={hideIp ? EyeOff : Eye}
|
||||||
|
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onClick={toggleHideIp}
|
||||||
|
active={hideIp}
|
||||||
|
/>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
@@ -297,7 +303,7 @@ function HiddenDevicesPanel() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useState(() => { load() })
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
const handleIgnore = async (id: string) => {
|
const handleIgnore = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,25 +1,89 @@
|
|||||||
import { Save, LayoutDashboard, Download } from 'lucide-react'
|
import { useRef } from 'react'
|
||||||
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onAutoLayout: () => void
|
onAutoLayout: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onChangeStyle: () => void
|
||||||
|
onUndo: () => void
|
||||||
|
onRedo: () => void
|
||||||
|
onShortcuts: () => void
|
||||||
|
onExportMd: () => void
|
||||||
|
onExportYaml: () => void
|
||||||
|
onImportYaml: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const content = ev.target?.result
|
||||||
|
if (typeof content === 'string') onImportYaml(content)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||||
|
<Logo size={28} showText={true} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={past.length === 0}
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<Undo2 size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={future.length === 0}
|
||||||
|
title="Redo (Ctrl+Y)"
|
||||||
|
>
|
||||||
|
<Redo2 size={14} />
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||||
|
<Palette size={14} /> Style
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
|
<Upload size={14} /> Import
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</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={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
|
<HelpCircle size={14} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
import type { ServiceInfo } from '@/types'
|
||||||
|
|
||||||
|
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
service_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getServiceUrl', () => {
|
||||||
|
it('returns null when no host is provided', () => {
|
||||||
|
expect(getServiceUrl(svc(80), undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for port 22 (SSH)', () => {
|
||||||
|
expect(getServiceUrl(svc(22, 'tcp', 'ssh'), '192.168.1.1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for UDP services', () => {
|
||||||
|
expect(getServiceUrl(svc(53, 'udp', 'dns'), '192.168.1.1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for known non-HTTP TCP ports', () => {
|
||||||
|
expect(getServiceUrl(svc(3306, 'tcp', 'mysql'), '192.168.1.1')).toBeNull()
|
||||||
|
expect(getServiceUrl(svc(5432, 'tcp', 'postgres'), '192.168.1.1')).toBeNull()
|
||||||
|
expect(getServiceUrl(svc(6379, 'tcp', 'redis'), '192.168.1.1')).toBeNull()
|
||||||
|
expect(getServiceUrl(svc(27017, 'tcp', 'mongodb'), '192.168.1.1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns http URL for standard HTTP port', () => {
|
||||||
|
expect(getServiceUrl(svc(80, 'tcp', 'http'), '192.168.1.10')).toBe('http://192.168.1.10:80')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns https URL for port 443', () => {
|
||||||
|
expect(getServiceUrl(svc(443, 'tcp', 'https'), '10.0.0.1')).toBe('https://10.0.0.1:443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns https URL for port 8443', () => {
|
||||||
|
expect(getServiceUrl(svc(8443), '10.0.0.1')).toBe('https://10.0.0.1:8443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns https URL when service name contains ssl', () => {
|
||||||
|
expect(getServiceUrl(svc(9443, 'tcp', 'my-ssl-app'), '10.0.0.1')).toBe('https://10.0.0.1:9443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns http URL for Sonarr on port 8989', () => {
|
||||||
|
expect(getServiceUrl(svc(8989, 'tcp', 'Sonarr'), '192.168.1.5')).toBe('http://192.168.1.5:8989')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns http URL for Radarr on port 7878', () => {
|
||||||
|
expect(getServiceUrl(svc(7878, 'tcp', 'Radarr'), '192.168.1.5')).toBe('http://192.168.1.5:7878')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns http URL for Jellyfin on port 8096', () => {
|
||||||
|
expect(getServiceUrl(svc(8096, 'tcp', 'Jellyfin'), '192.168.1.5')).toBe('http://192.168.1.5:8096')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns http URL for Proxmox web UI on port 8006', () => {
|
||||||
|
expect(getServiceUrl(svc(8006, 'tcp', 'Proxmox'), '192.168.1.100')).toBe('http://192.168.1.100:8006')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses host string directly (works with both IP and hostname)', () => {
|
||||||
|
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { DetailPanel } from '../DetailPanel'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: data.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Test Node',
|
||||||
|
type: 'server',
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode(nodeData)],
|
||||||
|
selectedNodeId: 'n1',
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DetailPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when no node is selected', () => {
|
||||||
|
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders node label and status', () => {
|
||||||
|
setupStore({ label: 'My Server', status: 'online' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('My Server')).toBeDefined()
|
||||||
|
expect(screen.getByText('online')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing for groupRect nodes', () => {
|
||||||
|
setupStore({ type: 'groupRect', label: 'Zone' })
|
||||||
|
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hardware section', () => {
|
||||||
|
it('does not render hardware section when no hardware data', () => {
|
||||||
|
setupStore({ label: 'Server' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Hardware')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders hardware section when cpu_count is set', () => {
|
||||||
|
setupStore({ cpu_count: 8 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
expect(screen.getByText('8')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cpu_model', () => {
|
||||||
|
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats ram_gb in GB', () => {
|
||||||
|
setupStore({ ram_gb: 32 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('32 GB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats ram_gb >= 1024 as TB', () => {
|
||||||
|
setupStore({ ram_gb: 2048 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('2 TB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats disk_gb in GB', () => {
|
||||||
|
setupStore({ disk_gb: 500 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('500 GB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats disk_gb >= 1024 as TB', () => {
|
||||||
|
setupStore({ disk_gb: 1536 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('1.5 TB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all hardware fields together', () => {
|
||||||
|
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
||||||
|
expect(screen.getByText('16')).toBeDefined()
|
||||||
|
expect(screen.getByText('128 GB')).toBeDefined()
|
||||||
|
expect(screen.getByText('4 TB')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
interface LogoProps {
|
||||||
|
size?: number;
|
||||||
|
showText?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117" />
|
||||||
|
<path
|
||||||
|
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||||
|
fill="#161b22"
|
||||||
|
stroke="#00d4ff"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
|
||||||
|
<circle cx="22" cy="38" r="2" fill="#39d353" />
|
||||||
|
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||||
|
<circle cx="42" cy="38" r="2" fill="#39d353" />
|
||||||
|
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||||
|
<circle cx="32" cy="24" r="2" fill="#a855f7" />
|
||||||
|
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
|
||||||
|
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
|
||||||
|
</svg>
|
||||||
|
{showText && (
|
||||||
|
<span
|
||||||
|
className="font-bold tracking-tight"
|
||||||
|
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#00d4ff' }}>Home</span>
|
||||||
|
<span style={{ color: '#ffffff' }}>lable</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,12 +23,17 @@ export function useStatusPolling() {
|
|||||||
if (STANDALONE || !isAuthenticated || !token) return
|
if (STANDALONE || !isAuthenticated || !token) return
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
const host = window.location.hostname
|
const host = window.location.host // includes port when non-standard
|
||||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}`
|
const url = `${protocol}://${host}/api/v1/status/ws/status`
|
||||||
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
// Send token as first message (not in URL to avoid log/history exposure)
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ token }))
|
||||||
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
|
|||||||
+11
-1
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
/* React Flow overrides */
|
/* React Flow overrides */
|
||||||
.react-flow__background {
|
.react-flow__background {
|
||||||
background-color: var(--surface-base) !important;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.react-flow__minimap {
|
.react-flow__minimap {
|
||||||
background-color: var(--surface-elevated) !important;
|
background-color: var(--surface-elevated) !important;
|
||||||
@@ -115,3 +115,13 @@
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Edge flow animation — dot traveling from source to target */
|
||||||
|
@keyframes flow-dot {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: -10000; }
|
||||||
|
}
|
||||||
|
.edge-flow-dot {
|
||||||
|
animation: flow-dot 2.5s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import LiveView from './components/LiveView.tsx'
|
||||||
|
|
||||||
|
const isLiveView = window.location.pathname === '/view'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
{isLiveView ? <LiveView /> : <App />}
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ describe('canvasStore', () => {
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
editingGroupRectId: null,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,18 +98,32 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onNodesChange marks unsaved', () => {
|
it('onNodesChange marks unsaved for position changes', () => {
|
||||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||||
useCanvasStore.getState().markSaved()
|
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)
|
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.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }])
|
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', () => {
|
it('onConnect adds an edge between two nodes', () => {
|
||||||
@@ -126,6 +144,13 @@ describe('canvasStore', () => {
|
|||||||
expect(edges[0].data?.label).toBe('uplink')
|
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', () => {
|
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' })
|
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
|
||||||
useCanvasStore.getState().onConnect(conn)
|
useCanvasStore.getState().onConnect(conn)
|
||||||
@@ -136,6 +161,15 @@ describe('canvasStore', () => {
|
|||||||
expect(edges[0].type).toBe('cluster')
|
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', () => {
|
it('addNode with parent_id sets parentId and extent', () => {
|
||||||
useCanvasStore.getState().addNode(makeNode('parent'))
|
useCanvasStore.getState().addNode(makeNode('parent'))
|
||||||
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
||||||
@@ -190,6 +224,40 @@ describe('canvasStore', () => {
|
|||||||
expect(updatedChild?.extent).toBeUndefined()
|
expect(updatedChild?.extent).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setEditingGroupRectId sets and clears the editing id', () => {
|
||||||
|
useCanvasStore.getState().setEditingGroupRectId('rect-1')
|
||||||
|
expect(useCanvasStore.getState().editingGroupRectId).toBe('rect-1')
|
||||||
|
useCanvasStore.getState().setEditingGroupRectId(null)
|
||||||
|
expect(useCanvasStore.getState().editingGroupRectId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setNodeZIndex updates the node zIndex and marks unsaved', () => {
|
||||||
|
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
useCanvasStore.getState().setNodeZIndex('n1', -5)
|
||||||
|
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(node?.zIndex).toBe(-5)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addNode with groupRect type preserves zIndex and dimensions', () => {
|
||||||
|
const rectNode: Node<NodeData> = {
|
||||||
|
id: 'rect-1',
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
zIndex: -9,
|
||||||
|
}
|
||||||
|
useCanvasStore.getState().addNode(rectNode)
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'rect-1')
|
||||||
|
expect(stored?.type).toBe('groupRect')
|
||||||
|
expect(stored?.zIndex).toBe(-9)
|
||||||
|
expect(stored?.width).toBe(360)
|
||||||
|
expect(stored?.height).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
it('loadCanvas sorts parents before children', () => {
|
it('loadCanvas sorts parents before children', () => {
|
||||||
const parent = makeNode('p1')
|
const parent = makeNode('p1')
|
||||||
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
||||||
@@ -199,4 +267,131 @@ describe('canvasStore', () => {
|
|||||||
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
||||||
expect(parentIdx).toBeLessThan(childIdx)
|
expect(parentIdx).toBeLessThan(childIdx)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- History (undo/redo) ---
|
||||||
|
|
||||||
|
it('snapshotHistory pushes current state to past and clears future', () => {
|
||||||
|
const { addNode, snapshotHistory } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
const { past, future } = useCanvasStore.getState()
|
||||||
|
expect(past).toHaveLength(1)
|
||||||
|
expect(past[0].nodes).toHaveLength(1)
|
||||||
|
expect(future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo restores previous state and moves current to future', () => {
|
||||||
|
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
const { nodes, past, future } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(nodes[0].id).toBe('n1')
|
||||||
|
expect(past).toHaveLength(0)
|
||||||
|
expect(future).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redo re-applies undone state', () => {
|
||||||
|
const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
redo()
|
||||||
|
const { nodes, future } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(2)
|
||||||
|
expect(future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo does nothing when past is empty', () => {
|
||||||
|
const { addNode, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
undo()
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapshotHistory clears future (new branch)', () => {
|
||||||
|
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
// now take a new action
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n3'))
|
||||||
|
expect(useCanvasStore.getState().future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Clipboard (copy/paste) ---
|
||||||
|
|
||||||
|
it('copySelectedNodes stores only selected nodes', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ ...makeNode('a'), selected: true },
|
||||||
|
{ ...makeNode('b'), selected: false },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
const { clipboard } = useCanvasStore.getState()
|
||||||
|
expect(clipboard).toHaveLength(1)
|
||||||
|
expect(clipboard[0].id).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
||||||
|
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(2)
|
||||||
|
const pasted = nodes.find((n) => n.id !== 'src')!
|
||||||
|
expect(pasted).toBeDefined()
|
||||||
|
expect(pasted.position.x).toBe(150)
|
||||||
|
expect(pasted.position.y).toBe(150)
|
||||||
|
expect(pasted.selected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
|
||||||
|
describe('themeStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with default theme', () => {
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setTheme updates activeTheme', () => {
|
||||||
|
useThemeStore.getState().setTheme('matrix')
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setTheme can switch between all presets', () => {
|
||||||
|
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
|
||||||
|
for (const id of themes) {
|
||||||
|
useThemeStore.getState().setTheme(id)
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setTheme back to default after neon', () => {
|
||||||
|
useThemeStore.getState().setTheme('neon')
|
||||||
|
useThemeStore.getState().setTheme('default')
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe('default')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
|
||||||
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
@@ -18,6 +21,18 @@ interface CanvasState {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
|
|
||||||
|
// History
|
||||||
|
past: HistoryEntry[]
|
||||||
|
future: HistoryEntry[]
|
||||||
|
snapshotHistory: () => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
clipboard: Node<NodeData>[]
|
||||||
|
copySelectedNodes: () => void
|
||||||
|
pasteNodes: () => void
|
||||||
|
|
||||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
onConnect: (connection: Connection) => void
|
onConnect: (connection: Connection) => void
|
||||||
@@ -28,9 +43,15 @@ interface CanvasState {
|
|||||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||||
deleteEdge: (id: string) => void
|
deleteEdge: (id: string) => void
|
||||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||||
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
|
editingGroupRectId: string | null
|
||||||
|
setEditingGroupRectId: (id: string | null) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
|
markUnsaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
|
hideIp: boolean
|
||||||
|
toggleHideIp: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||||
@@ -38,18 +59,81 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
editingGroupRectId: null,
|
||||||
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
|
|
||||||
|
snapshotHistory: () =>
|
||||||
|
set((state) => ({
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
})),
|
||||||
|
|
||||||
|
undo: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.past.length === 0) return state
|
||||||
|
const previous = state.past[state.past.length - 1]
|
||||||
|
return {
|
||||||
|
nodes: previous.nodes,
|
||||||
|
edges: previous.edges,
|
||||||
|
past: state.past.slice(0, -1),
|
||||||
|
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
redo: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.future.length === 0) return state
|
||||||
|
const next = state.future[0]
|
||||||
|
return {
|
||||||
|
nodes: next.nodes,
|
||||||
|
edges: next.edges,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: state.future.slice(1),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
copySelectedNodes: () =>
|
||||||
|
set((state) => ({
|
||||||
|
clipboard: state.nodes.filter((n) => n.selected),
|
||||||
|
})),
|
||||||
|
|
||||||
|
pasteNodes: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.clipboard.length === 0) return state
|
||||||
|
const newNodes = state.clipboard.map((n) => ({
|
||||||
|
...n,
|
||||||
|
id: generateUUID(),
|
||||||
|
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||||
|
selected: false,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
data: { ...n.data, parent_id: undefined },
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
nodes: [...state.nodes, ...newNodes],
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
onNodesChange: (changes) =>
|
onNodesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: applyNodeChanges(changes, state.nodes),
|
nodes: applyNodeChanges(changes, state.nodes),
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
onEdgesChange: (changes) =>
|
onEdgesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: applyEdgeChanges(changes, state.edges),
|
edges: applyEdgeChanges(changes, state.edges),
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
onConnect: (connection) =>
|
onConnect: (connection) =>
|
||||||
@@ -66,7 +150,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
sourceHandle: normalizeHandle(extra.sourceHandle),
|
sourceHandle: normalizeHandle(extra.sourceHandle),
|
||||||
targetHandle: normalizeHandle(extra.targetHandle),
|
targetHandle: normalizeHandle(extra.targetHandle),
|
||||||
type: edgeType,
|
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),
|
}, state.edges),
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
}
|
}
|
||||||
@@ -79,10 +163,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
const enriched = node.data.parent_id
|
const enriched = node.data.parent_id
|
||||||
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
|
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
|
||||||
: node
|
: 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)
|
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||||
if (enriched.parentId) {
|
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 }
|
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
|
||||||
}),
|
}),
|
||||||
@@ -96,12 +183,20 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
deleteNode: (id) =>
|
deleteNode: (id) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
nodes: state.nodes.filter((n) => n.id !== id),
|
const idsToRemove = new Set<string>()
|
||||||
edges: state.edges.filter((e) => e.source !== id && e.target !== id),
|
const collect = (nodeId: string) => {
|
||||||
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
|
idsToRemove.add(nodeId)
|
||||||
hasUnsavedChanges: true,
|
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) =>
|
updateEdge: (id, data) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -141,14 +236,26 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
return { nodes, hasUnsavedChanges: true }
|
return { nodes, hasUnsavedChanges: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setNodeZIndex: (id, zIndex) =>
|
||||||
|
set((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
})),
|
||||||
|
|
||||||
|
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||||
|
|
||||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||||
|
|
||||||
|
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|
||||||
|
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
|
||||||
|
|
||||||
loadCanvas: (nodes, edges) => {
|
loadCanvas: (nodes, edges) => {
|
||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
const parents = nodes.filter((n) => !n.parentId)
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
const children = 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: [] })
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { ThemeId } from '@/utils/themes'
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
activeTheme: ThemeId
|
||||||
|
setTheme: (id: ThemeId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>((set) => ({
|
||||||
|
activeTheme: 'default',
|
||||||
|
setTheme: (id) => set({ activeTheme: id }),
|
||||||
|
}))
|
||||||
@@ -13,7 +13,20 @@ export type NodeType =
|
|||||||
| 'printer'
|
| 'printer'
|
||||||
| 'computer'
|
| 'computer'
|
||||||
| 'cpl'
|
| 'cpl'
|
||||||
|
| 'docker'
|
||||||
| 'generic'
|
| 'generic'
|
||||||
|
| 'groupRect'
|
||||||
|
|
||||||
|
export type TextPosition =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'middle-left'
|
||||||
|
| 'center'
|
||||||
|
| 'middle-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right'
|
||||||
|
|
||||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
||||||
|
|
||||||
@@ -43,9 +56,29 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
last_seen?: string
|
last_seen?: string
|
||||||
response_time_ms?: number
|
response_time_ms?: number
|
||||||
notes?: string
|
notes?: string
|
||||||
|
cpu_count?: number
|
||||||
|
cpu_model?: string
|
||||||
|
ram_gb?: number
|
||||||
|
disk_gb?: number
|
||||||
|
show_hardware?: boolean
|
||||||
parent_id?: string
|
parent_id?: string
|
||||||
container_mode?: boolean
|
container_mode?: boolean
|
||||||
custom_colors?: { border?: string; background?: string; icon?: string }
|
custom_colors?: {
|
||||||
|
border?: string
|
||||||
|
background?: string
|
||||||
|
icon?: string
|
||||||
|
// Group rectangle extras (type === 'groupRect')
|
||||||
|
text_color?: string
|
||||||
|
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
|
||||||
|
}
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +91,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
speed?: string
|
speed?: string
|
||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
|
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||||
@@ -75,7 +109,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
printer: 'Printer',
|
printer: 'Printer',
|
||||||
computer: 'Computer',
|
computer: 'Computer',
|
||||||
cpl: 'CPL / Powerline',
|
cpl: 'CPL / Powerline',
|
||||||
|
docker: 'Docker Host',
|
||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
|
groupRect: 'Group Rectangle',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NodeType, EdgeType, CheckMethod } from '@/types'
|
||||||
|
|
||||||
|
export interface YamlNodeConnection {
|
||||||
|
label: string
|
||||||
|
linkType?: EdgeType
|
||||||
|
linkLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YamlNode {
|
||||||
|
nodeType: NodeType
|
||||||
|
nodeIcon?: string
|
||||||
|
label: string
|
||||||
|
hostname?: string
|
||||||
|
ipAddress?: string
|
||||||
|
checkMethod?: CheckMethod
|
||||||
|
checkTarget?: string
|
||||||
|
notes?: string
|
||||||
|
links?: YamlNodeConnection[]
|
||||||
|
parent?: YamlNodeConnection
|
||||||
|
clusterR?: YamlNodeConnection
|
||||||
|
clusterL?: YamlNodeConnection
|
||||||
|
cpuModel?: string
|
||||||
|
cpuCore?: number
|
||||||
|
ram?: number
|
||||||
|
disk?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import {
|
||||||
|
serializeNode,
|
||||||
|
serializeEdge,
|
||||||
|
deserializeApiNode,
|
||||||
|
deserializeApiEdge,
|
||||||
|
type ApiNode,
|
||||||
|
type ApiEdge,
|
||||||
|
} from '@/utils/canvasSerializer'
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeRfNode(overrides: Partial<Node<NodeData>> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 100, y: 200 },
|
||||||
|
data: {
|
||||||
|
label: 'My Server',
|
||||||
|
type: 'server',
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApiNode(overrides: Partial<ApiNode> = {}): ApiNode {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: 'server',
|
||||||
|
label: 'My Server',
|
||||||
|
pos_x: 100,
|
||||||
|
pos_y: 200,
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRfEdge(overrides: Partial<Edge<EdgeData>> = {}): Edge<EdgeData> {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'n1',
|
||||||
|
target: 'n2',
|
||||||
|
type: 'ethernet',
|
||||||
|
data: { type: 'ethernet' },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApiEdge(overrides: Partial<ApiEdge> = {}): ApiEdge {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'n1',
|
||||||
|
target: 'n2',
|
||||||
|
type: 'ethernet',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── serializeNode — regular nodes ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('serializeNode — regular node', () => {
|
||||||
|
it('maps position to pos_x/pos_y', () => {
|
||||||
|
const result = serializeNode(makeRfNode({ position: { x: 42, y: 99 } }))
|
||||||
|
expect(result.pos_x).toBe(42)
|
||||||
|
expect(result.pos_y).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes all data fields', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
data: {
|
||||||
|
label: 'Router', type: 'router', status: 'online', services: [],
|
||||||
|
hostname: 'gw.local', ip: '192.168.1.1', mac: 'aa:bb:cc:dd:ee:ff',
|
||||||
|
os: 'OpenWRT', check_method: 'ping', check_target: '192.168.1.1',
|
||||||
|
notes: 'main router',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect(result.hostname).toBe('gw.local')
|
||||||
|
expect(result.ip).toBe('192.168.1.1')
|
||||||
|
expect(result.mac).toBe('aa:bb:cc:dd:ee:ff')
|
||||||
|
expect(result.os).toBe('OpenWRT')
|
||||||
|
expect(result.check_method).toBe('ping')
|
||||||
|
expect(result.check_target).toBe('192.168.1.1')
|
||||||
|
expect(result.notes).toBe('main router')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes width and height when node has been resized', () => {
|
||||||
|
const node = makeRfNode({ width: 280, height: 120 })
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect(result.width).toBe(280)
|
||||||
|
expect(result.height).toBe(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes width/height as null when node has default size', () => {
|
||||||
|
const result = serializeNode(makeRfNode())
|
||||||
|
expect(result.width).toBeNull()
|
||||||
|
expect(result.height).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes hardware fields', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
data: {
|
||||||
|
label: 'Server', type: 'server', status: 'online', services: [],
|
||||||
|
cpu_count: 8, cpu_model: 'Intel i7', ram_gb: 32, disk_gb: 500, show_hardware: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect(result.cpu_count).toBe(8)
|
||||||
|
expect(result.cpu_model).toBe('Intel i7')
|
||||||
|
expect(result.ram_gb).toBe(32)
|
||||||
|
expect(result.disk_gb).toBe(500)
|
||||||
|
expect(result.show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes custom_colors', () => {
|
||||||
|
const node = makeRfNode({ data: { label: 'S', type: 'server', status: 'unknown', services: [], custom_colors: { border: '#ff0000' } } })
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect(result.custom_colors).toEqual({ border: '#ff0000' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes parent_id and container_mode', () => {
|
||||||
|
const node = makeRfNode({ data: { label: 'VM', type: 'vm', status: 'unknown', services: [], parent_id: 'px1', container_mode: false } })
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect(result.parent_id).toBe('px1')
|
||||||
|
expect(result.container_mode).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nulls optional fields when absent', () => {
|
||||||
|
const result = serializeNode(makeRfNode())
|
||||||
|
expect(result.hostname).toBeNull()
|
||||||
|
expect(result.ip).toBeNull()
|
||||||
|
expect(result.mac).toBeNull()
|
||||||
|
expect(result.os).toBeNull()
|
||||||
|
expect(result.check_method).toBeNull()
|
||||||
|
expect(result.check_target).toBeNull()
|
||||||
|
expect(result.notes).toBeNull()
|
||||||
|
expect(result.parent_id).toBeNull()
|
||||||
|
expect(result.cpu_count).toBeNull()
|
||||||
|
expect(result.cpu_model).toBeNull()
|
||||||
|
expect(result.ram_gb).toBeNull()
|
||||||
|
expect(result.disk_gb).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── serializeNode — groupRect ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('serializeNode — groupRect', () => {
|
||||||
|
it('stores dimensions inside custom_colors', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
type: 'groupRect',
|
||||||
|
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
|
||||||
|
width: 400,
|
||||||
|
height: 250,
|
||||||
|
})
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
|
||||||
|
expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to measured dimensions over explicit width/height', () => {
|
||||||
|
const node: Node<NodeData> = {
|
||||||
|
...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }),
|
||||||
|
measured: { width: 420, height: 260 },
|
||||||
|
}
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect((result.custom_colors as Record<string, unknown>).width).toBe(420)
|
||||||
|
expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaults when no dimensions available', () => {
|
||||||
|
const node = makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] } })
|
||||||
|
const result = serializeNode(node)
|
||||||
|
expect((result.custom_colors as Record<string, unknown>).width).toBe(360)
|
||||||
|
expect((result.custom_colors as Record<string, unknown>).height).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves existing custom_colors fields alongside dimensions', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
type: 'groupRect',
|
||||||
|
data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [], custom_colors: { border: '#aaa', z_order: 2 } },
|
||||||
|
width: 300, height: 200,
|
||||||
|
})
|
||||||
|
const result = serializeNode(node)
|
||||||
|
const cc = result.custom_colors as Record<string, unknown>
|
||||||
|
expect(cc.border).toBe('#aaa')
|
||||||
|
expect(cc.z_order).toBe(2)
|
||||||
|
expect(cc.width).toBe(300)
|
||||||
|
expect(cc.height).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── serializeEdge ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('serializeEdge', () => {
|
||||||
|
it('serializes basic fields', () => {
|
||||||
|
const result = serializeEdge(makeRfEdge())
|
||||||
|
expect(result.id).toBe('e1')
|
||||||
|
expect(result.source).toBe('n1')
|
||||||
|
expect(result.target).toBe('n2')
|
||||||
|
expect(result.type).toBe('ethernet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes top-t handle to top', () => {
|
||||||
|
const result = serializeEdge(makeRfEdge({ sourceHandle: 'top-t', targetHandle: 'bottom-t' }))
|
||||||
|
expect(result.source_handle).toBe('top')
|
||||||
|
expect(result.target_handle).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through non-stub handles unchanged', () => {
|
||||||
|
const result = serializeEdge(makeRfEdge({ sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }))
|
||||||
|
expect(result.source_handle).toBe('cluster-right')
|
||||||
|
expect(result.target_handle).toBe('cluster-left')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes optional edge data', () => {
|
||||||
|
const edge = makeRfEdge({ data: { type: 'vlan', label: 'uplink', vlan_id: 10, custom_color: '#ff0', path_style: 'smooth', animated: true } })
|
||||||
|
const result = serializeEdge(edge)
|
||||||
|
expect(result.label).toBe('uplink')
|
||||||
|
expect(result.vlan_id).toBe(10)
|
||||||
|
expect(result.custom_color).toBe('#ff0')
|
||||||
|
expect(result.path_style).toBe('smooth')
|
||||||
|
expect(result.animated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nulls optional fields when absent', () => {
|
||||||
|
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
|
||||||
|
expect(result.source_handle).toBeNull()
|
||||||
|
expect(result.target_handle).toBeNull()
|
||||||
|
expect(result.label).toBeNull()
|
||||||
|
expect(result.vlan_id).toBeNull()
|
||||||
|
expect(result.custom_color).toBeNull()
|
||||||
|
expect(result.path_style).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── deserializeApiNode — regular nodes ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('deserializeApiNode — regular node', () => {
|
||||||
|
const emptyMap = new Map<string, boolean>()
|
||||||
|
|
||||||
|
it('maps pos_x/pos_y to position', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode({ pos_x: 50, pos_y: 75 }), emptyMap)
|
||||||
|
expect(result.position).toEqual({ x: 50, y: 75 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores width and height when node was resized', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode({ width: 280, height: 120 }), emptyMap)
|
||||||
|
expect(result.width).toBe(280)
|
||||||
|
expect(result.height).toBe(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves width/height undefined for default-sized nodes', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode(), emptyMap)
|
||||||
|
expect(result.width).toBeUndefined()
|
||||||
|
expect(result.height).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets parentId and extent for children of container proxmox', () => {
|
||||||
|
const map = new Map([['px1', true]])
|
||||||
|
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
||||||
|
expect(result.parentId).toBe('px1')
|
||||||
|
expect(result.extent).toBe('parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not set parentId when parent is not in container mode', () => {
|
||||||
|
const map = new Map([['px1', false]])
|
||||||
|
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
||||||
|
expect(result.parentId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets proxmox dimensions using saved values', () => {
|
||||||
|
const result = deserializeApiNode(
|
||||||
|
makeApiNode({ type: 'proxmox', container_mode: true, width: 450, height: 300 }),
|
||||||
|
emptyMap,
|
||||||
|
)
|
||||||
|
expect(result.width).toBe(450)
|
||||||
|
expect(result.height).toBe(300)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to 300x200 for proxmox container with no saved dimensions', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: true }), emptyMap)
|
||||||
|
expect(result.width).toBe(300)
|
||||||
|
expect(result.height).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not set dimensions for non-container proxmox', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: false }), emptyMap)
|
||||||
|
expect(result.width).toBeUndefined()
|
||||||
|
expect(result.height).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── deserializeApiNode — groupRect ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('deserializeApiNode — groupRect', () => {
|
||||||
|
const emptyMap = new Map<string, boolean>()
|
||||||
|
|
||||||
|
it('restores width/height from custom_colors', () => {
|
||||||
|
const result = deserializeApiNode(
|
||||||
|
makeApiNode({ type: 'groupRect', custom_colors: { width: 400, height: 250, z_order: 2 } }),
|
||||||
|
emptyMap,
|
||||||
|
)
|
||||||
|
expect(result.width).toBe(400)
|
||||||
|
expect(result.height).toBe(250)
|
||||||
|
expect(result.zIndex).toBe(-8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to 360x240 when custom_colors has no dimensions', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode({ type: 'groupRect' }), emptyMap)
|
||||||
|
expect(result.width).toBe(360)
|
||||||
|
expect(result.height).toBe(240)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── deserializeApiEdge ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('deserializeApiEdge', () => {
|
||||||
|
it('maps source_handle/target_handle to sourceHandle/targetHandle', () => {
|
||||||
|
const result = deserializeApiEdge(makeApiEdge({ source_handle: 'top', target_handle: 'bottom' }))
|
||||||
|
expect(result.sourceHandle).toBe('top')
|
||||||
|
expect(result.targetHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets sourceHandle/targetHandle to null when absent', () => {
|
||||||
|
const result = deserializeApiEdge(makeApiEdge())
|
||||||
|
expect(result.sourceHandle).toBeNull()
|
||||||
|
expect(result.targetHandle).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves id, source, target, type', () => {
|
||||||
|
const result = deserializeApiEdge(makeApiEdge({ id: 'e99', source: 'a', target: 'b', type: 'wifi' }))
|
||||||
|
expect(result.id).toBe('e99')
|
||||||
|
expect(result.source).toBe('a')
|
||||||
|
expect(result.target).toBe('b')
|
||||||
|
expect(result.type).toBe('wifi')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Round-trip ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('round-trip: serialize → deserialize', () => {
|
||||||
|
const emptyMap = new Map<string, boolean>()
|
||||||
|
|
||||||
|
it('preserves position through serialize/deserialize', () => {
|
||||||
|
const node = makeRfNode({ position: { x: 123, y: 456 } })
|
||||||
|
const serialized = serializeNode(node) as ApiNode
|
||||||
|
const restored = deserializeApiNode(serialized, emptyMap)
|
||||||
|
expect(restored.position).toEqual({ x: 123, y: 456 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves width/height through serialize/deserialize', () => {
|
||||||
|
const node = makeRfNode({ width: 300, height: 160 })
|
||||||
|
const serialized = serializeNode(node) as ApiNode
|
||||||
|
const restored = deserializeApiNode(serialized, emptyMap)
|
||||||
|
expect(restored.width).toBe(300)
|
||||||
|
expect(restored.height).toBe(160)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves null width/height for default-sized nodes', () => {
|
||||||
|
const node = makeRfNode()
|
||||||
|
const serialized = serializeNode(node) as ApiNode
|
||||||
|
const restored = deserializeApiNode(serialized, emptyMap)
|
||||||
|
expect(restored.width).toBeUndefined()
|
||||||
|
expect(restored.height).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves groupRect dimensions through serialize/deserialize', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
type: 'groupRect',
|
||||||
|
data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] },
|
||||||
|
width: 500,
|
||||||
|
height: 300,
|
||||||
|
})
|
||||||
|
const serialized = serializeNode(node) as ApiNode
|
||||||
|
const restored = deserializeApiNode(serialized, emptyMap)
|
||||||
|
expect(restored.width).toBe(500)
|
||||||
|
expect(restored.height).toBe(300)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { generateMarkdownTable } from '../exportMarkdown'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<NodeData> = {}, id = '1'): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateMarkdownTable', () => {
|
||||||
|
it('returns empty string for empty node list', () => {
|
||||||
|
expect(generateMarkdownTable([])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes', () => {
|
||||||
|
const nodes = [makeNode({ type: 'groupRect', label: 'Zone' })]
|
||||||
|
expect(generateMarkdownTable(nodes)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates header + separator + row', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Router', type: 'router', ip: '192.168.1.1', status: 'online' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
const lines = md.split('\n')
|
||||||
|
expect(lines[0]).toContain('Label')
|
||||||
|
expect(lines[0]).toContain('IP')
|
||||||
|
expect(lines[1]).toContain('---')
|
||||||
|
expect(lines[2]).toContain('Router')
|
||||||
|
expect(lines[2]).toContain('192.168.1.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses — for missing fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Node', type: 'generic', ip: undefined, hostname: undefined })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists services as name:port pairs', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Server',
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }, { port: 443, protocol: 'tcp', service_name: 'https' }],
|
||||||
|
})]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('nginx:80')
|
||||||
|
expect(md).toContain('https:443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes pipe characters in cell values', () => {
|
||||||
|
const nodes = [makeNode({ label: 'A|B' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('A\\|B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates one row per non-groupRect node', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ type: 'server', label: 'A' }, '1'),
|
||||||
|
makeNode({ type: 'router', label: 'B' }, '2'),
|
||||||
|
makeNode({ type: 'groupRect', label: 'Zone' }, '3'),
|
||||||
|
]
|
||||||
|
const lines = generateMarkdownTable(nodes).split('\n')
|
||||||
|
// header + separator + 2 data rows
|
||||||
|
expect(lines).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportCanvasToYaml } from '../exportYaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<NodeData> = {}, id = '1', parentId?: string): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
parentId,
|
||||||
|
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeEdge = (id: string, source: string, target: string, data: Partial<EdgeData> = {}): Edge<EdgeData> => ({
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exportCanvasToYaml', () => {
|
||||||
|
it('serializes a simple node with basic fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'My Server', type: 'server', ip: '192.168.1.10', hostname: 'srv.local' })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry.nodeType).toBe('server')
|
||||||
|
expect(entry.label).toBe('My Server')
|
||||||
|
expect(entry.ipAddress).toBe('192.168.1.10')
|
||||||
|
expect(entry.hostname).toBe('srv.local')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits empty/null/undefined optional fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Router', type: 'router', hostname: undefined, ip: undefined, notes: undefined })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry).not.toHaveProperty('hostname')
|
||||||
|
expect(entry).not.toHaveProperty('ipAddress')
|
||||||
|
expect(entry).not.toHaveProperty('notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits hardware specs when zero or falsy', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 0, ram_gb: 0, disk_gb: 0 })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry).not.toHaveProperty('cpuCore')
|
||||||
|
expect(entry).not.toHaveProperty('ram')
|
||||||
|
expect(entry).not.toHaveProperty('disk')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes hardware specs when non-zero', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 16, ram_gb: 64, disk_gb: 2000, cpu_model: 'Intel Xeon' })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry.cpuCore).toBe(16)
|
||||||
|
expect(entry.ram).toBe(64)
|
||||||
|
expect(entry.disk).toBe(2000)
|
||||||
|
expect(entry.cpuModel).toBe('Intel Xeon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes parent relationship from parentId', () => {
|
||||||
|
const parent = makeNode({ label: 'Proxmox1', type: 'proxmox' }, 'pve1')
|
||||||
|
const child = makeNode({ label: 'VM1', type: 'vm' }, 'vm1', 'pve1')
|
||||||
|
const edge = makeEdge('e1', 'pve1', 'vm1', { type: 'virtual' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([parent, child], [edge])) as object[]
|
||||||
|
const childEntry = (result as Record<string, unknown>[]).find((e) => e.label === 'VM1')!
|
||||||
|
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes cluster-type edge as clusterR on source node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
|
||||||
|
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
|
||||||
|
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster', label: '10GbE' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'PVE1')!
|
||||||
|
expect(entryA.clusterR).toEqual({ label: 'PVE2', linkType: 'cluster', linkLabel: '10GbE' })
|
||||||
|
expect(entryA).not.toHaveProperty('links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes cluster-type incoming edge as clusterL on target node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
|
||||||
|
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
|
||||||
|
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'PVE1')!
|
||||||
|
const entryB = result.find((e) => e.label === 'PVE2')!
|
||||||
|
// edge serialized as clusterR on A — should NOT also appear as clusterL on B
|
||||||
|
expect(entryA.clusterR).toBeDefined()
|
||||||
|
expect(entryB).not.toHaveProperty('clusterL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes regular ethernet edge in links array on source node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||||
|
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
const edge = makeEdge('e1', 'sw', 's1', { type: 'ethernet', label: 'eth0' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'Switch')!
|
||||||
|
const entryB = result.find((e) => e.label === 'Server1')!
|
||||||
|
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'ethernet', linkLabel: 'eth0' }])
|
||||||
|
expect(entryB).not.toHaveProperty('links')
|
||||||
|
expect(entryA).not.toHaveProperty('clusterR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes multiple outgoing edges as links array', () => {
|
||||||
|
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||||
|
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
const s2 = makeNode({ label: 'Server2', type: 'server' }, 's2')
|
||||||
|
const s3 = makeNode({ label: 'Server3', type: 'server' }, 's3')
|
||||||
|
const edges = [
|
||||||
|
makeEdge('e1', 'sw', 's1', { type: 'ethernet' }),
|
||||||
|
makeEdge('e2', 'sw', 's2', { type: 'ethernet' }),
|
||||||
|
makeEdge('e3', 'sw', 's3', { type: 'wifi' }),
|
||||||
|
]
|
||||||
|
const result = yaml.load(exportCanvasToYaml([sw, s1, s2, s3], edges)) as Record<string, unknown>[]
|
||||||
|
const swEntry = result.find((e) => e.label === 'Switch')!
|
||||||
|
const links = swEntry.links as Record<string, unknown>[]
|
||||||
|
expect(links).toHaveLength(3)
|
||||||
|
expect(links.map((l) => l.label)).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3']))
|
||||||
|
// Servers should have no links (edges are on source side)
|
||||||
|
for (const label of ['Server1', 'Server2', 'Server3']) {
|
||||||
|
const entry = result.find((e) => e.label === label)!
|
||||||
|
expect(entry).not.toHaveProperty('links')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not duplicate a links edge on the target node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'NodeA', type: 'server' }, 'a')
|
||||||
|
const nodeB = makeNode({ label: 'NodeB', type: 'server' }, 'b')
|
||||||
|
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'NodeA')!
|
||||||
|
const entryB = result.find((e) => e.label === 'NodeB')!
|
||||||
|
expect(entryA.links).toHaveLength(1)
|
||||||
|
expect(entryB).not.toHaveProperty('links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes from output', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ label: 'Zone', type: 'groupRect' }, '1'),
|
||||||
|
makeNode({ label: 'Server', type: 'server' }, '2'),
|
||||||
|
]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect((result[0] as Record<string, unknown>).label).toBe('Server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('roundtrip: all non-empty fields appear in YAML output', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Full Node',
|
||||||
|
type: 'server',
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
hostname: 'full.local',
|
||||||
|
check_method: 'ping',
|
||||||
|
check_target: '10.0.0.1',
|
||||||
|
notes: 'test notes',
|
||||||
|
cpu_model: 'AMD EPYC',
|
||||||
|
cpu_count: 32,
|
||||||
|
ram_gb: 128,
|
||||||
|
disk_gb: 4000,
|
||||||
|
custom_icon: 'star',
|
||||||
|
})]
|
||||||
|
const yamlStr = exportCanvasToYaml(nodes, [])
|
||||||
|
expect(yamlStr).toContain('Full Node')
|
||||||
|
expect(yamlStr).toContain('10.0.0.1')
|
||||||
|
expect(yamlStr).toContain('full.local')
|
||||||
|
expect(yamlStr).toContain('ping')
|
||||||
|
expect(yamlStr).toContain('test notes')
|
||||||
|
expect(yamlStr).toContain('AMD EPYC')
|
||||||
|
expect(yamlStr).toContain('32')
|
||||||
|
expect(yamlStr).toContain('128')
|
||||||
|
expect(yamlStr).toContain('4000')
|
||||||
|
expect(yamlStr).toContain('star')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { parseYamlToCanvas } from '../importYaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
// Mock dagre layout to return nodes with predictable positions
|
||||||
|
vi.mock('../layout', () => ({
|
||||||
|
applyDagreLayout: (nodes: Node<NodeData>[]) =>
|
||||||
|
nodes.map((n, i) => ({ ...n, position: { x: i * 200, y: 0 } })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock uuid to return deterministic ids
|
||||||
|
let uuidCounter = 0
|
||||||
|
vi.mock('../uuid', () => ({
|
||||||
|
generateUUID: () => `test-uuid-${++uuidCounter}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
uuidCounter = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const empty: Node<NodeData>[] = []
|
||||||
|
const emptyEdges: Edge<EdgeData>[] = []
|
||||||
|
|
||||||
|
describe('parseYamlToCanvas', () => {
|
||||||
|
it('parses a minimal node (only nodeType + label)', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "My Server"
|
||||||
|
`
|
||||||
|
const { nodes, edges, imported } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(imported).toBe(1)
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(nodes[0].data.label).toBe('My Server')
|
||||||
|
expect(nodes[0].data.type).toBe('server')
|
||||||
|
expect(nodes[0].data.status).toBe('unknown')
|
||||||
|
expect(edges).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses all scalar fields', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
hostname: "pve1.local"
|
||||||
|
ipAddress: "192.168.1.10"
|
||||||
|
checkMethod: ping
|
||||||
|
checkTarget: "192.168.1.10"
|
||||||
|
notes: "main host"
|
||||||
|
nodeIcon: "custom-icon"
|
||||||
|
cpuModel: "Intel Xeon"
|
||||||
|
cpuCore: 16
|
||||||
|
ram: 64
|
||||||
|
disk: 2000
|
||||||
|
`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const d = nodes[0].data
|
||||||
|
expect(d.hostname).toBe('pve1.local')
|
||||||
|
expect(d.ip).toBe('192.168.1.10')
|
||||||
|
expect(d.check_method).toBe('ping')
|
||||||
|
expect(d.check_target).toBe('192.168.1.10')
|
||||||
|
expect(d.notes).toBe('main host')
|
||||||
|
expect(d.custom_icon).toBe('custom-icon')
|
||||||
|
expect(d.cpu_model).toBe('Intel Xeon')
|
||||||
|
expect(d.cpu_count).toBe(16)
|
||||||
|
expect(d.ram_gb).toBe(64)
|
||||||
|
expect(d.disk_gb).toBe(2000)
|
||||||
|
expect(d.show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets show_hardware only when hardware fields present', () => {
|
||||||
|
const yaml = `- nodeType: server\n label: "NoHW"\n`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes[0].data.show_hardware).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parent relationship sets parentId and creates an edge', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
- nodeType: vm
|
||||||
|
label: "VM1"
|
||||||
|
parent:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: virtual
|
||||||
|
linkLabel: "hosted"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const vm = nodes.find((n) => n.data.label === 'VM1')!
|
||||||
|
const pve = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
expect(vm.parentId).toBe(pve.id)
|
||||||
|
expect(vm.data.parent_id).toBe(pve.id)
|
||||||
|
expect(vm.extent).toBe('parent')
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve.id)
|
||||||
|
expect(edges[0].target).toBe(vm.id)
|
||||||
|
expect(edges[0].type).toBe('virtual')
|
||||||
|
expect(edges[0].data?.label).toBe('hosted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clusterR creates an edge from this node to target', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
linkLabel: "10GbE"
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve1.id)
|
||||||
|
expect(edges[0].target).toBe(pve2.id)
|
||||||
|
expect(edges[0].type).toBe('ethernet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clusterL creates an edge from referenced node to this node', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
clusterL:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: cluster
|
||||||
|
linkLabel: ""
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve1.id)
|
||||||
|
expect(edges[0].target).toBe(pve2.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links array creates multiple edges from this node', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: switch
|
||||||
|
label: "Switch"
|
||||||
|
links:
|
||||||
|
- label: "Server1"
|
||||||
|
linkType: ethernet
|
||||||
|
- label: "Server2"
|
||||||
|
linkType: ethernet
|
||||||
|
- label: "Server3"
|
||||||
|
linkType: wifi
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server1"
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server2"
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server3"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const sw = nodes.find((n) => n.data.label === 'Switch')!
|
||||||
|
expect(edges).toHaveLength(3)
|
||||||
|
expect(edges.every((e) => e.source === sw.id)).toBe(true)
|
||||||
|
const targets = edges.map((e) => nodes.find((n) => n.id === e.target)!.data.label)
|
||||||
|
expect(targets).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates edges when clusterR on A and clusterL on B point to each other', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
clusterL:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: ethernet
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips nodes with same label as existing canvas nodes', () => {
|
||||||
|
const existing: Node<NodeData>[] = [{
|
||||||
|
id: 'existing-1',
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'ExistingServer', type: 'server', status: 'online', services: [] },
|
||||||
|
}]
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "ExistingServer"
|
||||||
|
- nodeType: router
|
||||||
|
label: "NewRouter"
|
||||||
|
`
|
||||||
|
const { nodes, imported } = parseYamlToCanvas(yaml, existing, emptyEdges)
|
||||||
|
expect(imported).toBe(1)
|
||||||
|
expect(nodes.filter((n) => n.data.label === 'ExistingServer')).toHaveLength(1)
|
||||||
|
expect(nodes.filter((n) => n.data.label === 'NewRouter')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges with existing edges without duplicating', () => {
|
||||||
|
const existing: Node<NodeData>[] = [
|
||||||
|
{ id: 'a', type: 'server', position: { x: 0, y: 0 }, data: { label: 'A', type: 'server', status: 'online', services: [] } },
|
||||||
|
{ id: 'b', type: 'server', position: { x: 0, y: 0 }, data: { label: 'B', type: 'server', status: 'online', services: [] } },
|
||||||
|
]
|
||||||
|
const existingEdge: Edge<EdgeData>[] = [{
|
||||||
|
id: 'e1', source: 'a', target: 'b', type: 'ethernet',
|
||||||
|
data: { type: 'ethernet' },
|
||||||
|
}]
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "A"
|
||||||
|
clusterR:
|
||||||
|
label: "B"
|
||||||
|
linkType: ethernet
|
||||||
|
`
|
||||||
|
// A already exists so it's skipped, no new edge created
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, existing, existingEdge)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on invalid YAML', () => {
|
||||||
|
expect(() => parseYamlToCanvas('{invalid: [yaml', empty, emptyEdges)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when YAML is not an array', () => {
|
||||||
|
const yaml = `nodeType: server\nlabel: oops\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/list/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when nodeType is missing', () => {
|
||||||
|
const yaml = `- label: "Missing type"\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/nodeType/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when label is missing', () => {
|
||||||
|
const yaml = `- nodeType: server\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/label/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns and skips unknown parent label without crashing', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: vm
|
||||||
|
label: "OrphanVM"
|
||||||
|
parent:
|
||||||
|
label: "NonexistentHost"
|
||||||
|
linkType: virtual
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(edges).toHaveLength(0)
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
|
||||||
|
warnSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { applyDagreLayout } from '../layout'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
function makeNode(id: string, type: string, parentId?: string): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { type, label: id } as unknown as NodeData,
|
||||||
|
...(parentId ? { parentId } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEdge(source: string, target: string, sourceHandle?: string): Edge<EdgeData> {
|
||||||
|
return { id: `${source}-${target}`, source, target, sourceHandle, data: {} as EdgeData }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyDagreLayout', () => {
|
||||||
|
it('places two proxmox nodes connected to each other at the same Y', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode('router', 'router'),
|
||||||
|
makeNode('pve1', 'proxmox'),
|
||||||
|
makeNode('pve2', 'proxmox'),
|
||||||
|
]
|
||||||
|
const edges = [
|
||||||
|
makeEdge('router', 'pve1'),
|
||||||
|
makeEdge('router', 'pve2'),
|
||||||
|
makeEdge('pve1', 'pve2'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = applyDagreLayout(nodes, edges)
|
||||||
|
const pve1 = result.find((n) => n.id === 'pve1')!
|
||||||
|
const pve2 = result.find((n) => n.id === 'pve2')!
|
||||||
|
|
||||||
|
expect(pve1.position.y).toBe(pve2.position.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('orders peer nodes left-to-right by chain: endpoint first, middle last', () => {
|
||||||
|
// pve-left -- pve-center -- pve-right (chain)
|
||||||
|
// router connects to all three
|
||||||
|
const nodes = [
|
||||||
|
makeNode('router', 'router'),
|
||||||
|
makeNode('pve-left', 'proxmox'),
|
||||||
|
makeNode('pve-center', 'proxmox'),
|
||||||
|
makeNode('pve-right', 'proxmox'),
|
||||||
|
]
|
||||||
|
const edges = [
|
||||||
|
makeEdge('router', 'pve-left'),
|
||||||
|
makeEdge('router', 'pve-center'),
|
||||||
|
makeEdge('router', 'pve-right'),
|
||||||
|
makeEdge('pve-left', 'pve-center'),
|
||||||
|
makeEdge('pve-center', 'pve-right'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = applyDagreLayout(nodes, edges)
|
||||||
|
const left = result.find((n) => n.id === 'pve-left')!
|
||||||
|
const center = result.find((n) => n.id === 'pve-center')!
|
||||||
|
const right = result.find((n) => n.id === 'pve-right')!
|
||||||
|
|
||||||
|
// All at same Y
|
||||||
|
expect(left.position.y).toBe(center.position.y)
|
||||||
|
expect(center.position.y).toBe(right.position.y)
|
||||||
|
|
||||||
|
// X order: endpoint (left or right) < center (middle has 2 peer connections)
|
||||||
|
// The BFS starts from an endpoint, so we just verify the middle is not at the extremes
|
||||||
|
const xs = [left.position.x, center.position.x, right.position.x].sort((a, b) => a - b)
|
||||||
|
expect(center.position.x).toBe(xs[1]) // pve-center must be in the middle
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps child nodes (parentId set) in place', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode('router', 'router'),
|
||||||
|
makeNode('pve1', 'proxmox'),
|
||||||
|
makeNode('vm1', 'vm', 'pve1'),
|
||||||
|
]
|
||||||
|
const edges = [makeEdge('router', 'pve1')]
|
||||||
|
|
||||||
|
const result = applyDagreLayout(nodes, edges)
|
||||||
|
const vm1 = result.find((n) => n.id === 'vm1')!
|
||||||
|
expect(vm1.position).toEqual({ x: 0, y: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places a node below its parent when the edge exits from the top handle (upward edge)', () => {
|
||||||
|
// Frigate connects UP to Proxmox via its top handle (source=Frigate, sourceHandle='top')
|
||||||
|
// Dagre must place Frigate BELOW Proxmox, not above.
|
||||||
|
const nodes = [
|
||||||
|
makeNode('router', 'router'),
|
||||||
|
makeNode('proxmox', 'proxmox'),
|
||||||
|
makeNode('frigate', 'server'),
|
||||||
|
]
|
||||||
|
const edges = [
|
||||||
|
makeEdge('router', 'proxmox'),
|
||||||
|
makeEdge('frigate', 'proxmox', 'top'), // upward edge: frigate → proxmox via top handle
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = applyDagreLayout(nodes, edges)
|
||||||
|
const proxmox = result.find((n) => n.id === 'proxmox')!
|
||||||
|
const frigate = result.find((n) => n.id === 'frigate')!
|
||||||
|
|
||||||
|
expect(frigate.position.y).toBeGreaterThan(proxmox.position.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places two switch nodes connected to each other at the same Y', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode('router', 'router'),
|
||||||
|
makeNode('sw1', 'switch'),
|
||||||
|
makeNode('sw2', 'switch'),
|
||||||
|
]
|
||||||
|
const edges = [
|
||||||
|
makeEdge('router', 'sw1'),
|
||||||
|
makeEdge('router', 'sw2'),
|
||||||
|
makeEdge('sw1', 'sw2'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = applyDagreLayout(nodes, edges)
|
||||||
|
const sw1 = result.find((n) => n.id === 'sw1')!
|
||||||
|
const sw2 = result.find((n) => n.id === 'sw2')!
|
||||||
|
expect(sw1.position.y).toBe(sw2.position.y)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { maskIp } from '../maskIp'
|
||||||
|
|
||||||
|
describe('maskIp', () => {
|
||||||
|
it('masks last two octets of a standard IPv4', () => {
|
||||||
|
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masks any IPv4', () => {
|
||||||
|
expect(maskIp('10.0.0.1')).toBe('10.0.XX.XX')
|
||||||
|
expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through non-IPv4 strings unchanged', () => {
|
||||||
|
expect(maskIp('hostname')).toBe('hostname')
|
||||||
|
expect(maskIp('fe80::1')).toBe('fe80::1')
|
||||||
|
expect(maskIp('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,4 +52,25 @@ describe('resolveNodeColors', () => {
|
|||||||
const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined })
|
const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined })
|
||||||
expect(result).toEqual(NODE_DEFAULT_COLORS.generic)
|
expect(result).toEqual(NODE_DEFAULT_COLORS.generic)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses theme colors when themeId is provided', () => {
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'server' }), 'matrix')
|
||||||
|
expect(result.border).not.toBe(NODE_DEFAULT_COLORS.server.border)
|
||||||
|
expect(result.border).toBe('#008822') // matrix server accent
|
||||||
|
})
|
||||||
|
|
||||||
|
it('custom_colors override theme colors', () => {
|
||||||
|
const custom = { border: '#ff0000' }
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'server', custom_colors: custom }), 'neon')
|
||||||
|
expect(result.border).toBe('#ff0000')
|
||||||
|
// background should come from neon theme, not default
|
||||||
|
expect(result.background).not.toBe(NODE_DEFAULT_COLORS.server.background)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groupRect always has transparent background regardless of theme', () => {
|
||||||
|
for (const themeId of ['default', 'light', 'matrix', 'neon', 'dark'] as const) {
|
||||||
|
const result = resolveNodeColors(makeData({ type: 'groupRect' }), themeId)
|
||||||
|
expect(result.background).toBe('transparent')
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => {
|
|||||||
expect(keys).toContain('play') // Jellyfin
|
expect(keys).toContain('play') // Jellyfin
|
||||||
expect(keys).toContain('shield') // Pi-hole
|
expect(keys).toContain('shield') // Pi-hole
|
||||||
expect(keys).toContain('anchor') // Portainer
|
expect(keys).toContain('anchor') // Portainer
|
||||||
|
expect(keys).toContain('package') // Docker Host
|
||||||
expect(keys).toContain('key') // Vaultwarden
|
expect(keys).toContain('key') // Vaultwarden
|
||||||
expect(keys).toContain('database') // DB services
|
expect(keys).toContain('database') // DB services
|
||||||
expect(keys).toContain('cctv') // IP Camera / CCTV
|
expect(keys).toContain('cctv') // IP Camera / CCTV
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Standalone mode save/load tests.
|
||||||
|
*
|
||||||
|
* In standalone mode (VITE_STANDALONE=true) the canvas is persisted directly
|
||||||
|
* to localStorage as JSON — no backend involved. The full RF node object is
|
||||||
|
* serialized as-is, which means width/height survive the round-trip without
|
||||||
|
* going through serializeNode / deserializeApiNode.
|
||||||
|
*
|
||||||
|
* These tests verify that critical node properties (especially width/height
|
||||||
|
* added for resizable nodes) are not lost through the localStorage cycle, and
|
||||||
|
* that the demo data is structurally valid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
|
// Simulates what App.tsx does on Ctrl+S in standalone mode
|
||||||
|
function standaloneSerialize(nodes: Node<NodeData>[], edges: Edge<EdgeData>[], theme_id = 'default') {
|
||||||
|
return JSON.stringify({ nodes, edges, theme_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates what App.tsx does on load in standalone mode
|
||||||
|
function standaloneDeserialize(raw: string) {
|
||||||
|
return JSON.parse(raw) as { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; theme_id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(id: string, overrides: Partial<Node<NodeData>> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: id, type: 'server', status: 'unknown', services: [] },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Standalone localStorage save/load cycle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], hasUnsavedChanges: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── width / height round-trip ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('preserves width and height for resized nodes', () => {
|
||||||
|
const nodes = [makeNode('n1', { width: 320, height: 180 })]
|
||||||
|
const raw = standaloneSerialize(nodes, [])
|
||||||
|
localStorage.setItem(STORAGE_KEY, raw)
|
||||||
|
|
||||||
|
const { nodes: loaded } = standaloneDeserialize(localStorage.getItem(STORAGE_KEY)!)
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||||
|
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(stored?.width).toBe(320)
|
||||||
|
expect(stored?.height).toBe(180)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves undefined width/height for default-sized nodes', () => {
|
||||||
|
const nodes = [makeNode('n1')]
|
||||||
|
const raw = standaloneSerialize(nodes, [])
|
||||||
|
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||||
|
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(stored?.width).toBeUndefined()
|
||||||
|
expect(stored?.height).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves mixed: some nodes resized, others not', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode('n1', { width: 280, height: 120 }),
|
||||||
|
makeNode('n2'),
|
||||||
|
]
|
||||||
|
const raw = standaloneSerialize(nodes, [])
|
||||||
|
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||||
|
|
||||||
|
const { nodes: stored } = useCanvasStore.getState()
|
||||||
|
expect(stored.find((n) => n.id === 'n1')?.width).toBe(280)
|
||||||
|
expect(stored.find((n) => n.id === 'n2')?.width).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── other node properties ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('preserves position through the round-trip', () => {
|
||||||
|
const nodes = [makeNode('n1', { position: { x: 123, y: 456 } })]
|
||||||
|
const raw = standaloneSerialize(nodes, [])
|
||||||
|
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 123, y: 456 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves node data fields through the round-trip', () => {
|
||||||
|
const nodes = [makeNode('n1', {
|
||||||
|
data: {
|
||||||
|
label: 'My Router', type: 'router', status: 'online', services: [],
|
||||||
|
ip: '192.168.1.1', hostname: 'gw.local',
|
||||||
|
},
|
||||||
|
})]
|
||||||
|
const raw = standaloneSerialize(nodes, [])
|
||||||
|
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||||
|
|
||||||
|
const stored = useCanvasStore.getState().nodes[0]
|
||||||
|
expect(stored.data.label).toBe('My Router')
|
||||||
|
expect(stored.data.ip).toBe('192.168.1.1')
|
||||||
|
expect(stored.data.hostname).toBe('gw.local')
|
||||||
|
expect(stored.data.status).toBe('online')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves theme_id through the round-trip', () => {
|
||||||
|
const raw = standaloneSerialize([], [], 'cyberpunk')
|
||||||
|
const { theme_id } = standaloneDeserialize(raw)
|
||||||
|
expect(theme_id).toBe('cyberpunk')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves edge data through the round-trip', () => {
|
||||||
|
const edges: Edge<EdgeData>[] = [{
|
||||||
|
id: 'e1', source: 'n1', target: 'n2', type: 'vlan',
|
||||||
|
data: { type: 'vlan', vlan_id: 20, label: 'VLAN 20' },
|
||||||
|
}]
|
||||||
|
const raw = standaloneSerialize([], edges)
|
||||||
|
const { edges: loaded } = standaloneDeserialize(raw)
|
||||||
|
useCanvasStore.getState().loadCanvas([], loaded)
|
||||||
|
|
||||||
|
const stored = useCanvasStore.getState().edges[0]
|
||||||
|
expect(stored.data?.vlan_id).toBe(20)
|
||||||
|
expect(stored.data?.label).toBe('VLAN 20')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── loadCanvas marks clean ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('loadCanvas sets hasUnsavedChanges to false', () => {
|
||||||
|
useCanvasStore.setState({ hasUnsavedChanges: true })
|
||||||
|
const { nodes: loaded } = standaloneDeserialize(standaloneSerialize([makeNode('n1')], []))
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Demo data validation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Demo data (standalone fallback)', () => {
|
||||||
|
it('all demo nodes have required fields', () => {
|
||||||
|
for (const n of demoNodes) {
|
||||||
|
expect(n.id, `${n.id} missing id`).toBeTruthy()
|
||||||
|
expect(n.type, `${n.id} missing type`).toBeTruthy()
|
||||||
|
expect(n.position, `${n.id} missing position`).toBeDefined()
|
||||||
|
expect(n.data.label, `${n.id} missing label`).toBeTruthy()
|
||||||
|
expect(n.data.type, `${n.id} missing data.type`).toBeTruthy()
|
||||||
|
expect(n.data.status, `${n.id} missing status`).toBeTruthy()
|
||||||
|
expect(Array.isArray(n.data.services), `${n.id} services must be array`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demo nodes have no explicit width/height — render at natural size', () => {
|
||||||
|
for (const n of demoNodes) {
|
||||||
|
expect(n.width, `${n.id} should not have explicit width`).toBeUndefined()
|
||||||
|
expect(n.height, `${n.id} should not have explicit height`).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all demo edges reference valid node ids', () => {
|
||||||
|
const nodeIds = new Set(demoNodes.map((n) => n.id))
|
||||||
|
for (const e of demoEdges) {
|
||||||
|
expect(nodeIds.has(e.source), `edge ${e.id} source '${e.source}' not in nodes`).toBe(true)
|
||||||
|
expect(nodeIds.has(e.target), `edge ${e.id} target '${e.target}' not in nodes`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demo data loads into store without errors', () => {
|
||||||
|
useCanvasStore.getState().loadCanvas(demoNodes, demoEdges)
|
||||||
|
const { nodes, edges } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(demoNodes.length)
|
||||||
|
expect(edges).toHaveLength(demoEdges.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demo data round-trips through standalone JSON serialization', () => {
|
||||||
|
const raw = standaloneSerialize(demoNodes, demoEdges)
|
||||||
|
const { nodes: loaded, edges: loadedEdges } = standaloneDeserialize(raw)
|
||||||
|
useCanvasStore.getState().loadCanvas(loaded, loadedEdges)
|
||||||
|
|
||||||
|
const { nodes, edges } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(demoNodes.length)
|
||||||
|
expect(edges).toHaveLength(demoEdges.length)
|
||||||
|
// Positions intact
|
||||||
|
const router = nodes.find((n) => n.id === 'router-1')!
|
||||||
|
expect(router.position).toEqual({ x: 300, y: 140 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { THEMES, THEME_ORDER, type ThemeId } from '../themes'
|
||||||
|
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||||
|
|
||||||
|
const NODE_TYPES: NodeType[] = [
|
||||||
|
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||||
|
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
|
||||||
|
]
|
||||||
|
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
|
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||||
|
|
||||||
|
describe('THEME_ORDER', () => {
|
||||||
|
it('contains all theme IDs', () => {
|
||||||
|
const keys = Object.keys(THEMES) as ThemeId[]
|
||||||
|
expect(THEME_ORDER).toHaveLength(keys.length)
|
||||||
|
expect(new Set(THEME_ORDER)).toEqual(new Set(keys))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('THEMES', () => {
|
||||||
|
for (const themeId of Object.keys(THEMES) as ThemeId[]) {
|
||||||
|
describe(`theme: ${themeId}`, () => {
|
||||||
|
const preset = THEMES[themeId]
|
||||||
|
|
||||||
|
it('has id, label, description', () => {
|
||||||
|
expect(preset.id).toBe(themeId)
|
||||||
|
expect(typeof preset.label).toBe('string')
|
||||||
|
expect(preset.label.length).toBeGreaterThan(0)
|
||||||
|
expect(typeof preset.description).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nodeAccents for all node types', () => {
|
||||||
|
for (const type of NODE_TYPES) {
|
||||||
|
expect(preset.colors.nodeAccents[type]).toBeDefined()
|
||||||
|
expect(typeof preset.colors.nodeAccents[type].border).toBe('string')
|
||||||
|
expect(typeof preset.colors.nodeAccents[type].icon).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has surface colors', () => {
|
||||||
|
expect(typeof preset.colors.nodeCardBackground).toBe('string')
|
||||||
|
expect(typeof preset.colors.nodeIconBackground).toBe('string')
|
||||||
|
expect(typeof preset.colors.nodeLabelColor).toBe('string')
|
||||||
|
expect(typeof preset.colors.nodeSubtextColor).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has statusColors for all statuses', () => {
|
||||||
|
for (const status of STATUS_TYPES) {
|
||||||
|
expect(typeof preset.colors.statusColors[status]).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has edgeColors for all edge types', () => {
|
||||||
|
for (const type of EDGE_TYPES) {
|
||||||
|
expect(typeof preset.colors.edgeColors[type]).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has edge label colors', () => {
|
||||||
|
expect(typeof preset.colors.edgeSelectedColor).toBe('string')
|
||||||
|
expect(typeof preset.colors.edgeLabelBackground).toBe('string')
|
||||||
|
expect(typeof preset.colors.edgeLabelColor).toBe('string')
|
||||||
|
expect(typeof preset.colors.edgeLabelBorder).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has canvas colors', () => {
|
||||||
|
expect(typeof preset.colors.canvasBackground).toBe('string')
|
||||||
|
expect(typeof preset.colors.canvasDotColor).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has handle colors', () => {
|
||||||
|
expect(typeof preset.colors.handleBackground).toBe('string')
|
||||||
|
expect(typeof preset.colors.handleBorder).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has valid reactFlowColorMode', () => {
|
||||||
|
expect(['dark', 'light']).toContain(preset.colors.reactFlowColorMode)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('default theme matches original hardcoded colors', () => {
|
||||||
|
const d = THEMES.default.colors
|
||||||
|
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
||||||
|
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
||||||
|
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
||||||
|
expect(d.nodeAccents.docker.border).toBe('#2496ED')
|
||||||
|
expect(d.nodeCardBackground).toBe('#21262d')
|
||||||
|
expect(d.nodeIconBackground).toBe('#161b22')
|
||||||
|
expect(d.canvasBackground).toBe('#0d1117')
|
||||||
|
expect(d.canvasDotColor).toBe('#30363d')
|
||||||
|
expect(d.edgeColors.ethernet).toBe('#30363d')
|
||||||
|
expect(d.edgeColors.wifi).toBe('#00d4ff')
|
||||||
|
expect(d.statusColors.online).toBe('#39d353')
|
||||||
|
expect(d.statusColors.offline).toBe('#f85149')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('light theme has reactFlowColorMode light', () => {
|
||||||
|
expect(THEMES.light.colors.reactFlowColorMode).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('light theme has light canvas background', () => {
|
||||||
|
expect(THEMES.light.colors.canvasBackground).toBe('#f6f8fa')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matrix theme uses green accents', () => {
|
||||||
|
const m = THEMES.matrix.colors
|
||||||
|
expect(m.nodeAccents.isp.border).toMatch(/^#0/)
|
||||||
|
expect(m.nodeLabelColor).toBe('#00ff41')
|
||||||
|
expect(m.canvasBackground).toBe('#000000')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { generateUUID } from '../uuid'
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
describe('generateUUID', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid v4 UUID using crypto.randomUUID when available', () => {
|
||||||
|
const id = generateUUID()
|
||||||
|
expect(id).toMatch(UUID_REGEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid v4 UUID using crypto.getRandomValues fallback', () => {
|
||||||
|
vi.spyOn(crypto, 'randomUUID' as never).mockImplementation(undefined as never)
|
||||||
|
const id = generateUUID()
|
||||||
|
expect(id).toMatch(UUID_REGEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates unique IDs', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 100 }, () => generateUUID()))
|
||||||
|
expect(ids.size).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const EMPTY = '—'
|
||||||
|
|
||||||
|
function cell(v: string | null | undefined): string {
|
||||||
|
if (!v) return EMPTY
|
||||||
|
// Escape pipe chars so they don't break the table
|
||||||
|
return v.replace(/\|/g, '\\|')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||||
|
const rows = nodes
|
||||||
|
.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
.map((n) => {
|
||||||
|
const d = n.data
|
||||||
|
const services = d.services?.length
|
||||||
|
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
|
||||||
|
: EMPTY
|
||||||
|
return [
|
||||||
|
cell(d.label),
|
||||||
|
cell(d.type),
|
||||||
|
cell(d.ip),
|
||||||
|
cell(d.hostname),
|
||||||
|
cell(d.status),
|
||||||
|
services,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rows.length === 0) return ''
|
||||||
|
|
||||||
|
const headers = ['Label', 'Type', 'IP', 'Hostname', 'Status', 'Services']
|
||||||
|
const separator = headers.map(() => '---')
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`| ${headers.join(' | ')} |`,
|
||||||
|
`| ${separator.join(' | ')} |`,
|
||||||
|
...rows.map((r) => `| ${r.join(' | ')} |`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData, EdgeType } from '@/types'
|
||||||
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
/** Build a map of node id → label for edge resolution */
|
||||||
|
function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
|
||||||
|
const m = new Map<string, string>()
|
||||||
|
for (const n of nodes) m.set(n.id, n.data.label)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection {
|
||||||
|
return {
|
||||||
|
label: targetLabel,
|
||||||
|
linkType: edgeType,
|
||||||
|
linkLabel: edgeLabel ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize React Flow canvas state to a YAML string.
|
||||||
|
* Each node becomes one entry; edges are embedded as parent/clusterR/clusterL sub-objects.
|
||||||
|
* Edge deduplication: each edge is written on exactly one side (source as clusterR, target as clusterL)
|
||||||
|
* unless the edge type is 'virtual' or there is a parentId relationship, in which case
|
||||||
|
* it becomes the 'parent' field of the child node.
|
||||||
|
*/
|
||||||
|
export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData>[]): string {
|
||||||
|
const idToLabel = buildIdToLabel(nodes)
|
||||||
|
|
||||||
|
// Build per-node edge maps (id → connections)
|
||||||
|
// We use a Set to track already-serialized edge ids (deduplication).
|
||||||
|
const serializedEdges = new Set<string>()
|
||||||
|
|
||||||
|
// Index edges by source and target for quick lookup
|
||||||
|
const edgesBySource = new Map<string, Edge<EdgeData>[]>()
|
||||||
|
const edgesByTarget = new Map<string, Edge<EdgeData>[]>()
|
||||||
|
for (const e of edges) {
|
||||||
|
if (!edgesBySource.has(e.source)) edgesBySource.set(e.source, [])
|
||||||
|
edgesBySource.get(e.source)!.push(e)
|
||||||
|
if (!edgesByTarget.has(e.target)) edgesByTarget.set(e.target, [])
|
||||||
|
edgesByTarget.get(e.target)!.push(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlNodes: YamlNode[] = []
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const d = node.data
|
||||||
|
|
||||||
|
// Skip groupRect nodes — they are canvas decoration only
|
||||||
|
if (d.type === 'groupRect') continue
|
||||||
|
|
||||||
|
const entry: YamlNode = {
|
||||||
|
nodeType: d.type,
|
||||||
|
label: d.label,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.custom_icon) entry.nodeIcon = d.custom_icon
|
||||||
|
if (d.hostname) entry.hostname = d.hostname
|
||||||
|
if (d.ip) entry.ipAddress = d.ip
|
||||||
|
if (d.check_method && d.check_method !== 'none') entry.checkMethod = d.check_method
|
||||||
|
if (d.check_target) entry.checkTarget = d.check_target
|
||||||
|
if (d.notes) entry.notes = d.notes
|
||||||
|
|
||||||
|
// Hardware specs — omit zero values
|
||||||
|
if (d.cpu_model) entry.cpuModel = d.cpu_model
|
||||||
|
if (d.cpu_count && d.cpu_count > 0) entry.cpuCore = d.cpu_count
|
||||||
|
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
|
||||||
|
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
|
||||||
|
|
||||||
|
// Parent relationship: if this node has a parentId in React Flow,
|
||||||
|
// encode it as a 'parent' connection using any virtual edge between them.
|
||||||
|
if (node.parentId) {
|
||||||
|
const parentLabel = idToLabel.get(node.parentId) ?? node.parentId
|
||||||
|
// Find an edge between parent and this node (either direction)
|
||||||
|
const parentEdges = [
|
||||||
|
...(edgesBySource.get(node.parentId) ?? []).filter((e) => e.target === node.id),
|
||||||
|
...(edgesByTarget.get(node.parentId) ?? []).filter((e) => e.source === node.id),
|
||||||
|
]
|
||||||
|
const pEdge = parentEdges[0]
|
||||||
|
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
|
||||||
|
const linkLabel = pEdge?.data?.label ?? ''
|
||||||
|
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
|
||||||
|
if (pEdge) serializedEdges.add(pEdge.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing edges (this node is the source):
|
||||||
|
// - cluster type → clusterR (Proxmox cluster link, directional)
|
||||||
|
// - everything else → links array (supports multiple connections)
|
||||||
|
const outgoingEdges = (edgesBySource.get(node.id) ?? []).filter(
|
||||||
|
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId,
|
||||||
|
)
|
||||||
|
for (const e of outgoingEdges) {
|
||||||
|
const targetLabel = idToLabel.get(e.target)
|
||||||
|
if (!targetLabel) continue
|
||||||
|
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
||||||
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
|
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
|
||||||
|
if (edgeType === 'cluster') {
|
||||||
|
if (!entry.clusterR) entry.clusterR = conn
|
||||||
|
} else {
|
||||||
|
entry.links = [...(entry.links ?? []), conn]
|
||||||
|
}
|
||||||
|
serializedEdges.add(e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming cluster edges not yet serialized → clusterL
|
||||||
|
const incomingClusterEdges = (edgesByTarget.get(node.id) ?? []).filter(
|
||||||
|
(e) => !serializedEdges.has(e.id) && (e.data?.type as EdgeType) === 'cluster',
|
||||||
|
)
|
||||||
|
for (const e of incomingClusterEdges) {
|
||||||
|
const sourceLabel = idToLabel.get(e.source)
|
||||||
|
if (!sourceLabel) continue
|
||||||
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
|
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
|
||||||
|
serializedEdges.add(e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlNodes.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return yaml.dump(yamlNodes, { lineWidth: -1, noRefs: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a browser file download with the given YAML content */
|
||||||
|
export function downloadYaml(content: string, filename = 'homelable-export.yaml'): void {
|
||||||
|
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import yaml from 'js-yaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas.
|
||||||
|
* - Nodes with the same label as an existing node are skipped (no duplicates).
|
||||||
|
* - Positions are computed via dagre auto-layout over the full merged set.
|
||||||
|
*/
|
||||||
|
export function parseYamlToCanvas(
|
||||||
|
yamlString: string,
|
||||||
|
existingNodes: Node<NodeData>[],
|
||||||
|
existingEdges: Edge<EdgeData>[],
|
||||||
|
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; imported: number } {
|
||||||
|
const raw = yaml.load(yamlString)
|
||||||
|
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new Error('YAML must be a list of node objects (top-level array)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = raw as unknown[]
|
||||||
|
|
||||||
|
// Build lookup: label → existing node id (existing canvas + nodes being added)
|
||||||
|
const labelToId = new Map<string, string>()
|
||||||
|
for (const n of existingNodes) {
|
||||||
|
labelToId.set(n.data.label, n.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: validate and create nodes (without positions — dagre will assign them)
|
||||||
|
const newNodes: Node<NodeData>[] = []
|
||||||
|
const yamlNodes: YamlNode[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const raw = entry as Record<string, unknown>
|
||||||
|
|
||||||
|
if (!raw.nodeType || typeof raw.nodeType !== 'string') {
|
||||||
|
throw new Error(`Each YAML entry must have a "nodeType" string field`)
|
||||||
|
}
|
||||||
|
if (!raw.label || typeof raw.label !== 'string') {
|
||||||
|
throw new Error(`Each YAML entry must have a "label" string field`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const yn = raw as unknown as YamlNode
|
||||||
|
|
||||||
|
// Skip if a node with this label already exists on the canvas
|
||||||
|
if (labelToId.has(yn.label)) {
|
||||||
|
console.warn(`[importYaml] Skipping duplicate label: "${yn.label}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateUUID()
|
||||||
|
labelToId.set(yn.label, id)
|
||||||
|
|
||||||
|
const hasHardware = !!(yn.cpuModel || yn.cpuCore || yn.ram || yn.disk)
|
||||||
|
|
||||||
|
const data: NodeData = {
|
||||||
|
label: yn.label,
|
||||||
|
type: yn.nodeType,
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
...(yn.hostname ? { hostname: yn.hostname } : {}),
|
||||||
|
...(yn.ipAddress ? { ip: yn.ipAddress } : {}),
|
||||||
|
...(yn.checkMethod ? { check_method: yn.checkMethod } : {}),
|
||||||
|
...(yn.checkTarget ? { check_target: yn.checkTarget } : {}),
|
||||||
|
...(yn.notes ? { notes: yn.notes } : {}),
|
||||||
|
...(yn.nodeIcon ? { custom_icon: yn.nodeIcon } : {}),
|
||||||
|
...(yn.cpuModel ? { cpu_model: yn.cpuModel } : {}),
|
||||||
|
...(yn.cpuCore ? { cpu_count: yn.cpuCore } : {}),
|
||||||
|
...(yn.ram ? { ram_gb: yn.ram } : {}),
|
||||||
|
...(yn.disk ? { disk_gb: yn.disk } : {}),
|
||||||
|
...(hasHardware ? { show_hardware: true } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
newNodes.push({
|
||||||
|
id,
|
||||||
|
type: yn.nodeType,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
yamlNodes.push(yn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: apply parent relationships (parentId / parent_id)
|
||||||
|
const newEdges: Edge<EdgeData>[] = []
|
||||||
|
// Track edge pairs to deduplicate (store as "sourceId|targetId")
|
||||||
|
const edgePairs = new Set<string>(
|
||||||
|
existingEdges.map((e) => `${e.source}|${e.target}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
function addEdgeIfNew(
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
conn: YamlNodeConnection,
|
||||||
|
) {
|
||||||
|
const key = `${sourceId}|${targetId}`
|
||||||
|
const reverseKey = `${targetId}|${sourceId}`
|
||||||
|
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
|
||||||
|
edgePairs.add(key)
|
||||||
|
const edgeType = conn.linkType ?? 'ethernet'
|
||||||
|
newEdges.push({
|
||||||
|
id: generateUUID(),
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
type: edgeType,
|
||||||
|
data: {
|
||||||
|
type: edgeType,
|
||||||
|
...(conn.linkLabel ? { label: conn.linkLabel } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < newNodes.length; i++) {
|
||||||
|
const node = newNodes[i]
|
||||||
|
const yn = yamlNodes[i]
|
||||||
|
|
||||||
|
if (yn.parent) {
|
||||||
|
const parentId = labelToId.get(yn.parent.label)
|
||||||
|
if (!parentId) {
|
||||||
|
console.warn(`[importYaml] parent label not found: "${yn.parent.label}" — skipping relationship`)
|
||||||
|
} else {
|
||||||
|
// Set React Flow parentId for nesting
|
||||||
|
node.data = { ...node.data, parent_id: parentId }
|
||||||
|
node.parentId = parentId
|
||||||
|
node.extent = 'parent'
|
||||||
|
// Also create an edge
|
||||||
|
addEdgeIfNew(parentId, node.id, yn.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.links) {
|
||||||
|
for (const link of yn.links) {
|
||||||
|
const targetId = labelToId.get(link.label)
|
||||||
|
if (!targetId) {
|
||||||
|
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(node.id, targetId, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.clusterR) {
|
||||||
|
const targetId = labelToId.get(yn.clusterR.label)
|
||||||
|
if (!targetId) {
|
||||||
|
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(node.id, targetId, yn.clusterR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.clusterL) {
|
||||||
|
const sourceId = labelToId.get(yn.clusterL.label)
|
||||||
|
if (!sourceId) {
|
||||||
|
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(sourceId, node.id, yn.clusterL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and apply layout
|
||||||
|
const mergedNodes = [...existingNodes, ...newNodes]
|
||||||
|
const mergedEdges = [...existingEdges, ...newEdges]
|
||||||
|
const laidOut = applyDagreLayout(mergedNodes, mergedEdges)
|
||||||
|
|
||||||
|
return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length }
|
||||||
|
}
|
||||||
+117
-14
@@ -5,46 +5,149 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
const NODE_WIDTH = 180
|
const NODE_WIDTH = 180
|
||||||
const NODE_HEIGHT = 52
|
const NODE_HEIGHT = 52
|
||||||
|
|
||||||
|
const PEER_TYPES = new Set(['proxmox', 'switch'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find groups of peer nodes (same type, directly connected to each other)
|
||||||
|
* using union-find. Returns a map: nodeId → groupId (the minimum nodeId in the group).
|
||||||
|
*/
|
||||||
|
function buildPeerGroups(
|
||||||
|
topLevel: Node<NodeData>[],
|
||||||
|
edges: Edge<EdgeData>[],
|
||||||
|
): Map<string, string> {
|
||||||
|
const parent = new Map<string, string>(topLevel.map((n) => [n.id, n.id]))
|
||||||
|
|
||||||
|
function find(id: string): string {
|
||||||
|
if (parent.get(id) !== id) parent.set(id, find(parent.get(id)!))
|
||||||
|
return parent.get(id)!
|
||||||
|
}
|
||||||
|
function union(a: string, b: string) {
|
||||||
|
const ra = find(a), rb = find(b)
|
||||||
|
if (ra !== rb) parent.set(ra, rb)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevelIds = new Set(topLevel.map((n) => n.id))
|
||||||
|
const peerIds = new Set(topLevel.filter((n) => PEER_TYPES.has(n.type ?? '')).map((n) => n.id))
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const { source: s, target: t } = edge
|
||||||
|
if (topLevelIds.has(s) && topLevelIds.has(t) && peerIds.has(s) && peerIds.has(t)) {
|
||||||
|
// Only merge if both nodes share the same type (proxmox↔proxmox, switch↔switch)
|
||||||
|
const srcNode = topLevel.find((n) => n.id === s)!
|
||||||
|
const tgtNode = topLevel.find((n) => n.id === t)!
|
||||||
|
if (srcNode.type === tgtNode.type) union(s, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve all to canonical group ids
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
for (const n of topLevel) result.set(n.id, find(n.id))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
|
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
|
||||||
* Child nodes (parentId set) keep their relative position inside the parent — only
|
* Child nodes (parentId set) keep their relative position inside the parent — only
|
||||||
* top-level nodes are repositioned by Dagre.
|
* top-level nodes are repositioned by Dagre.
|
||||||
|
*
|
||||||
|
* Post-pass: peer nodes of the same type (proxmox, switch) connected to each other
|
||||||
|
* are snapped to the same Y rank so they appear on the same horizontal level.
|
||||||
*/
|
*/
|
||||||
export function applyDagreLayout(
|
export function applyDagreLayout(
|
||||||
nodes: Node<NodeData>[],
|
nodes: Node<NodeData>[],
|
||||||
edges: Edge<EdgeData>[],
|
edges: Edge<EdgeData>[],
|
||||||
): Node<NodeData>[] {
|
): Node<NodeData>[] {
|
||||||
|
const topLevel = nodes.filter((n) => !n.parentId)
|
||||||
|
const topLevelIds = new Set(topLevel.map((n) => n.id))
|
||||||
|
|
||||||
|
// Capture original X positions before Dagre — used to preserve left-to-right
|
||||||
|
// ordering of peer groups as the user set them.
|
||||||
|
const originalX = new Map<string, number>(topLevel.map((n) => [n.id, n.position.x]))
|
||||||
|
|
||||||
|
// Identify peer groups before running Dagre so we can exclude peer edges
|
||||||
|
const peerGroups = buildPeerGroups(topLevel, edges)
|
||||||
|
const isPeerEdge = (e: Edge<EdgeData>) => {
|
||||||
|
const sg = peerGroups.get(e.source)
|
||||||
|
const tg = peerGroups.get(e.target)
|
||||||
|
return sg !== undefined && tg !== undefined && sg === tg
|
||||||
|
}
|
||||||
|
|
||||||
const g = new dagre.graphlib.Graph()
|
const g = new dagre.graphlib.Graph()
|
||||||
g.setDefaultEdgeLabel(() => ({}))
|
g.setDefaultEdgeLabel(() => ({}))
|
||||||
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
|
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
|
||||||
|
|
||||||
const topLevel = nodes.filter((n) => !n.parentId)
|
|
||||||
|
|
||||||
for (const node of topLevel) {
|
for (const node of topLevel) {
|
||||||
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
|
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
|
||||||
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
|
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
|
||||||
g.setNode(node.id, { width: w, height: h })
|
g.setNode(node.id, { width: w, height: h })
|
||||||
}
|
}
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
// Only add edges between top-level nodes
|
const srcTop = topLevelIds.has(edge.source)
|
||||||
const srcTop = topLevel.some((n) => n.id === edge.source)
|
const tgtTop = topLevelIds.has(edge.target)
|
||||||
const tgtTop = topLevel.some((n) => n.id === edge.target)
|
// Exclude peer-to-peer edges — they confuse Dagre's rank assignment
|
||||||
if (srcTop && tgtTop) g.setEdge(edge.source, edge.target)
|
if (!srcTop || !tgtTop || isPeerEdge(edge)) continue
|
||||||
|
// If the edge exits from the TOP handle of the source, the connection goes
|
||||||
|
// upward — meaning the source node is visually below the target. Reverse
|
||||||
|
// the edge direction for Dagre so it places the source below the target.
|
||||||
|
const upward = (edge as { sourceHandle?: string | null }).sourceHandle === 'top'
|
||||||
|
if (upward) {
|
||||||
|
g.setEdge(edge.target, edge.source)
|
||||||
|
} else {
|
||||||
|
g.setEdge(edge.source, edge.target)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dagre.layout(g)
|
dagre.layout(g)
|
||||||
|
|
||||||
return nodes.map((node) => {
|
// Build initial positions from Dagre
|
||||||
if (node.parentId) return node // keep children in place
|
const positions = new Map<string, { x: number; y: number; w: number; h: number }>()
|
||||||
|
for (const node of topLevel) {
|
||||||
const pos = g.node(node.id)
|
const pos = g.node(node.id)
|
||||||
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
|
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
|
||||||
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
|
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
|
||||||
return {
|
positions.set(node.id, { x: pos.x - w / 2, y: pos.y - h / 2, w, h })
|
||||||
...node,
|
}
|
||||||
position: {
|
|
||||||
x: pos.x - w / 2,
|
// Post-pass: fix peer groups (same-type nodes directly connected to each other)
|
||||||
y: pos.y - h / 2,
|
// Collect members per group
|
||||||
},
|
const groupMembers = new Map<string, string[]>()
|
||||||
|
for (const [id, groupId] of peerGroups) {
|
||||||
|
if (!groupMembers.has(groupId)) groupMembers.set(groupId, [])
|
||||||
|
groupMembers.get(groupId)!.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, members] of groupMembers) {
|
||||||
|
if (members.length < 2) continue
|
||||||
|
|
||||||
|
// --- Y: snap all to average Y of the group ---
|
||||||
|
const avgY = members.reduce((sum, id) => sum + positions.get(id)!.y, 0) / members.length
|
||||||
|
for (const id of members) positions.set(id, { ...positions.get(id)!, y: avgY })
|
||||||
|
|
||||||
|
// --- X: sort by original (pre-layout) X to preserve the user's intended
|
||||||
|
// left-to-right order. Fall back to Dagre X if all nodes share the
|
||||||
|
// same original X (e.g. freshly created canvas with no positions yet). ---
|
||||||
|
const GAP = 60
|
||||||
|
const origXs = members.map((id) => originalX.get(id) ?? 0)
|
||||||
|
const allSameOrigX = origXs.every((x) => x === origXs[0])
|
||||||
|
const ordered = members.slice().sort((a, b) =>
|
||||||
|
allSameOrigX
|
||||||
|
? positions.get(a)!.x - positions.get(b)!.x // fall back to Dagre X
|
||||||
|
: (originalX.get(a) ?? 0) - (originalX.get(b) ?? 0), // preserve user order
|
||||||
|
)
|
||||||
|
const totalWidth = ordered.reduce((sum, id) => sum + positions.get(id)!.w, 0) + GAP * (ordered.length - 1)
|
||||||
|
const centerX = members.reduce((sum, id) => sum + positions.get(id)!.x + positions.get(id)!.w / 2, 0) / members.length
|
||||||
|
|
||||||
|
let curX = centerX - totalWidth / 2
|
||||||
|
for (const id of ordered) {
|
||||||
|
const p = positions.get(id)!
|
||||||
|
positions.set(id, { ...p, x: curX })
|
||||||
|
curX += p.w + GAP
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.parentId) return node
|
||||||
|
const p = positions.get(node.id)!
|
||||||
|
return { ...node, position: { x: p.x, y: p.y } }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Mask the last two octets of an IPv4 address.
|
||||||
|
* e.g. "192.168.1.115" → "192.168.XX.XX"
|
||||||
|
* Non-IPv4 strings are returned unchanged.
|
||||||
|
*/
|
||||||
|
export function maskIp(ip: string): string {
|
||||||
|
const parts = ip.split('.')
|
||||||
|
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
|
||||||
|
return ip
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user