Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c668cd89 | |||
| cb4bf51b5a | |||
| c1dad1a5ae | |||
| 4ee68b839c | |||
| 0b7691c083 | |||
| 9979ae9dc8 | |||
| 21b19a775e | |||
| 34182f0d11 | |||
| ebc1d41d5c | |||
| fe5e3c9858 | |||
| 78b47384da | |||
| 38e6604f50 | |||
| 0e70b45e8a | |||
| 7312132767 | |||
| cb5b1bd2e2 | |||
| c9a402fa0a | |||
| 9f880395da | |||
| 585df726e7 | |||
| 1a3cde3a02 | |||
| a8ca4f1cca | |||
| feb05a1df5 | |||
| 0204a7ddeb | |||
| 03e1e32af1 | |||
| c9cd6a08fc | |||
| daa78a036a | |||
| 3deb750441 | |||
| 88554ef952 | |||
| 110592f89e | |||
| 8b8da5584c | |||
| 4260a6582c | |||
| a0f18dd237 | |||
| 00edc32aeb | |||
| cd6a788f77 | |||
| 9cf6a48b04 | |||
| 2c94616afa | |||
| c7be851c34 | |||
| fddfd0a769 | |||
| 074b49358b | |||
| a47b7649f0 | |||
| 7e08a85f73 | |||
| c7c5183356 | |||
| 7608d07255 | |||
| 1bc6798d76 | |||
| f6de7d1770 | |||
| 9dddd00858 | |||
| a5bf9c9db6 | |||
| a9c5c538b4 | |||
| a816faa0b9 | |||
| fbfacec6dc | |||
| b5eb8d1b74 | |||
| 0193f933ce | |||
| 5ad5eba58c | |||
| ef96cafcc8 | |||
| 6c9974b357 | |||
| ce5fc785e1 | |||
| 0019c086cf | |||
| 0eff7da46e | |||
| 2e6ee9dad2 | |||
| 81b109f981 | |||
| 73b16a7620 | |||
| a37bf101d2 | |||
| 5def6b7fbf | |||
| eb235cb101 | |||
| 04a1c63558 | |||
| 88f0c03c57 | |||
| 718aff5918 | |||
| 70311e6331 | |||
| 6a3da5aded | |||
| 35c3d00f17 | |||
| 3a5cb0de21 | |||
| f72d44d5e5 | |||
| a7b244502e | |||
| 72d5a51b44 | |||
| 12f46715c1 | |||
| 62f674b15d | |||
| b0a67744f5 | |||
| 04069e080a | |||
| dd1f690892 | |||
| 8b04deb608 | |||
| 531fb12eab | |||
| e666abefad | |||
| f1e9fd7cf8 | |||
| 9134812e32 | |||
| 4844576c3b | |||
| 4976f2e694 | |||
| d6a7b062f4 | |||
| 94c6ac7fa7 | |||
| e0f96001e2 | |||
| 3d89ba3b6f | |||
| 3afc8ed3d8 | |||
| 9e8bab5dec | |||
| 9d9fdd61e9 | |||
| b0df8f389a | |||
| 75c7f25a30 | |||
| 8bd1c48976 | |||
| 05c98355a6 | |||
| 323dea6798 | |||
| 19cb4b71f5 | |||
| fd86c0f6ad | |||
| 00d44abfad | |||
| 3bd18ab543 | |||
| 4d8bb246f1 | |||
| 07da498d18 | |||
| 0e59f15608 | |||
| bd22891fab | |||
| d96b502524 | |||
| 9cb9d02459 | |||
| 08a3a689c5 | |||
| 034ba599f4 | |||
| 38c5bcb606 | |||
| 0a72f9be67 | |||
| 041583c9b4 | |||
| 5a3e8ea0b1 | |||
| 70907e37bc | |||
| e356d433cb | |||
| cf7777e0af | |||
| 255443b8e1 | |||
| 7e24878077 | |||
| ff1bc7340d | |||
| a7c9abbb9a | |||
| e4bfab7e58 | |||
| d5069d9b96 | |||
| 7498a10c14 | |||
| a8dd41a156 | |||
| bdf3b6ea40 | |||
| 0b89244317 | |||
| 45a17b0254 | |||
| 057891f7d5 | |||
| 5321070720 | |||
| 59e5a95912 | |||
| 985ced6bf5 | |||
| 05a647aac7 | |||
| 1444a81150 | |||
| 1f884fd1db | |||
| e9152df17a | |||
| e7fc091701 | |||
| 7071f8ef5a | |||
| 350dc14a16 | |||
| 68c7672cea | |||
| 381f870bb5 | |||
| 58381b97d2 | |||
| ce4af14ee6 | |||
| 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 |
@@ -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
|
||||||
@@ -22,3 +23,8 @@ STATUS_CHECKER_INTERVAL=60
|
|||||||
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
MCP_API_KEY=mcp_sk_changeme
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
MCP_SERVICE_KEY=svc_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:
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ htmlcov/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.db.back
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# Contributing to Homelable
|
||||||
|
|
||||||
|
Thanks for taking the time to contribute! This document covers everything you need to get started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Ways to Contribute](#ways-to-contribute)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Suggesting Features](#suggesting-features)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Standards](#coding-standards)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||||
|
- [Commit Message Format](#commit-message-format)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ways to Contribute
|
||||||
|
|
||||||
|
- Report bugs or unexpected behavior
|
||||||
|
- Suggest new features or improvements
|
||||||
|
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
|
||||||
|
- Improve documentation
|
||||||
|
- Add service signatures to `service_signatures.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Before opening an issue, search existing ones to avoid duplicates.
|
||||||
|
|
||||||
|
When filing a bug, include:
|
||||||
|
|
||||||
|
- **Homelable version** (visible in the sidebar bottom-left)
|
||||||
|
- **Deployment method** (Docker Compose, Proxmox LXC, source)
|
||||||
|
- **Steps to reproduce**
|
||||||
|
- **Expected vs actual behavior**
|
||||||
|
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
|
||||||
|
- **Browser console errors** if it's a UI issue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggesting Features
|
||||||
|
|
||||||
|
Open an issue with the `enhancement` label. Describe:
|
||||||
|
|
||||||
|
- The problem you're trying to solve
|
||||||
|
- Your proposed solution
|
||||||
|
- Any alternatives you considered
|
||||||
|
|
||||||
|
For large changes, discuss first before writing code — it avoids wasted effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+** and **npm**
|
||||||
|
- **Python 3.11–3.13** (3.14 not yet supported by all dependencies)
|
||||||
|
- **nmap** installed on your system (required for scanner)
|
||||||
|
- **Docker + Docker Compose** (optional, for full-stack testing)
|
||||||
|
|
||||||
|
### 1. Clone the repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Pouzor/homelable.git
|
||||||
|
cd homelable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3.13 -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Copy and configure environment
|
||||||
|
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
|
||||||
|
|
||||||
|
# Start the backend (auto-reloads on change)
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
API docs available at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to `localhost:8000` — the backend must be running.
|
||||||
|
|
||||||
|
### 4. Verify tooling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify-tooling.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
homelable/
|
||||||
|
├── frontend/src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
|
||||||
|
│ │ ├── panels/ # Sidebar, detail panel, toolbar
|
||||||
|
│ │ ├── modals/ # Add/edit node, scan config, pending devices
|
||||||
|
│ │ └── ui/ # Shadcn/ui base components
|
||||||
|
│ ├── stores/ # Zustand state (canvas, auth, scan)
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── types/ # TypeScript interfaces & enums
|
||||||
|
│ ├── api/ # Axios client & typed endpoints
|
||||||
|
│ └── utils/ # Layout, export, color helpers
|
||||||
|
│
|
||||||
|
├── backend/app/
|
||||||
|
│ ├── api/routes/ # FastAPI route handlers
|
||||||
|
│ ├── services/ # Scanner, status checker, canvas service
|
||||||
|
│ ├── db/ # SQLAlchemy models, Alembic migrations
|
||||||
|
│ ├── schemas/ # Pydantic request/response schemas
|
||||||
|
│ └── core/ # Config, JWT, scheduler
|
||||||
|
│
|
||||||
|
├── docker/ # Nginx configs
|
||||||
|
├── scripts/ # LXC bootstrap, dev helpers
|
||||||
|
└── mcp/ # MCP server (AI integration)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- No untested code merged — every feature or fix must include tests
|
||||||
|
- Keep changes focused — one concern per PR
|
||||||
|
|
||||||
|
### Frontend (TypeScript + React)
|
||||||
|
|
||||||
|
- Strict TypeScript — no `any`, no type assertions unless truly necessary
|
||||||
|
- React Flow node domain fields go in `node.data`, never on the node root
|
||||||
|
- State management via Zustand stores — no prop drilling beyond 2 levels
|
||||||
|
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
|
||||||
|
- Run before committing:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Python + FastAPI)
|
||||||
|
|
||||||
|
- Python 3.11+ syntax
|
||||||
|
- Pydantic v2 schemas for all request/response types
|
||||||
|
- SQLAlchemy async sessions — never block the event loop
|
||||||
|
- Scanner logic runs in a background thread — never in an async route directly
|
||||||
|
- All schema changes via Alembic migrations — never modify tables directly
|
||||||
|
- Run before committing:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
ruff check .
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design System
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#0d1117` |
|
||||||
|
| Surface | `#161b22` |
|
||||||
|
| Card | `#21262d` |
|
||||||
|
| Accent cyan | `#00d4ff` |
|
||||||
|
| Online | `#39d353` |
|
||||||
|
| Offline | `#f85149` |
|
||||||
|
| Pending | `#e3b341` |
|
||||||
|
| Font (UI) | Inter |
|
||||||
|
| Font (IPs/ports) | JetBrains Mono |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm test # run all tests
|
||||||
|
npm run test:coverage # with coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
|
||||||
|
|
||||||
|
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
pytest # run all tests
|
||||||
|
pytest -v tests/test_nodes.py # single file
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files live in `backend/tests/test_*.py`.
|
||||||
|
|
||||||
|
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
|
||||||
|
|
||||||
|
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submitting a Pull Request
|
||||||
|
|
||||||
|
1. **Fork** the repo and create a branch from `main`:
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Make your changes** — include tests.
|
||||||
|
|
||||||
|
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
|
||||||
|
|
||||||
|
4. **Open a PR** against `main`:
|
||||||
|
- Use a clear title (see commit format below)
|
||||||
|
- Describe what changed and why
|
||||||
|
- Reference any related issues (`Closes #123`)
|
||||||
|
- Include screenshots for UI changes
|
||||||
|
|
||||||
|
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Message Format
|
||||||
|
|
||||||
|
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>: <short description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | When to use |
|
||||||
|
|---|---|
|
||||||
|
| `feat` | New feature |
|
||||||
|
| `fix` | Bug fix |
|
||||||
|
| `docs` | Documentation only |
|
||||||
|
| `refactor` | Code change with no behavior change |
|
||||||
|
| `test` | Adding or fixing tests |
|
||||||
|
| `chore` | Build, deps, tooling |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
feat: add logout button to sidebar
|
||||||
|
fix: stop click propagation on pending device checkbox
|
||||||
|
docs: add CONTRIBUTING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
|
||||||
@@ -2,13 +2,14 @@ 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 + curl for the health check
|
||||||
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 curl && 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
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
# Create data directory (volume mount point)
|
# Create data directory (volume mount point)
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# 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.
|
||||||
|
# node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||||
|
|
||||||
ARG VITE_STANDALONE=false
|
ARG VITE_STANDALONE=false
|
||||||
ENV VITE_STANDALONE=$VITE_STANDALONE
|
ENV VITE_STANDALONE=$VITE_STANDALONE
|
||||||
@@ -9,6 +12,7 @@ COPY frontend/package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
COPY VERSION ../VERSION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: serve
|
# Stage 2: serve
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 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:
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
|
||||||
|
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
|
||||||
|
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
You can now install Homelable with community-scripts (proxmox-VE) :
|
||||||
|
|
||||||
|
`https://community-scripts.org/scripts/homelable`
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.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
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Remy Jardinet
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -16,95 +16,15 @@ If you just like the design, you can only run the frontend and export your desig
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
|
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
|
||||||
|
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Installation
|
||||||
|
|
||||||
```bash
|
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
|
||||||
cd homelable && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open **http://localhost:3000** — login with `admin` / `admin`.
|
|
||||||
|
|
||||||
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
|
||||||
>
|
|
||||||
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
|
|
||||||
>
|
|
||||||
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
|
|
||||||
|
|
||||||
## Quick Start - Front only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
|
|
||||||
cd homelable && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
|
||||||
cd homelable && docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Pouzor/homelable.git
|
|
||||||
cd homelable
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxmox LXC Install
|
|
||||||
|
|
||||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
|
||||||
|
|
||||||
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
|
||||||
> ```bash
|
|
||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,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
|
||||||
|
|
||||||
@@ -137,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 |
|
||||||
@@ -162,9 +74,34 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration)
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
Add LIVEVIEW_KEY to your .env:
|
||||||
|
|
||||||
|
`LIVEVIEW_KEY=your-secret-key`
|
||||||
|
|
||||||
|
|
||||||
|
Then restart the backend:
|
||||||
|
|
||||||
|
`docker compose restart backend`
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Use this URL to view your canvas:
|
||||||
|
|
||||||
|
http://<your-homelab-ip>/view?key=your-secret-key
|
||||||
|
|
||||||
|
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Server (AI Integration) (optionnal)
|
||||||
|
|
||||||
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|
||||||
### What the AI can do
|
### What the AI can do
|
||||||
|
|
||||||
@@ -248,25 +185,4 @@ Or add it manually to `~/.claude.json`:
|
|||||||
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`.
|
- 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.
|
- The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Mode
|
|
||||||
|
|
||||||
**Backend (Python 3.13):**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python3.13 -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
cp ../.env.example .env # edit SECRET_KEY and review defaults
|
|
||||||
uvicorn app.main:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend:**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -12,12 +14,25 @@ from app.db.database import AsyncSessionLocal, get_db
|
|||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
|
class BulkActionRequest(BaseModel):
|
||||||
|
device_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
interval_seconds: int
|
|
||||||
|
@field_validator("ranges")
|
||||||
|
@classmethod
|
||||||
|
def validate_cidr(cls, v: list[str]) -> list[str]:
|
||||||
|
for r in v:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(r, strict=False)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -26,7 +41,15 @@ router = APIRouter()
|
|||||||
|
|
||||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
await run_scan(ranges, db, run_id)
|
await run_scan(ranges, db, run_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scan run %s failed unexpectedly", run_id)
|
||||||
|
await db.rollback()
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run and run.status == "running":
|
||||||
|
run.status = "failed"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger", response_model=ScanRunResponse)
|
@router.post("/trigger", response_model=ScanRunResponse)
|
||||||
@@ -44,18 +67,105 @@ async def trigger_scan(
|
|||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/stop", response_model=dict)
|
||||||
|
async def stop_scan(
|
||||||
|
run_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
try:
|
||||||
|
uuid.UUID(run_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
||||||
|
if run.status != "running":
|
||||||
|
raise HTTPException(status_code=409, detail="Scan is not running")
|
||||||
|
request_cancel(run_id)
|
||||||
|
return {"stopping": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pending", response_model=dict)
|
||||||
|
async def clear_pending(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, int]:
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
|
await db.commit()
|
||||||
|
return {"deleted": result.rowcount}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/bulk-approve", response_model=dict)
|
||||||
|
async def bulk_approve_devices(
|
||||||
|
payload: BulkActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
|
PendingDevice.status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
created_nodes: list[Node] = []
|
||||||
|
for device in devices:
|
||||||
|
device.status = "approved"
|
||||||
|
node = Node(
|
||||||
|
label=device.hostname or device.ip,
|
||||||
|
type=device.suggested_type or "generic",
|
||||||
|
ip=device.ip,
|
||||||
|
hostname=device.hostname,
|
||||||
|
status="unknown",
|
||||||
|
services=device.services or [],
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
created_nodes.append(node)
|
||||||
|
await db.flush() # populates node.id from Python-side default before reading
|
||||||
|
node_ids = [n.id for n in created_nodes]
|
||||||
|
approved_device_ids = [d.id for d in devices]
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"approved": len(node_ids),
|
||||||
|
"node_ids": node_ids,
|
||||||
|
"device_ids": approved_device_ids,
|
||||||
|
"skipped": len(payload.device_ids) - len(node_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/bulk-hide", response_model=dict)
|
||||||
|
async def bulk_hide_devices(
|
||||||
|
payload: BulkActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
|
PendingDevice.status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
for device in devices:
|
||||||
|
device.status = "hidden"
|
||||||
|
await db.commit()
|
||||||
|
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
async def approve_device(
|
async def approve_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -64,13 +174,24 @@ async def approve_device(
|
|||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
if device.status != "pending":
|
||||||
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
node = Node(**node_data.model_dump())
|
node = Node(
|
||||||
|
label=node_data.label,
|
||||||
|
type=node_data.type,
|
||||||
|
ip=node_data.ip,
|
||||||
|
hostname=node_data.hostname,
|
||||||
|
status=node_data.status,
|
||||||
|
services=node_data.services or [],
|
||||||
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
|
await db.flush()
|
||||||
|
node_id = node.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"approved": True, "node_id": node.id}
|
return {"approved": True, "node_id": node_id}
|
||||||
return {"approved": False}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/hide")
|
@router.post("/pending/{device_id}/hide")
|
||||||
@@ -78,7 +199,8 @@ async def hide_device(
|
|||||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
device.status = "hidden"
|
device.status = "hidden"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"hidden": True}
|
return {"hidden": True}
|
||||||
@@ -89,7 +211,8 @@ async def ignore_device(
|
|||||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
await db.delete(device)
|
await db.delete(device)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"ignored": True}
|
return {"ignored": True}
|
||||||
@@ -103,18 +226,17 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
|||||||
|
|
||||||
@router.get("/config", response_model=ScanConfig)
|
@router.get("/config", response_model=ScanConfig)
|
||||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
return ScanConfig(
|
return ScanConfig(ranges=settings.scanner_ranges)
|
||||||
ranges=settings.scanner_ranges,
|
|
||||||
interval_seconds=settings.status_checker_interval,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
try:
|
previous = settings.scanner_ranges
|
||||||
settings.scanner_ranges = payload.ranges
|
settings.scanner_ranges = payload.ranges
|
||||||
settings.status_checker_interval = payload.interval_seconds
|
try:
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
settings.scanner_ranges = previous
|
||||||
|
logger.error("Failed to save scan config: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""App-level settings (status checker interval, etc.)."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(BaseModel):
|
||||||
|
interval_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AppSettings)
|
||||||
|
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||||
|
return AppSettings(interval_seconds=settings.status_checker_interval)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AppSettings)
|
||||||
|
async def update_settings(
|
||||||
|
payload: AppSettings, _: str = Depends(get_current_user)
|
||||||
|
) -> AppSettings:
|
||||||
|
try:
|
||||||
|
settings.status_checker_interval = payload.interval_seconds
|
||||||
|
settings.save_overrides()
|
||||||
|
return payload
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
@@ -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:
|
||||||
|
# Accept first so we can send a close frame with a reason code
|
||||||
|
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):
|
if not token or not decode_token(token):
|
||||||
await websocket.close(code=1008) # Policy Violation
|
await websocket.close(code=1008) # Policy Violation
|
||||||
return
|
return
|
||||||
await websocket.accept()
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
_connections.append(websocket)
|
_connections.append(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
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__)
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
for candidate in [
|
||||||
|
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||||
|
Path("/app/VERSION"), # Docker image
|
||||||
|
]:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate.read_text().strip()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
APP_VERSION = _read_version()
|
||||||
|
|
||||||
|
|
||||||
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,6 +34,17 @@ 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"]
|
||||||
|
|
||||||
@@ -30,6 +56,11 @@ class Settings(BaseSettings):
|
|||||||
# Leave empty to disable MCP service key auth.
|
# Leave empty to disable MCP service key auth.
|
||||||
mcp_service_key: str = ""
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""APScheduler setup for background scan and status check jobs."""
|
"""APScheduler setup for background scan and status check jobs."""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -15,43 +16,94 @@ logger = logging.getLogger(__name__)
|
|||||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
async def _run_status_checks() -> None:
|
async def _check_single_node(
|
||||||
"""Check all nodes and broadcast results via WebSocket."""
|
node_id: str,
|
||||||
|
check_method: str,
|
||||||
|
check_target: str | None,
|
||||||
|
ip: str | None,
|
||||||
|
) -> tuple[str, dict[str, object] | None]:
|
||||||
|
"""Run a single node check; returns (node_id, result_or_None).
|
||||||
|
|
||||||
|
Accepts plain scalars — not an ORM object — so there is no risk of
|
||||||
|
DetachedInstanceError when the originating session has already closed.
|
||||||
|
"""
|
||||||
from app.api.routes.status import broadcast_status # avoid circular import
|
from app.api.routes.status import broadcast_status # avoid circular import
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
result = await db.execute(select(Node))
|
|
||||||
nodes = result.scalars().all()
|
|
||||||
|
|
||||||
for node in nodes:
|
|
||||||
if not node.check_method:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
check_result = await check_node(node.check_method, node.check_target, node.ip)
|
check_result = await check_node(check_method, check_target, ip)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
n = await db.get(Node, node.id)
|
n = await db.get(Node, node_id)
|
||||||
if n:
|
if n:
|
||||||
n.status = check_result["status"]
|
n.status = check_result["status"]
|
||||||
n.response_time_ms = check_result["response_time_ms"]
|
n.response_time_ms = check_result["response_time_ms"]
|
||||||
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
|
if check_result["status"] == "online":
|
||||||
|
n.last_seen = now
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await broadcast_status(
|
await broadcast_status(
|
||||||
node_id=node.id,
|
node_id=node_id,
|
||||||
status=check_result["status"],
|
status=check_result["status"],
|
||||||
checked_at=datetime.now(timezone.utc).isoformat(),
|
checked_at=now.isoformat(),
|
||||||
response_time_ms=check_result["response_time_ms"],
|
response_time_ms=check_result["response_time_ms"],
|
||||||
)
|
)
|
||||||
|
return node_id, check_result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Status check failed for node %s: %s", node.id, exc)
|
logger.error("Status check failed for node %s: %s", node_id, exc)
|
||||||
|
return node_id, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_status_checks() -> None:
|
||||||
|
"""Check all nodes concurrently and broadcast results via WebSocket."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Node))
|
||||||
|
nodes = result.scalars().all()
|
||||||
|
# Extract scalars while the session is open to avoid DetachedInstanceError
|
||||||
|
checkable = [
|
||||||
|
(n.id, n.check_method, n.check_target, n.ip)
|
||||||
|
for n in nodes
|
||||||
|
if n.check_method
|
||||||
|
]
|
||||||
|
|
||||||
|
if not checkable:
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.gather(*[
|
||||||
|
_check_single_node(node_id, method, target, ip)
|
||||||
|
for node_id, method, target, ip in checkable
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
|
if scheduler.running:
|
||||||
|
try:
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
|
scheduler.add_job(
|
||||||
|
_run_status_checks,
|
||||||
|
"interval",
|
||||||
|
seconds=settings.status_checker_interval,
|
||||||
|
id="status_checks",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule_status_checks(interval_seconds: int) -> None:
|
||||||
|
"""Update the status check interval on the running scheduler."""
|
||||||
|
if interval_seconds < 10:
|
||||||
|
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
|
||||||
|
if not scheduler.running:
|
||||||
|
logger.warning("Scheduler not running, skipping reschedule")
|
||||||
|
return
|
||||||
|
scheduler.reschedule_job("status_checks", trigger="interval", seconds=interval_seconds)
|
||||||
|
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
|
if scheduler.running:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
try:
|
||||||
return bool(pwd_context.verify(plain, hashed))
|
return bool(pwd_context.verify(plain, hashed))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from collections.abc import AsyncGenerator
|
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
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import APP_VERSION, settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Ensure the data directory exists before SQLite tries to open the file
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -22,26 +27,92 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_db() -> None:
|
||||||
|
db_path = Path(settings.sqlite_path)
|
||||||
|
if not db_path.exists():
|
||||||
|
return
|
||||||
|
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
|
||||||
|
if backup_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
shutil.copy2(db_path, backup_path)
|
||||||
|
logger.info("DB backup created: %s", backup_path.name)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Could not create DB backup at %s", backup_path)
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
|
_backup_db()
|
||||||
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(Exception):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
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")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||||
|
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||||
|
with suppress(OperationalError):
|
||||||
|
rows = await conn.exec_driver_sql(
|
||||||
|
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||||
|
"FROM nodes WHERE properties IS NULL"
|
||||||
|
)
|
||||||
|
for row in rows.fetchall():
|
||||||
|
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||||
|
props = []
|
||||||
|
visible = bool(show_hardware)
|
||||||
|
if cpu_model:
|
||||||
|
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||||
|
if cpu_count is not None:
|
||||||
|
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||||
|
if ram_gb is not None:
|
||||||
|
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||||
|
if disk_gb is not None:
|
||||||
|
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||||
|
import json as _json
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
|
(_json.dumps(props), node_id),
|
||||||
|
)
|
||||||
|
# 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,15 +33,23 @@ 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)
|
||||||
|
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
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)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||||
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
||||||
|
|
||||||
@@ -58,9 +66,10 @@ 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[bool] = mapped_column(Boolean, default=False)
|
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)
|
||||||
|
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +92,7 @@ class PendingDevice(Base):
|
|||||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||||
status: Mapped[str] = mapped_column(String, default="pending")
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
|
discovery_source: Mapped[str | None] = mapped_column(String)
|
||||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -5,7 +7,8 @@ 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.api.routes import settings as settings_routes
|
||||||
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
|
||||||
@@ -13,6 +16,16 @@ from app.db.database import init_db
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
# Ensure app logs are visible: attach a handler to the root logger if none
|
||||||
|
# exists (uvicorn only installs handlers on its own loggers, not the root).
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
if not root_logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
logging.getLogger("app").setLevel(logging.INFO)
|
||||||
|
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
|
||||||
await init_db()
|
await init_db()
|
||||||
settings.load_overrides()
|
settings.load_overrides()
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
@@ -22,7 +35,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.9.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +43,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 +53,8 @@ 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(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
|
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,15 @@ 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
|
||||||
|
properties: list[Any] = []
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
bottom_handles: int = 1
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
@@ -37,9 +47,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: bool = False
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
waypoints: list[dict[str, float]] | 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):
|
||||||
|
|||||||
@@ -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: bool = False
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
waypoints: list[dict[str, float]] | 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):
|
||||||
@@ -28,9 +36,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: bool | 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
|
||||||
|
waypoints: list[dict[str, float]] | 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):
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ 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
|
||||||
|
properties: list[dict[str, Any]] = []
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
bottom_handles: int = 1
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -46,6 +55,15 @@ class NodeUpdate(BaseModel):
|
|||||||
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
|
||||||
|
properties: list[dict[str, Any]] | None = None
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
bottom_handles: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
services: list[Any]
|
services: list[Any]
|
||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
|
discovery_source: str | None
|
||||||
discovered_at: datetime
|
discovered_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -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', 'basic'):
|
||||||
|
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:
|
||||||
|
if _SIGNATURES is None:
|
||||||
|
path = Path(__file__).parent.parent / "data" / "service_signatures.json"
|
||||||
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
_SIGNATURES = json.load(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
|
||||||
|
|
||||||
|
|
||||||
@@ -55,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated)
|
# Known OUI prefixes — lowercase, colon-separated, first 3 octets
|
||||||
_MAC_OUI_TYPES: dict[str, str] = {
|
_MAC_OUI_TYPES: dict[str, str] = {
|
||||||
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
|
# Hypervisors / VMs
|
||||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
|
"52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
|
||||||
|
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
|
||||||
"00:50:56": "vm", # VMware
|
"00:50:56": "vm", # VMware
|
||||||
"00:0c:29": "vm", # VMware Workstation / Fusion
|
"00:0c:29": "vm", # VMware Workstation / Fusion
|
||||||
"08:00:27": "vm", # VirtualBox
|
"08:00:27": "vm", # VirtualBox
|
||||||
"00:15:5d": "vm", # Hyper-V
|
"00:15:5d": "vm", # Hyper-V
|
||||||
|
# Shelly
|
||||||
|
"34:94:54": "iot",
|
||||||
|
"84:f3:eb": "iot",
|
||||||
|
"ec:fa:bc": "iot",
|
||||||
|
"30:c6:f7": "iot",
|
||||||
|
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
|
||||||
|
"a0:20:a6": "iot",
|
||||||
|
"24:62:ab": "iot",
|
||||||
|
"30:ae:a4": "iot",
|
||||||
|
"cc:50:e3": "iot",
|
||||||
|
"ac:67:b2": "iot",
|
||||||
|
"b4:e6:2d": "iot",
|
||||||
|
"3c:71:bf": "iot",
|
||||||
|
"8c:aa:b5": "iot",
|
||||||
|
# Sonoff / ITEAD
|
||||||
|
"dc:4f:22": "iot",
|
||||||
|
"e8:db:84": "iot",
|
||||||
|
# Tapo / TP-Link smart home
|
||||||
|
"b0:a7:b9": "iot",
|
||||||
|
"50:c7:bf": "iot",
|
||||||
|
"1c:3b:f3": "iot",
|
||||||
|
"10:27:f5": "iot",
|
||||||
|
# Philips Hue
|
||||||
|
"00:17:88": "iot",
|
||||||
|
"ec:b5:fa": "iot",
|
||||||
|
# IKEA Tradfri
|
||||||
|
"34:13:e8": "iot",
|
||||||
|
"00:21:2e": "iot",
|
||||||
|
# Tuya / Smart Life (widely used chip in many brands)
|
||||||
|
"d8:f1:5b": "iot",
|
||||||
|
"68:57:2d": "iot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,10 +133,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
|||||||
37777: "camera", # Dahua
|
37777: "camera", # Dahua
|
||||||
34567: "camera", # Amcrest
|
34567: "camera", # Amcrest
|
||||||
2020: "camera", # Tapo
|
2020: "camera", # Tapo
|
||||||
# Smart-home / MQTT → iot
|
# Smart-home / MQTT / CoAP → iot
|
||||||
1883: "iot",
|
1883: "iot",
|
||||||
8883: "iot",
|
8883: "iot",
|
||||||
6052: "iot", # ESPHome
|
6052: "iot", # ESPHome dashboard
|
||||||
|
4915: "iot", # Shelly CoIoT
|
||||||
|
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
|
||||||
|
5684: "iot", # CoAP DTLS
|
||||||
# AP / wireless
|
# AP / wireless
|
||||||
8880: "ap", # UniFi HTTP
|
8880: "ap", # UniFi HTTP
|
||||||
8443: "ap", # UniFi HTTPS
|
8443: "ap", # UniFi HTTPS
|
||||||
@@ -105,8 +150,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
|||||||
|
|
||||||
|
|
||||||
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
||||||
"""Suggest a node type based on matched signatures and MAC OUI."""
|
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
|
||||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
|
# IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
|
||||||
|
mac_type = suggest_type_from_mac(mac)
|
||||||
|
if mac_type == "iot":
|
||||||
|
return "iot"
|
||||||
|
|
||||||
|
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
|
||||||
found: set[str] = set()
|
found: set[str] = set()
|
||||||
for p in open_ports:
|
for p in open_ports:
|
||||||
port = p["port"]
|
port = p["port"]
|
||||||
@@ -116,10 +166,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
|
|||||||
found.add(sig["suggested_node_type"])
|
found.add(sig["suggested_node_type"])
|
||||||
if port in _PORT_TYPE_HINTS:
|
if port in _PORT_TYPE_HINTS:
|
||||||
found.add(_PORT_TYPE_HINTS[port])
|
found.add(_PORT_TYPE_HINTS[port])
|
||||||
# MAC OUI is a lower-priority hint — only used if ports give no better answer
|
|
||||||
mac_type = suggest_type_from_mac(mac)
|
|
||||||
if mac_type:
|
if mac_type:
|
||||||
found.add(mac_type)
|
found.add(mac_type)
|
||||||
|
|
||||||
for t in priority:
|
for t in priority:
|
||||||
if t in found:
|
if t in found:
|
||||||
return t
|
return t
|
||||||
|
|||||||
@@ -1,17 +1,48 @@
|
|||||||
"""Network scanner: ARP sweep + nmap service detection."""
|
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Run IDs that have been requested to cancel (thread-safe via lock)
|
||||||
|
_cancelled_runs: set[str] = set()
|
||||||
|
_cancelled_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Port list for service detection (Phase 2)
|
||||||
|
_EXTRA_PORTS = (
|
||||||
|
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
||||||
|
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
||||||
|
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
|
||||||
|
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
|
||||||
|
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
|
||||||
|
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
|
||||||
|
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
|
||||||
|
"16686,34567,37777,51413,64738"
|
||||||
|
)
|
||||||
|
|
||||||
|
_MDNS_SERVICE_TYPES = [
|
||||||
|
"_http._tcp.local.",
|
||||||
|
"_shelly._tcp.local.",
|
||||||
|
"_esphomelib._tcp.local.",
|
||||||
|
"_hap._tcp.local.", # HomeKit Accessory Protocol
|
||||||
|
"_mqtt._tcp.local.",
|
||||||
|
"_device-info._tcp.local.",
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nmap
|
import nmap
|
||||||
_NMAP_AVAILABLE = True
|
_NMAP_AVAILABLE = True
|
||||||
@@ -19,50 +50,24 @@ except ImportError:
|
|||||||
_NMAP_AVAILABLE = False
|
_NMAP_AVAILABLE = False
|
||||||
logger.warning("python-nmap not available — scanner will run in mock mode")
|
logger.warning("python-nmap not available — scanner will run in mock mode")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zeroconf import ServiceStateChange
|
||||||
|
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||||
|
_ZEROCONF_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_ZEROCONF_AVAILABLE = False
|
||||||
|
logger.warning("zeroconf not available — mDNS discovery disabled")
|
||||||
|
|
||||||
def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
|
||||||
"""Run nmap -sV --open on target, return list of host dicts."""
|
|
||||||
if not _NMAP_AVAILABLE:
|
|
||||||
return _mock_scan(target)
|
|
||||||
|
|
||||||
nm = nmap.PortScanner()
|
def request_cancel(run_id: str) -> None:
|
||||||
try:
|
"""Signal a running scan to stop early."""
|
||||||
# Home lab port range: standard top-1000 + common self-hosted service ports
|
with _cancelled_lock:
|
||||||
extra_ports = (
|
_cancelled_runs.add(run_id)
|
||||||
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
|
||||||
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
|
||||||
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
|
|
||||||
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
|
|
||||||
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
|
|
||||||
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
|
|
||||||
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
|
|
||||||
)
|
|
||||||
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("nmap scan failed: %s", exc)
|
|
||||||
raise RuntimeError(str(exc)) from exc
|
|
||||||
|
|
||||||
hosts = []
|
|
||||||
for host in nm.all_hosts():
|
def _is_cancelled(run_id: str) -> bool:
|
||||||
if nm[host].state() != "up":
|
with _cancelled_lock:
|
||||||
continue
|
return run_id in _cancelled_runs
|
||||||
open_ports = []
|
|
||||||
for proto in nm[host].all_protocols():
|
|
||||||
for port, info in nm[host][proto].items():
|
|
||||||
if info["state"] == "open":
|
|
||||||
open_ports.append({
|
|
||||||
"port": port,
|
|
||||||
"protocol": proto,
|
|
||||||
"banner": info.get("product", "") + " " + info.get("version", ""),
|
|
||||||
})
|
|
||||||
hosts.append({
|
|
||||||
"ip": host,
|
|
||||||
"hostname": _resolve_hostname(host),
|
|
||||||
"mac": nm[host].get("addresses", {}).get("mac"),
|
|
||||||
"os": _extract_os(nm, host),
|
|
||||||
"open_ports": open_ports,
|
|
||||||
})
|
|
||||||
return hosts
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_hostname(ip: str) -> str | None:
|
def _resolve_hostname(ip: str) -> str | None:
|
||||||
@@ -82,6 +87,278 @@ def _extract_os(nm: object, host: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Read the OS ARP cache for recently-seen hosts in the target network.
|
||||||
|
Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
|
||||||
|
Supplements nmap discovery — catches IoT and devices with all ports filtered.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(network, strict=False)
|
||||||
|
found: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
|
||||||
|
proc_arp = "/proc/net/arp"
|
||||||
|
try:
|
||||||
|
with open(proc_arp) as f:
|
||||||
|
for line in f.readlines()[1:]: # skip header row
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
ip, mac = parts[0], parts[3]
|
||||||
|
if mac == "00:00:00:00:00:00":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip) in net:
|
||||||
|
found[ip] = {
|
||||||
|
"ip": ip, "mac": mac,
|
||||||
|
"hostname": _resolve_hostname(ip),
|
||||||
|
"os": None, "open_ports": [],
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# /proc/net/arp opened successfully — return whatever we found (may be empty)
|
||||||
|
# Don't fall through to `arp -a` since we're on Linux
|
||||||
|
return found
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # Not Linux — fall through to macOS `arp -a`
|
||||||
|
|
||||||
|
# macOS: parse `arp -a` output
|
||||||
|
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
ip, mac = m.group(1), m.group(2)
|
||||||
|
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip) in net:
|
||||||
|
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return found
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Phase 1: Concurrent ICMP ping sweep + ARP cache.
|
||||||
|
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
|
||||||
|
Supplements with the OS ARP cache to catch devices that block ICMP.
|
||||||
|
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
|
||||||
|
"""
|
||||||
|
net = ipaddress.ip_network(target, strict=False)
|
||||||
|
all_ips = [str(ip) for ip in net.hosts()]
|
||||||
|
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(50)
|
||||||
|
|
||||||
|
async def _ping(ip: str) -> str | None:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ping", "-c", "1", "-W", "1", ip,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
return ip if proc.returncode == 0 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
|
||||||
|
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
|
||||||
|
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
|
||||||
|
|
||||||
|
# ARP cache: catch devices that block ICMP but were recently active,
|
||||||
|
# and enrich ping-alive hosts with their MAC addresses.
|
||||||
|
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
|
||||||
|
|
||||||
|
alive: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for ip in alive_ips:
|
||||||
|
mac = arp_cache.get(ip, {}).get("mac")
|
||||||
|
hostname = await asyncio.to_thread(_resolve_hostname, ip)
|
||||||
|
logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
|
||||||
|
alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
|
||||||
|
|
||||||
|
for ip, host in arp_cache.items():
|
||||||
|
if ip not in alive:
|
||||||
|
logger.info(
|
||||||
|
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
|
||||||
|
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
|
||||||
|
)
|
||||||
|
alive[ip] = host
|
||||||
|
|
||||||
|
return alive
|
||||||
|
|
||||||
|
|
||||||
|
def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Phase 2 — single-IP port scan with service detection.
|
||||||
|
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
||||||
|
"""
|
||||||
|
ip = host_dict["ip"]
|
||||||
|
logger.info("[Phase 2] Scanning %s ...", ip)
|
||||||
|
|
||||||
|
if not _NMAP_AVAILABLE:
|
||||||
|
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
is_root = os.geteuid() == 0
|
||||||
|
if is_root:
|
||||||
|
# SYN scan + version detection (fastest, most accurate)
|
||||||
|
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
||||||
|
else:
|
||||||
|
# TCP connect scan (-sT) — no raw sockets needed, works without root.
|
||||||
|
# nmap auto-selects -sT without root but being explicit avoids edge cases.
|
||||||
|
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
||||||
|
|
||||||
|
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
||||||
|
nm = nmap.PortScanner()
|
||||||
|
try:
|
||||||
|
nm.scan(hosts=ip, arguments=scan_args)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
all_scanned = nm.all_hosts()
|
||||||
|
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
|
||||||
|
if ip not in all_scanned:
|
||||||
|
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
open_ports = []
|
||||||
|
for proto in nm[ip].all_protocols():
|
||||||
|
for port, info in nm[ip][proto].items():
|
||||||
|
if info["state"] == "open":
|
||||||
|
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
||||||
|
open_ports.append({"port": port, "protocol": proto, "banner": banner})
|
||||||
|
|
||||||
|
if open_ports:
|
||||||
|
port_summary = ", ".join(
|
||||||
|
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
||||||
|
)
|
||||||
|
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
||||||
|
else:
|
||||||
|
logger.info("[Phase 2] %s — 0 open ports detected", ip)
|
||||||
|
|
||||||
|
host_dict["open_ports"] = open_ports
|
||||||
|
if not host_dict["mac"]:
|
||||||
|
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
|
||||||
|
host_dict["os"] = _extract_os(nm, ip)
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
|
||||||
|
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Phase 2: Per-IP service detection with bounded concurrency.
|
||||||
|
Each host is scanned independently in a thread — no inter-host timeout interference.
|
||||||
|
Up to 10 hosts scanned concurrently.
|
||||||
|
"""
|
||||||
|
if not alive:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
|
||||||
|
semaphore = asyncio.Semaphore(10)
|
||||||
|
|
||||||
|
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with semaphore:
|
||||||
|
return await asyncio.to_thread(_nmap_scan_single, host_dict)
|
||||||
|
|
||||||
|
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
|
||||||
|
results = []
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, BaseException):
|
||||||
|
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
|
||||||
|
else:
|
||||||
|
results.append(item)
|
||||||
|
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Two-phase scan for a CIDR range.
|
||||||
|
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
|
||||||
|
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
|
||||||
|
"""
|
||||||
|
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
|
||||||
|
if not _NMAP_AVAILABLE:
|
||||||
|
logger.warning("[Scan] nmap not available — returning mock data")
|
||||||
|
return _mock_scan(target)
|
||||||
|
try:
|
||||||
|
alive = await _ping_sweep(target)
|
||||||
|
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
|
||||||
|
len(alive), target, ", ".join(sorted(alive.keys())))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Phase 1 ping sweep failed: %s", exc)
|
||||||
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
return await _nmap_port_scan(alive)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Passive mDNS/Bonjour sweep.
|
||||||
|
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
|
||||||
|
Runs for `timeout` seconds then returns what it found.
|
||||||
|
"""
|
||||||
|
if not _ZEROCONF_AVAILABLE:
|
||||||
|
return []
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
found_services: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def _on_change(
|
||||||
|
zeroconf: Any,
|
||||||
|
service_type: str,
|
||||||
|
name: str,
|
||||||
|
state_change: Any,
|
||||||
|
) -> None:
|
||||||
|
if state_change == ServiceStateChange.Added:
|
||||||
|
found_services.append((service_type, name))
|
||||||
|
|
||||||
|
discovered: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncZeroconf() as azc:
|
||||||
|
browser = AsyncServiceBrowser(
|
||||||
|
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
|
||||||
|
)
|
||||||
|
await asyncio.sleep(timeout)
|
||||||
|
await browser.async_cancel()
|
||||||
|
|
||||||
|
for service_type, name in found_services:
|
||||||
|
try:
|
||||||
|
info = AsyncServiceInfo(service_type, name)
|
||||||
|
await info.async_request(azc.zeroconf, 3000)
|
||||||
|
if not info.addresses:
|
||||||
|
continue
|
||||||
|
ip = str(ipaddress.IPv4Address(info.addresses[0]))
|
||||||
|
if ip in discovered:
|
||||||
|
continue
|
||||||
|
discovered[ip] = {
|
||||||
|
"ip": ip,
|
||||||
|
"hostname": info.server,
|
||||||
|
"mac": None,
|
||||||
|
"os": None,
|
||||||
|
"open_ports": (
|
||||||
|
[{"port": info.port, "protocol": "tcp", "banner": ""}]
|
||||||
|
if info.port else []
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("mDNS resolution failed for %s: %s", name, exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("mDNS discovery error: %s", exc)
|
||||||
|
|
||||||
|
logger.info("mDNS discovery found %d device(s)", len(discovered))
|
||||||
|
return list(discovered.values())
|
||||||
|
|
||||||
|
|
||||||
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||||
"""Return fake results for dev/test environments without nmap."""
|
"""Return fake results for dev/test environments without nmap."""
|
||||||
return [
|
return [
|
||||||
@@ -100,25 +377,62 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||||
# Avoid circular import
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.status import broadcast_scan_update
|
from app.api.routes.status import broadcast_scan_update
|
||||||
|
|
||||||
devices_found = 0
|
devices_found = 0
|
||||||
|
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
|
||||||
try:
|
try:
|
||||||
for cidr in ranges:
|
# Validate all ranges are valid CIDRs before passing anything to nmap
|
||||||
# Run nmap in a thread pool — does not block the event loop
|
for r in ranges:
|
||||||
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
try:
|
||||||
|
ipaddress.ip_network(r, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
||||||
|
|
||||||
|
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
|
||||||
|
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
||||||
|
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
|
||||||
|
|
||||||
|
hidden_ips_result = await db.execute(
|
||||||
|
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
||||||
|
)
|
||||||
|
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
||||||
|
|
||||||
|
# Clean up stale pending devices whose IPs are already in the canvas
|
||||||
|
if canvas_ips:
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
await db.execute(
|
||||||
|
sa_delete(PendingDevice).where(
|
||||||
|
PendingDevice.status == "pending",
|
||||||
|
PendingDevice.ip.in_(canvas_ips),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Start mDNS discovery in the background while nmap scans run
|
||||||
|
mdns_task = asyncio.create_task(_mdns_discover())
|
||||||
|
|
||||||
|
# Track IPs found by nmap so mDNS doesn't duplicate them
|
||||||
|
nmap_ips: set[str] = set()
|
||||||
|
|
||||||
|
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
|
||||||
|
nonlocal devices_found
|
||||||
|
ip = host["ip"]
|
||||||
|
|
||||||
|
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
|
||||||
|
if ip in canvas_ips:
|
||||||
|
logger.debug("Skipping %s — already in canvas", ip)
|
||||||
|
return
|
||||||
|
if ip in hidden_ips:
|
||||||
|
logger.debug("Skipping %s — hidden by user", ip)
|
||||||
|
return
|
||||||
|
|
||||||
for host in hosts:
|
|
||||||
services = fingerprint_ports(host["open_ports"])
|
services = fingerprint_ports(host["open_ports"])
|
||||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||||
|
|
||||||
# Update existing pending device or create a new one
|
|
||||||
existing_result = await db.execute(
|
existing_result = await db.execute(
|
||||||
select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
PendingDevice.ip == host["ip"],
|
PendingDevice.ip == ip,
|
||||||
PendingDevice.status == "pending",
|
PendingDevice.status == "pending",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,43 +444,69 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
existing.services = services
|
existing.services = services
|
||||||
existing.suggested_type = suggested_type
|
existing.suggested_type = suggested_type
|
||||||
else:
|
else:
|
||||||
device = PendingDevice(
|
db.add(PendingDevice(
|
||||||
ip=host["ip"],
|
ip=ip,
|
||||||
mac=host.get("mac"),
|
mac=host.get("mac"),
|
||||||
hostname=host.get("hostname"),
|
hostname=host.get("hostname"),
|
||||||
os=host.get("os"),
|
os=host.get("os"),
|
||||||
services=services,
|
services=services,
|
||||||
suggested_type=suggested_type,
|
suggested_type=suggested_type,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
discovery_source=discovery_source,
|
||||||
db.add(device)
|
))
|
||||||
devices_found += 1
|
devices_found += 1
|
||||||
|
|
||||||
# Commit immediately so the device is visible right away
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||||
|
|
||||||
# Update running count on the scan run record
|
# nmap scan per CIDR — results stream in progressively
|
||||||
|
for cidr in ranges:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
hosts = await _nmap_scan(cidr)
|
||||||
|
for host in hosts:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
nmap_ips.add(host["ip"])
|
||||||
|
await _process_host(host)
|
||||||
|
|
||||||
|
# Update ScanRun count once after all CIDR ranges
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.devices_found = devices_found
|
run.devices_found = devices_found
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Push WS event so the frontend refreshes pending panel
|
# Collect mDNS results — task already has its own 4s internal timeout
|
||||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
if not _is_cancelled(run_id):
|
||||||
|
mdns_hosts = await mdns_task
|
||||||
|
|
||||||
# Mark scan as done
|
for host in mdns_hosts:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
if host["ip"] in nmap_ips:
|
||||||
|
continue # already processed with richer nmap data
|
||||||
|
await _process_host(host, discovery_source="mdns")
|
||||||
|
else:
|
||||||
|
mdns_task.cancel()
|
||||||
|
|
||||||
|
# Mark scan as done or cancelled
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "done"
|
run.status = "cancelled" if _is_cancelled(run_id) else "done"
|
||||||
run.devices_found = devices_found
|
run.devices_found = devices_found
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Scan failed: %s", exc)
|
logger.error("Scan failed: %s", exc)
|
||||||
|
if mdns_task is not None and not mdns_task.done():
|
||||||
|
mdns_task.cancel()
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "error"
|
run.status = "error"
|
||||||
run.error = str(exc)
|
run.error = str(exc)
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
with _cancelled_lock:
|
||||||
|
_cancelled_runs.discard(run_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
if check_method == "none":
|
if check_method == "none":
|
||||||
return {"status": "online", "response_time_ms": None}
|
return {"status": "online", "response_time_ms": None}
|
||||||
|
|
||||||
host = target or ip
|
# Use only the first IP when the field contains comma-separated addresses
|
||||||
|
raw_ip = ip.split(",")[0].strip() if ip else None
|
||||||
|
host = target or raw_ip
|
||||||
if not host:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
|
||||||
@@ -57,8 +60,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
else:
|
||||||
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ping", "-c", "1", "-W", "1", host,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
scan_config.json
|
scan_config.json
|
||||||
|
homelab.db.*
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
omit = ["*/migrations/*", "*/tests/*"]
|
omit = ["*/migrations/*", "*/tests/*"]
|
||||||
|
concurrency = ["thread"]
|
||||||
|
core = "sysmon"
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
skip_empty = true
|
skip_empty = true
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ pydantic-settings==2.5.2
|
|||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.26
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
zeroconf==0.131.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
mypy==1.11.2
|
mypy==1.11.2
|
||||||
pytest==8.3.3
|
pytest==9.0.3
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==1.3.0
|
||||||
pytest-cov==5.0.0
|
pytest-cov==5.0.0
|
||||||
|
|||||||
@@ -56,3 +56,15 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
|||||||
settings.mcp_service_key = ""
|
settings.mcp_service_key = ""
|
||||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||||
assert res.status_code == 401
|
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,403 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
# ── properties ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_default_empty(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]["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
|
||||||
|
props = [
|
||||||
|
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
|
||||||
|
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
|
||||||
|
]
|
||||||
|
n1 = node_payload(properties=props)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
returned = canvas["nodes"][0]["properties"]
|
||||||
|
assert len(returned) == 2
|
||||||
|
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
|
||||||
|
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_updated = {**n1, "properties": [
|
||||||
|
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
|
||||||
|
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
|
||||||
|
]}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
props = canvas["nodes"][0]["properties"]
|
||||||
|
assert len(props) == 2
|
||||||
|
assert props[0]["value"] == "64 GB"
|
||||||
|
assert props[1]["key"] == "Disk"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
|
||||||
|
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
|
||||||
|
n1 = node_payload(properties=props)
|
||||||
|
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]["properties"][0]["icon"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_cleared = {**n1, "properties": []}
|
||||||
|
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]["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── edge waypoints & handles ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["waypoints"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
returned = canvas["edges"][0]["waypoints"]
|
||||||
|
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(bottom_handles=3)
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
edge = canvas["edges"][0]
|
||||||
|
assert edge["source_handle"] == "bottom-1"
|
||||||
|
assert edge["target_handle"] == "top"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["animated"] == "snake"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["animated"] == "basic"
|
||||||
|
|
||||||
|
|
||||||
|
# ── node fields ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(
|
||||||
|
type="server",
|
||||||
|
label="Main Server",
|
||||||
|
hostname="server.local",
|
||||||
|
ip="192.168.1.10",
|
||||||
|
mac="aa:bb:cc:dd:ee:ff",
|
||||||
|
os="Ubuntu 22.04",
|
||||||
|
status="online",
|
||||||
|
check_method="http",
|
||||||
|
check_target="http://192.168.1.10",
|
||||||
|
services=[{"name": "nginx", "port": 80}],
|
||||||
|
notes="Primary web server",
|
||||||
|
pos_x=150.0,
|
||||||
|
pos_y=250.0,
|
||||||
|
bottom_handles=2,
|
||||||
|
)
|
||||||
|
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["hostname"] == "server.local"
|
||||||
|
assert node["ip"] == "192.168.1.10"
|
||||||
|
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||||
|
assert node["os"] == "Ubuntu 22.04"
|
||||||
|
assert node["status"] == "online"
|
||||||
|
assert node["check_method"] == "http"
|
||||||
|
assert node["check_target"] == "http://192.168.1.10"
|
||||||
|
assert node["services"] == [{"name": "nginx", "port": 80}]
|
||||||
|
assert node["notes"] == "Primary web server"
|
||||||
|
assert node["pos_x"] == 150.0
|
||||||
|
assert node["pos_y"] == 250.0
|
||||||
|
assert node["bottom_handles"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(bottom_handles=4)
|
||||||
|
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]["bottom_handles"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_bottom_handles_defaults_one(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]["bottom_handles"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
|
||||||
|
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
|
||||||
|
n1 = node_payload(services=services, notes="My NAS device")
|
||||||
|
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["services"] == services
|
||||||
|
assert node["notes"] == "My NAS device"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
||||||
|
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
||||||
|
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
||||||
|
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]["services"] == services
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
||||||
|
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["check_method"] == "ping"
|
||||||
|
assert node["check_target"] == "192.168.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── parent/child nodes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
|
||||||
|
parent = node_payload(type="proxmox", label="PVE Host")
|
||||||
|
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||||
|
assert node_map[child["id"]]["parent_id"] == parent["id"]
|
||||||
|
assert node_map[parent["id"]]["parent_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
|
||||||
|
parent = node_payload(type="proxmox", label="PVE Host")
|
||||||
|
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
# Remove both parent and child
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── groupRect / group node ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
|
||||||
|
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
|
||||||
|
member = node_payload(type="server", label="Member", parent_id=group["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||||
|
assert node_map[group["id"]]["type"] == "group"
|
||||||
|
assert node_map[group["id"]]["width"] == 400.0
|
||||||
|
assert node_map[group["id"]]["height"] == 300.0
|
||||||
|
assert node_map[member["id"]]["parent_id"] == group["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── viewport ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
|
||||||
|
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
|
||||||
|
|
||||||
|
|
||||||
|
# ── edge types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
edge = canvas["edges"][0]
|
||||||
|
assert edge["type"] == "vlan"
|
||||||
|
assert edge["vlan_id"] == 10
|
||||||
|
assert edge["label"] == "VLAN 10"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
|
||||||
|
"""Second save updates an existing edge (exercises the db_edge branch)."""
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], label="original")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
edge = canvas["edges"][0]
|
||||||
|
assert edge["label"] == "updated"
|
||||||
|
assert edge["custom_color"] == "#ff0000"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Tests for automatic DB backup before migrations.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.db.database import _backup_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def tmp_db(tmp_path: Path):
|
||||||
|
db = tmp_path / "homelab.db"
|
||||||
|
db.write_bytes(b"SQLite placeholder")
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_created_when_db_exists(tmp_db: Path):
|
||||||
|
with patch("app.db.database.settings") as mock_settings, \
|
||||||
|
patch("app.db.database.APP_VERSION", "1.9"):
|
||||||
|
mock_settings.sqlite_path = str(tmp_db)
|
||||||
|
_backup_db()
|
||||||
|
backup = tmp_db.parent / "homelab.db.back-1.9"
|
||||||
|
assert backup.exists()
|
||||||
|
assert backup.read_bytes() == b"SQLite placeholder"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_skipped_when_db_missing(tmp_path: Path):
|
||||||
|
with patch("app.db.database.settings") as mock_settings, \
|
||||||
|
patch("app.db.database.APP_VERSION", "1.9"):
|
||||||
|
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
|
||||||
|
_backup_db()
|
||||||
|
assert not any(tmp_path.glob("*.back-*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
|
||||||
|
with patch("app.db.database.settings") as mock_settings, \
|
||||||
|
patch("app.db.database.APP_VERSION", "1.9"):
|
||||||
|
mock_settings.sqlite_path = str(tmp_db)
|
||||||
|
_backup_db()
|
||||||
|
backup = tmp_db.parent / "homelab.db.back-1.9"
|
||||||
|
backup.write_bytes(b"original backup")
|
||||||
|
_backup_db()
|
||||||
|
assert backup.read_bytes() == b"original backup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_version_in_filename(tmp_db: Path):
|
||||||
|
with patch("app.db.database.settings") as mock_settings, \
|
||||||
|
patch("app.db.database.APP_VERSION", "2.0"):
|
||||||
|
mock_settings.sqlite_path = str(tmp_db)
|
||||||
|
_backup_db()
|
||||||
|
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
|
||||||
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
|
|||||||
]):
|
]):
|
||||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||||
assert result == "camera"
|
assert result == "camera"
|
||||||
|
|
||||||
|
|
||||||
|
# ── IoT detection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_from_mqtt_port():
|
||||||
|
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_from_coap_port():
|
||||||
|
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_from_esphome_port():
|
||||||
|
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_shelly_mac_overrides_http_port():
|
||||||
|
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
|
||||||
|
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_espressif_mac_returns_iot():
|
||||||
|
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_tuya_mac_returns_iot():
|
||||||
|
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
|
||||||
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
|
||||||
|
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
|
||||||
|
result = suggest_node_type([
|
||||||
|
{"port": 80, "protocol": "tcp"},
|
||||||
|
{"port": 1883, "protocol": "tcp"},
|
||||||
|
])
|
||||||
|
assert result == "iot"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
# --- Properties tests ---
|
||||||
|
|
||||||
|
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
|
||||||
|
"""New node has an empty properties list by default."""
|
||||||
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
|
||||||
|
"""Node created with properties round-trips correctly."""
|
||||||
|
props = [
|
||||||
|
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
|
||||||
|
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
|
||||||
|
]
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_node_properties(client: AsyncClient, headers: dict):
|
||||||
|
"""PATCH with properties replaces the full properties array."""
|
||||||
|
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
|
||||||
|
"""PATCH that omits properties leaves existing properties untouched."""
|
||||||
|
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
# PATCH only the label — properties must survive
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
assert res.json()["label"] == "Updated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
|
||||||
|
"""PATCH with properties=[] explicitly clears all properties."""
|
||||||
|
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
|
||||||
|
"""GET /nodes/:id returns the properties field."""
|
||||||
|
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
|
||||||
|
|
||||||
|
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||||
|
"""A property with icon=null is valid and round-trips correctly."""
|
||||||
|
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert create.status_code == 201
|
||||||
|
assert create.json()["properties"] == props
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Tests for the hardware → properties migration logic.
|
||||||
|
|
||||||
|
We test the migration function directly against an in-memory SQLite database
|
||||||
|
so we can set up legacy rows (with hardware columns, NULL properties) and
|
||||||
|
verify the migration produces the expected properties JSON.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_legacy_table(conn):
|
||||||
|
"""Create a minimal nodes table that mimics the pre-migration schema."""
|
||||||
|
await conn.exec_driver_sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL DEFAULT 'generic',
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
cpu_model TEXT,
|
||||||
|
cpu_count INTEGER,
|
||||||
|
ram_gb REAL,
|
||||||
|
disk_gb REAL,
|
||||||
|
show_hardware BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
properties JSON
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_migration(conn):
|
||||||
|
"""Run only the properties migration portion (extracted from init_db)."""
|
||||||
|
rows = await conn.exec_driver_sql(
|
||||||
|
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||||
|
"FROM nodes WHERE properties IS NULL"
|
||||||
|
)
|
||||||
|
for row in rows.fetchall():
|
||||||
|
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||||
|
props = []
|
||||||
|
visible = bool(show_hardware)
|
||||||
|
if cpu_model:
|
||||||
|
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||||
|
if cpu_count is not None:
|
||||||
|
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||||
|
if ram_gb is not None:
|
||||||
|
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||||
|
if disk_gb is not None:
|
||||||
|
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
|
(json.dumps(props), node_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_properties(conn, node_id: str) -> list:
|
||||||
|
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
||||||
|
raw = rows.fetchone()[0]
|
||||||
|
return json.loads(raw) if raw else []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_full_hardware():
|
||||||
|
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-1")
|
||||||
|
|
||||||
|
assert len(props) == 4
|
||||||
|
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
|
||||||
|
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
|
||||||
|
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
|
||||||
|
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_partial_hardware():
|
||||||
|
"""Node with only cpu_model and ram_gb → 2 property entries."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||||
|
("node-2", "Ryzen 5 5600", 16.0, 0),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-2")
|
||||||
|
|
||||||
|
assert len(props) == 2
|
||||||
|
assert props[0]["key"] == "CPU Model"
|
||||||
|
assert props[0]["visible"] is False
|
||||||
|
assert props[1]["key"] == "RAM"
|
||||||
|
assert props[1]["icon"] == "MemoryStick"
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_no_hardware():
|
||||||
|
"""Node with no hardware fields → empty properties array."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id) VALUES (?)",
|
||||||
|
("node-3",),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-3")
|
||||||
|
|
||||||
|
assert props == []
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_idempotent():
|
||||||
|
"""Running migration twice does not duplicate properties."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
|
||||||
|
("node-4", "Core i5", 1),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
await _run_migration(conn) # second pass — node already has properties, should be skipped
|
||||||
|
props = await _get_properties(conn, "node-4")
|
||||||
|
|
||||||
|
assert len(props) == 1
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_show_hardware_false_sets_visible_false():
|
||||||
|
"""show_hardware=0 means all migrated properties have visible=False."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||||
|
("node-5", "ARM Cortex-A72", 4.0, 0),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-5")
|
||||||
|
|
||||||
|
assert all(p["visible"] is False for p in props)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_already_migrated_node_not_touched():
|
||||||
|
"""Node that already has properties is skipped — existing properties preserved."""
|
||||||
|
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-6")
|
||||||
|
|
||||||
|
assert props == existing
|
||||||
|
await engine.dispose()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
|
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.services.scanner import run_scan
|
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
|||||||
json=node_payload,
|
json=node_payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 404
|
||||||
assert res.json()["approved"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Hide device ---
|
# --- Hide device ---
|
||||||
@@ -199,6 +198,213 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
|||||||
assert device.suggested_type == "server"
|
assert device.suggested_type == "server"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
||||||
|
"""Pending devices that were already in canvas before scan starts must be removed."""
|
||||||
|
node = Node(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
label="Existing Server",
|
||||||
|
type="server",
|
||||||
|
ip="192.168.1.50",
|
||||||
|
status="online",
|
||||||
|
services=[],
|
||||||
|
pos_x=0.0,
|
||||||
|
pos_y=0.0,
|
||||||
|
)
|
||||||
|
stale = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip="192.168.1.50",
|
||||||
|
mac=None,
|
||||||
|
hostname=None,
|
||||||
|
os=None,
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(node)
|
||||||
|
db_session.add(stale)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
|
||||||
|
"""Devices whose IP already exists as a canvas Node must not appear in pending."""
|
||||||
|
node = Node(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
label="Existing Server",
|
||||||
|
type="server",
|
||||||
|
ip="192.168.1.50",
|
||||||
|
status="online",
|
||||||
|
services=[],
|
||||||
|
pos_x=0.0,
|
||||||
|
pos_y=0.0,
|
||||||
|
)
|
||||||
|
db_session.add(node)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
||||||
|
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
|
||||||
|
hidden = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip="192.168.1.50",
|
||||||
|
mac=None,
|
||||||
|
hostname=None,
|
||||||
|
os=None,
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="hidden",
|
||||||
|
)
|
||||||
|
db_session.add(hidden)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.ip == "192.168.1.50",
|
||||||
|
PendingDevice.status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stop scan ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_requires_auth(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/scan/fake-id/stop")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
||||||
|
import uuid as _uuid
|
||||||
|
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
|
||||||
|
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||||
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
|
||||||
|
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"stopping": True}
|
||||||
|
# run_id added to cancel set
|
||||||
|
assert run.id in _cancelled_runs
|
||||||
|
# cleanup for other tests
|
||||||
|
_cancelled_runs.discard(run.id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- run_scan cancellation ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
|
||||||
|
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
request_cancel(run_id)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
# nmap should not have been called — cancelled before first range
|
||||||
|
mock_nmap.assert_not_called()
|
||||||
|
|
||||||
|
await db_session.refresh(run)
|
||||||
|
assert run.status == "cancelled"
|
||||||
|
assert run.finished_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
|
||||||
|
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def nmap_side_effect(target: str):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
# Signal cancellation after the first CIDR scan completes
|
||||||
|
if call_count == 1:
|
||||||
|
request_cancel(run_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
assert call_count == 1 # second CIDR was skipped
|
||||||
|
await db_session.refresh(run)
|
||||||
|
assert run.status == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||||
@@ -237,3 +443,102 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
|||||||
# Services and hostname should be updated
|
# Services and hostname should be updated
|
||||||
assert device.hostname == "myhost.lan"
|
assert device.hostname == "myhost.lan"
|
||||||
assert any(s["port"] == 8096 for s in device.services)
|
assert any(s["port"] == 8096 for s in device.services)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bulk approve ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def two_pending_devices(db_session):
|
||||||
|
devices = []
|
||||||
|
for i in range(2):
|
||||||
|
d = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip=f"192.168.1.{10 + i}",
|
||||||
|
mac=None,
|
||||||
|
hostname=f"host-{i}",
|
||||||
|
os=None,
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(d)
|
||||||
|
devices.append(d)
|
||||||
|
await db_session.commit()
|
||||||
|
for d in devices:
|
||||||
|
await db_session.refresh(d)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["approved"] == 2
|
||||||
|
assert len(data["node_ids"]) == 2
|
||||||
|
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
||||||
|
assert len(data["device_ids"]) == 2
|
||||||
|
assert data["skipped"] == 0
|
||||||
|
# Pending list should now be empty
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert pending_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
# Approve first device individually first
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/scan/pending/{ids[0]}/approve",
|
||||||
|
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
# Bulk approve both — first one is already approved (not pending), should be skipped
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["approved"] == 1
|
||||||
|
assert data["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bulk hide ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["hidden"] == 2
|
||||||
|
assert data["skipped"] == 0
|
||||||
|
# Should appear in hidden list
|
||||||
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||||
|
assert len(hidden_res.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
# Hide first device individually first
|
||||||
|
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
||||||
|
# Bulk hide both — first is already hidden (not pending anymore)
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["hidden"] == 1
|
||||||
|
assert data["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_run_id() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db():
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scan_run(run_id: str) -> ScanRun:
|
||||||
|
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _ping_sweep
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_returns_alive_hosts():
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def fake_ping(ip: str) -> str | None:
|
||||||
|
return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ping_sweep", wraps=None):
|
||||||
|
pass # just ensure import is fine
|
||||||
|
|
||||||
|
# Patch asyncio.create_subprocess_exec to simulate ping responses
|
||||||
|
responding = {"192.168.1.1", "192.168.1.2"}
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
ip = args[-1]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0 if ip in responding else 1
|
||||||
|
proc.wait = AsyncMock(return_value=proc.returncode)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value={}), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.2" in result
|
||||||
|
for host in result.values():
|
||||||
|
assert host["open_ports"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_excludes_non_responding():
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
ip = args[-1]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0 if ip == "192.168.1.1" else 1
|
||||||
|
proc.wait = AsyncMock(return_value=proc.returncode)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value={}), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30")
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.2" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_supplements_with_arp_cache():
|
||||||
|
"""Devices that block ICMP but appear in ARP cache should still be discovered."""
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 1 # all pings fail
|
||||||
|
proc.wait = AsyncMock(return_value=1)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
arp_extra = {
|
||||||
|
"192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert "192.168.1.10" in result
|
||||||
|
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_enriches_mac_from_arp_cache():
|
||||||
|
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
|
||||||
|
from app.services.scanner import _ping_sweep
|
||||||
|
|
||||||
|
async def mock_subprocess(*args, **kwargs):
|
||||||
|
ip = args[-1]
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0 if ip == "192.168.1.1" else 1
|
||||||
|
proc.wait = AsyncMock(return_value=proc.returncode)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
arp_extra = {
|
||||||
|
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30")
|
||||||
|
|
||||||
|
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _arp_table_hosts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_arp_table_hosts_parses_proc_net_arp():
|
||||||
|
import io # noqa: PLC0415
|
||||||
|
|
||||||
|
from app.services.scanner import _arp_table_hosts
|
||||||
|
|
||||||
|
arp_content = (
|
||||||
|
"IP address HW type Flags HW address Mask Device\n"
|
||||||
|
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
|
||||||
|
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
|
||||||
|
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
|
||||||
|
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
|
||||||
|
mock_file.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("builtins.open", return_value=mock_file), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = _arp_table_hosts("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.50" in result
|
||||||
|
assert "10.0.0.1" not in result # outside target subnet
|
||||||
|
assert "192.168.1.99" not in result # zero MAC skipped
|
||||||
|
|
||||||
|
|
||||||
|
def test_arp_table_hosts_parses_macos_arp_output():
|
||||||
|
from app.services.scanner import _arp_table_hosts
|
||||||
|
|
||||||
|
arp_output = (
|
||||||
|
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
|
||||||
|
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
|
||||||
|
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
|
||||||
|
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = arp_output
|
||||||
|
|
||||||
|
with patch("builtins.open", side_effect=FileNotFoundError), \
|
||||||
|
patch("subprocess.run", return_value=mock_result), \
|
||||||
|
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||||
|
result = _arp_table_hosts("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
assert "192.168.1.20" in result
|
||||||
|
assert "192.168.1.99" not in result # incomplete MAC
|
||||||
|
assert "10.0.0.1" not in result # outside subnet
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _nmap_scan_single (Phase 2 per-IP worker)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_nmap_scan_single_detects_open_ports():
|
||||||
|
from app.services.scanner import _nmap_scan_single
|
||||||
|
|
||||||
|
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||||
|
|
||||||
|
# Build a realistic host entry: protocols → ports → port info
|
||||||
|
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
|
||||||
|
mock_host = MagicMock()
|
||||||
|
mock_host.all_protocols.return_value = ["tcp"]
|
||||||
|
mock_host.__getitem__ = MagicMock(return_value=port_info)
|
||||||
|
mock_host.get.return_value = {}
|
||||||
|
|
||||||
|
mock_nm = MagicMock()
|
||||||
|
mock_nm.all_hosts.return_value = ["192.168.1.10"]
|
||||||
|
mock_nm.__getitem__ = MagicMock(return_value=mock_host)
|
||||||
|
|
||||||
|
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
||||||
|
patch("app.services.scanner._extract_os", return_value=None):
|
||||||
|
result = _nmap_scan_single(host)
|
||||||
|
|
||||||
|
assert len(result["open_ports"]) == 1
|
||||||
|
assert result["open_ports"][0]["port"] == 80
|
||||||
|
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nmap_scan_single_returns_host_unchanged_on_error():
|
||||||
|
from app.services.scanner import _nmap_scan_single
|
||||||
|
|
||||||
|
host = {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||||
|
mock_nm = MagicMock()
|
||||||
|
mock_nm.scan.side_effect = Exception("nmap error")
|
||||||
|
|
||||||
|
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
||||||
|
result = _nmap_scan_single(host)
|
||||||
|
|
||||||
|
assert result["ip"] == "192.168.1.20"
|
||||||
|
assert result["open_ports"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nmap_scan_single_returns_host_unchanged_when_no_results():
|
||||||
|
"""Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
|
||||||
|
from app.services.scanner import _nmap_scan_single
|
||||||
|
|
||||||
|
host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
|
||||||
|
mock_nm = MagicMock()
|
||||||
|
mock_nm.all_hosts.return_value = [] # no results
|
||||||
|
|
||||||
|
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
||||||
|
result = _nmap_scan_single(host)
|
||||||
|
|
||||||
|
assert result["ip"] == "192.168.1.30"
|
||||||
|
assert result["open_ports"] == []
|
||||||
|
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _nmap_scan
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_scan_uses_mock_when_nmap_unavailable():
|
||||||
|
from app.services.scanner import _nmap_scan
|
||||||
|
|
||||||
|
with patch("app.services.scanner._NMAP_AVAILABLE", False):
|
||||||
|
result = await _nmap_scan("192.168.1.0/24")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["ip"] == "192.168.1.99"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_scan_raises_on_sweep_error():
|
||||||
|
from app.services.scanner import _nmap_scan
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
|
||||||
|
pytest.raises(RuntimeError, match="ping sweep failed"):
|
||||||
|
await _nmap_scan("192.168.1.0/24")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _mdns_discover
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
|
||||||
|
from app.services.scanner import _mdns_discover
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
|
||||||
|
result = await _mdns_discover()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mdns_discover_returns_devices():
|
||||||
|
from app.services.scanner import _mdns_discover
|
||||||
|
|
||||||
|
mock_info = MagicMock()
|
||||||
|
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
|
||||||
|
mock_info.server = "shelly1.local."
|
||||||
|
mock_info.port = 80
|
||||||
|
mock_info.async_request = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
mock_browser = AsyncMock()
|
||||||
|
mock_browser.async_cancel = AsyncMock()
|
||||||
|
|
||||||
|
# Simulate a service being found during the sleep
|
||||||
|
captured_handler: list = []
|
||||||
|
|
||||||
|
def fake_browser(zc, types, handlers):
|
||||||
|
captured_handler.extend(handlers)
|
||||||
|
return mock_browser
|
||||||
|
|
||||||
|
from zeroconf import ServiceStateChange
|
||||||
|
|
||||||
|
async def fake_sleep(t):
|
||||||
|
# Fire the handler as if a device was discovered
|
||||||
|
for h in captured_handler:
|
||||||
|
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
|
||||||
|
|
||||||
|
mock_azc = AsyncMock()
|
||||||
|
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
|
||||||
|
mock_azc.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_azc.zeroconf = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
|
||||||
|
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
|
||||||
|
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
|
||||||
|
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
|
||||||
|
patch("asyncio.sleep", side_effect=fake_sleep):
|
||||||
|
result = await _mdns_discover(timeout=0.01)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["ip"] == "192.168.1.80"
|
||||||
|
assert result[0]["hostname"] == "shelly1.local."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _nmap_port_scan (Phase 2 concurrency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
|
||||||
|
from app.services.scanner import _nmap_port_scan
|
||||||
|
|
||||||
|
result = await _nmap_port_scan({})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_port_scan_tolerates_single_host_exception():
|
||||||
|
"""A single per-host failure should not abort the entire Phase 2 gather."""
|
||||||
|
from app.services.scanner import _nmap_port_scan
|
||||||
|
|
||||||
|
hosts = {
|
||||||
|
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||||
|
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _flaky_scan(host_dict):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if host_dict["ip"] == "192.168.1.1":
|
||||||
|
raise RuntimeError("simulated nmap crash")
|
||||||
|
return host_dict
|
||||||
|
|
||||||
|
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
|
||||||
|
patch("app.services.scanner._NMAP_AVAILABLE", True):
|
||||||
|
result = await _nmap_port_scan(hosts)
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
# The crashing host is dropped; the healthy one survives
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["ip"] == "192.168.1.2"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_scan integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice))
|
||||||
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
assert any(d.ip == "192.168.1.5" for d in devices)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_mdns_only_device_added(mem_db):
|
||||||
|
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
|
||||||
|
device = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert device is not None
|
||||||
|
assert device.status == "pending"
|
||||||
|
assert device.discovery_source == "mdns"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||||
|
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
|
||||||
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
assert len(devices) == 1 # not duplicated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_skips_canvas_nodes(mem_db):
|
||||||
|
"""Hosts already approved onto the canvas must be skipped."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
canvas_node = Node(
|
||||||
|
id=str(uuid.uuid4()), label="PVE", type="proxmox",
|
||||||
|
ip="192.168.1.100", status="online",
|
||||||
|
)
|
||||||
|
session.add(canvas_node)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_skips_hidden_devices(mem_db):
|
||||||
|
"""Hosts hidden by the user must not re-appear in pending."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
|
||||||
|
session.add(hidden)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
|
||||||
|
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
|
||||||
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
request_cancel(run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = await session.get(ScanRun, run_id)
|
||||||
|
assert run is not None
|
||||||
|
assert run.status == "cancelled"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for GET/POST /api/v1/settings."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_requires_auth(client: AsyncClient):
|
||||||
|
res = await client.get("/api/v1/settings")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_returns_interval(client: AsyncClient, headers):
|
||||||
|
res = await client.get("/api/v1/settings", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert "interval_seconds" in data
|
||||||
|
assert isinstance(data["interval_seconds"], int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
||||||
|
with patch("app.api.routes.settings.settings") as mock_settings:
|
||||||
|
mock_settings.status_checker_interval = 60
|
||||||
|
mock_settings.save_overrides = lambda: None
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={"interval_seconds": 120},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["interval_seconds"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||||
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _tcp_connect, check_node
|
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
|
|||||||
assert result["response_time_ms"] is None
|
assert result["response_time_ms"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping platform args ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_unix_args_on_non_windows():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-c" in captured["args"]
|
||||||
|
assert "-W" in captured["args"]
|
||||||
|
assert "-n" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_windows_args_on_win32():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-n" in captured["args"]
|
||||||
|
assert "-w" in captured["args"]
|
||||||
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# CI override — exposes backend port 8000 and injects credentials.
|
||||||
|
# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
|
||||||
|
#
|
||||||
|
# AUTH_PASSWORD_HASH uses $$ escaping: docker-compose converts $$ → $ before
|
||||||
|
# passing to the container, so the backend receives a valid bcrypt hash.
|
||||||
|
# This avoids the project .env file being subject to docker-compose $VAR expansion.
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
AUTH_USERNAME: admin
|
||||||
|
# bcrypt hash of "admin" — $$ is docker-compose escape for literal $
|
||||||
|
AUTH_PASSWORD_HASH: $$2b$$12$$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
|
||||||
@@ -7,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,12 @@ 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:
|
mcp:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 618 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
|
||||||
|
<!-- House body -->
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Floor line (subtle) -->
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
|
||||||
|
<!-- Door -->
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- Network lines (drawn under nodes) -->
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Center hub glow -->
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<!-- Center hub -->
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
|
||||||
|
<!-- Left node -->
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
|
||||||
|
<!-- Right node -->
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
|
||||||
|
<!-- Top node -->
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(4, 4)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(4, 4)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(4, 4)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Icon (72×72, scaled from 64 viewBox) -->
|
||||||
|
<g transform="translate(4, 4) scale(1)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<text x="80" y="42"
|
||||||
|
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||||
|
font-weight="600"
|
||||||
|
font-size="30"
|
||||||
|
letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Subtitle -->
|
||||||
|
<text x="81" y="58"
|
||||||
|
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||||
|
font-weight="400"
|
||||||
|
font-size="11"
|
||||||
|
fill="#8b949e"
|
||||||
|
letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.10.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,12 +19,15 @@
|
|||||||
"@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",
|
||||||
|
"@radix-ui/react-tooltip": "^1.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,12 +49,12 @@
|
|||||||
"@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",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.7.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
|
|||||||
@@ -2,8 +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 { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
|
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'
|
||||||
@@ -29,9 +33,10 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, 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()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
@@ -40,6 +45,8 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||||
|
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
@@ -47,6 +54,7 @@ export default function App() {
|
|||||||
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)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -57,71 +65,8 @@ export default function App() {
|
|||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map((n) => {
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
if (n.data.type === 'groupRect') {
|
const edgesToSave = edges.map(serializeEdge)
|
||||||
return {
|
|
||||||
id: n.id,
|
|
||||||
type: 'groupRect',
|
|
||||||
label: n.data.label,
|
|
||||||
hostname: null,
|
|
||||||
ip: null,
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
status: 'unknown',
|
|
||||||
check_method: null,
|
|
||||||
check_target: null,
|
|
||||||
services: [],
|
|
||||||
notes: null,
|
|
||||||
parent_id: null,
|
|
||||||
container_mode: false,
|
|
||||||
custom_icon: null,
|
|
||||||
pos_x: n.position.x,
|
|
||||||
pos_y: n.position.y,
|
|
||||||
// Persist size and all rect config inside custom_colors
|
|
||||||
custom_colors: {
|
|
||||||
...n.data.custom_colors,
|
|
||||||
width: n.measured?.width ?? n.width ?? 360,
|
|
||||||
height: n.measured?.height ?? n.height ?? 240,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: n.id,
|
|
||||||
type: n.data.type,
|
|
||||||
label: n.data.label,
|
|
||||||
hostname: n.data.hostname ?? null,
|
|
||||||
ip: n.data.ip ?? null,
|
|
||||||
mac: n.data.mac ?? null,
|
|
||||||
os: n.data.os ?? null,
|
|
||||||
status: n.data.status,
|
|
||||||
check_method: n.data.check_method ?? null,
|
|
||||||
check_target: n.data.check_target ?? null,
|
|
||||||
services: n.data.services ?? [],
|
|
||||||
notes: n.data.notes ?? null,
|
|
||||||
parent_id: n.data.parent_id ?? null,
|
|
||||||
container_mode: n.data.container_mode ?? false,
|
|
||||||
custom_colors: n.data.custom_colors ?? null,
|
|
||||||
custom_icon: n.data.custom_icon ?? null,
|
|
||||||
pos_x: n.position.x,
|
|
||||||
pos_y: n.position.y,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const edgesToSave = edges.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
type: e.data?.type ?? 'ethernet',
|
|
||||||
label: e.data?.label ?? null,
|
|
||||||
vlan_id: e.data?.vlan_id ?? null,
|
|
||||||
speed: e.data?.speed ?? null,
|
|
||||||
custom_color: e.data?.custom_color ?? null,
|
|
||||||
path_style: e.data?.path_style ?? null,
|
|
||||||
animated: e.data?.animated ?? false,
|
|
||||||
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
|
||||||
// map them back to their canonical source handle ID so reload works correctly.
|
|
||||||
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
|
|
||||||
target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null),
|
|
||||||
}))
|
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
@@ -156,46 +101,14 @@ export default function App() {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
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 container mode nodes 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 === 'group' || n.container_mode === true)
|
||||||
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
|
.map((n) => [n.id, true])
|
||||||
)
|
)
|
||||||
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))
|
||||||
if (n.type === 'groupRect') {
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
const w = n.custom_colors?.width ?? 360
|
|
||||||
const h = n.custom_colors?.height ?? 240
|
|
||||||
const z = n.custom_colors?.z_order ?? 1
|
|
||||||
return {
|
|
||||||
id: n.id,
|
|
||||||
type: 'groupRect',
|
|
||||||
position: { x: n.pos_x, y: n.pos_y },
|
|
||||||
data: n,
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
zIndex: z - 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
|
||||||
return {
|
|
||||||
id: n.id,
|
|
||||||
type: n.type,
|
|
||||||
position: { x: n.pos_x, y: n.pos_y },
|
|
||||||
data: n,
|
|
||||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
|
||||||
...(n.type === 'proxmox' && n.container_mode !== false ? { width: 300, height: 200 } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string; source_handle?: string; target_handle?: string }) => ({
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
type: e.type,
|
|
||||||
sourceHandle: e.source_handle ?? null,
|
|
||||||
targetHandle: e.target_handle ?? null,
|
|
||||||
data: e,
|
|
||||||
}))
|
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
loadCanvas(rfNodes, rfEdges)
|
loadCanvas(rfNodes, rfEdges)
|
||||||
@@ -238,8 +151,8 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = generateUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isContainerNode = data.container_mode === true
|
||||||
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
|
||||||
const position = parentNode
|
const position = parentNode
|
||||||
@@ -252,7 +165,7 @@ export default function App() {
|
|||||||
position,
|
position,
|
||||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||||
...(isProxmox ? { width: 300, height: 200 } : {}),
|
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
@@ -260,7 +173,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = generateUUID()
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: 'groupRect',
|
type: 'groupRect',
|
||||||
@@ -272,9 +185,13 @@ export default function App() {
|
|||||||
services: [],
|
services: [],
|
||||||
custom_colors: {
|
custom_colors: {
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
|
text_size: data.text_size,
|
||||||
|
label_position: data.label_position,
|
||||||
font: data.font,
|
font: data.font,
|
||||||
z_order: data.z_order,
|
z_order: data.z_order,
|
||||||
},
|
},
|
||||||
@@ -288,22 +205,27 @@ export default function App() {
|
|||||||
|
|
||||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
|
snapshotHistory()
|
||||||
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
||||||
updateNode(editingGroupRectId, {
|
updateNode(editingGroupRectId, {
|
||||||
label: data.label,
|
label: data.label,
|
||||||
custom_colors: {
|
custom_colors: {
|
||||||
...existing?.data.custom_colors,
|
...existing?.data.custom_colors,
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
|
text_size: data.text_size,
|
||||||
|
label_position: data.label_position,
|
||||||
font: data.font,
|
font: data.font,
|
||||||
z_order: data.z_order,
|
z_order: data.z_order,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setNodeZIndex(editingGroupRectId, data.z_order - 10)
|
setNodeZIndex(editingGroupRectId, data.z_order - 10)
|
||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
|
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
const handleDeleteGroupRect = useCallback(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
@@ -321,13 +243,13 @@ export default function App() {
|
|||||||
snapshotHistory()
|
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 container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||||
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
|
if (typeof data.container_mode === 'boolean') {
|
||||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||||
}
|
}
|
||||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||||
const nodeType = data.type ?? existingNode?.data.type
|
const nodeType = data.type ?? existingNode?.data.type
|
||||||
if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) {
|
if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) {
|
||||||
const oldParentId = existingNode?.data.parent_id ?? null
|
const oldParentId = existingNode?.data.parent_id ?? null
|
||||||
const newParentId = data.parent_id ?? null
|
const newParentId = data.parent_id ?? null
|
||||||
if (oldParentId !== newParentId) {
|
if (oldParentId !== newParentId) {
|
||||||
@@ -340,13 +262,16 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
if (oldEdge) deleteEdge(oldEdge.id)
|
if (oldEdge) deleteEdge(oldEdge.id)
|
||||||
}
|
}
|
||||||
// Create new virtual edge: LXC top → Proxmox bottom
|
// Create virtual edge only when parent is NOT in container mode
|
||||||
|
// (container mode shows containment visually — no edge needed)
|
||||||
if (newParentId) {
|
if (newParentId) {
|
||||||
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData>
|
const parentNode = nodes.find((n) => n.id === newParentId)
|
||||||
|
if (!parentNode?.data.container_mode) {
|
||||||
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setEditNodeId(null)
|
setEditNodeId(null)
|
||||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||||
|
|
||||||
@@ -363,15 +288,29 @@ export default function App() {
|
|||||||
toast.success('Markdown table copied to clipboard')
|
toast.success('Markdown table copied to clipboard')
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
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(() => {
|
||||||
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 }
|
||||||
try {
|
setExportModalOpen(true)
|
||||||
await exportToPng(el)
|
|
||||||
toast.success('Exported as PNG')
|
|
||||||
} catch {
|
|
||||||
toast.error('Export failed')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
@@ -382,15 +321,19 @@ export default function App() {
|
|||||||
if (!pendingConnection) return
|
if (!pendingConnection) return
|
||||||
snapshotHistory()
|
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 a child node and a container node, sync parent_id
|
||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||||
const srcType = src?.data.type
|
const srcType = src?.data.type as NodeData['type']
|
||||||
const tgtType = tgt?.data.type
|
const tgtType = tgt?.data.type as NodeData['type']
|
||||||
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
|
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||||
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) {
|
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||||
|
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||||
|
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
|
||||||
|
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||||
|
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,6 +344,10 @@ export default function App() {
|
|||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
||||||
|
handleEditNode(node.id)
|
||||||
|
}, [handleEditNode])
|
||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -415,6 +362,13 @@ export default function App() {
|
|||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, deleteEdge, snapshotHistory])
|
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||||
|
|
||||||
|
const handleClearWaypoints = useCallback(() => {
|
||||||
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
|
updateEdge(editEdgeId, { waypoints: [] })
|
||||||
|
setEditEdgeId(null)
|
||||||
|
}, [editEdgeId, updateEdge, 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
|
||||||
|
|
||||||
@@ -430,6 +384,8 @@ export default function App() {
|
|||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
onNodeApproved={setEditNodeId}
|
||||||
|
forceView={sidebarForceView}
|
||||||
|
highlightPendingId={highlightPendingId}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -441,22 +397,40 @@ export default function App() {
|
|||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
onShortcuts={() => setShortcutsOpen(true)}
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
onExportMd={handleExportMd}
|
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} onNodeDragStart={snapshotHistory} />
|
<CanvasContainer
|
||||||
|
onConnect={handleEdgeConnect}
|
||||||
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
|
onNodeDragStart={snapshotHistory}
|
||||||
|
onOpenPending={(deviceId) => {
|
||||||
|
setHighlightPendingId(undefined)
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightPendingId(deviceId)
|
||||||
|
setSidebarForceView('pending')
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NodeModal
|
<NodeModal
|
||||||
|
key={addNodeOpen ? 'add-open' : 'add-closed'}
|
||||||
open={addNodeOpen}
|
open={addNodeOpen}
|
||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
parentContainerNodes={nodes
|
||||||
|
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
||||||
|
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -467,7 +441,9 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
parentContainerNodes={nodes
|
||||||
|
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
||||||
|
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -488,6 +464,7 @@ export default function App() {
|
|||||||
onClose={() => setEditEdgeId(null)}
|
onClose={() => setEditEdgeId(null)}
|
||||||
onSubmit={handleEdgeUpdate}
|
onSubmit={handleEdgeUpdate}
|
||||||
onDelete={handleEdgeDelete}
|
onDelete={handleEdgeDelete}
|
||||||
|
onClearWaypoints={handleClearWaypoints}
|
||||||
initial={editEdge?.data}
|
initial={editEdge?.data}
|
||||||
title="Edit Link"
|
title="Edit Link"
|
||||||
/>
|
/>
|
||||||
@@ -496,7 +473,11 @@ export default function App() {
|
|||||||
<ScanConfigModal
|
<ScanConfigModal
|
||||||
open={scanConfigOpen}
|
open={scanConfigOpen}
|
||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => toast.success('Scan triggered')}
|
onScanNow={() => {
|
||||||
|
toast.success('Network scan started — check Scan History for results')
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -504,7 +485,7 @@ export default function App() {
|
|||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
onSubmit={handleAddGroupRect}
|
onSubmit={handleAddGroupRect}
|
||||||
title="Add Rectangle"
|
title="Add Zone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different rect */}
|
{/* key forces re-mount when editing a different rect */}
|
||||||
@@ -524,11 +505,15 @@ export default function App() {
|
|||||||
text_color: rc.text_color ?? '#e6edf3',
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
text_position: rc.text_position ?? 'top-left',
|
text_position: rc.text_position ?? 'top-left',
|
||||||
border_color: rc.border ?? '#00d4ff',
|
border_color: rc.border ?? '#00d4ff',
|
||||||
|
border_style: rc.border_style ?? 'solid',
|
||||||
|
border_width: rc.border_width ?? 2,
|
||||||
background_color: rc.background ?? '#00d4ff0d',
|
background_color: rc.background ?? '#00d4ff0d',
|
||||||
|
text_size: rc.text_size ?? 12,
|
||||||
|
label_position: rc.label_position ?? 'inside',
|
||||||
z_order: rc.z_order ?? 1,
|
z_order: rc.z_order ?? 1,
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
title="Edit Rectangle"
|
title="Edit Zone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||||
@@ -538,9 +523,26 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchModal
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onOpenPending={(deviceId) => {
|
||||||
|
setHighlightPendingId(undefined)
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightPendingId(deviceId)
|
||||||
|
setSidebarForceView('pending')
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
<ExportModal
|
||||||
|
open={exportModalOpen}
|
||||||
|
onClose={() => setExportModalOpen(false)}
|
||||||
|
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
<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,14 +47,27 @@ 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'),
|
||||||
hidden: () => api.get('/scan/hidden'),
|
hidden: () => api.get('/scan/hidden'),
|
||||||
runs: () => api.get('/scan/runs'),
|
runs: () => api.get('/scan/runs'),
|
||||||
|
clearPending: () => api.delete('/scan/pending'),
|
||||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'),
|
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
|
||||||
saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data),
|
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||||
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||||
|
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
useReactFlow,
|
||||||
|
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, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
|
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' || n.type === 'group')
|
||||||
|
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : 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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.12, duration: 350 })
|
||||||
|
clearFitViewPending()
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||||
|
|
||||||
|
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
|
||||||
|
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,207 @@
|
|||||||
|
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' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
|
}))
|
||||||
|
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: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => { setSearch('') })
|
||||||
|
|
||||||
|
// ── 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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const XYFLOW_MOCK = {
|
||||||
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
ReactFlow: () => <div data-testid="react-flow" />,
|
||||||
|
Background: () => null,
|
||||||
|
Controls: () => null,
|
||||||
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LiveView (standalone — localStorage)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSearch('')
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const mockLoad = vi.fn()
|
||||||
|
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||||
|
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||||
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
|
setSearch('')
|
||||||
|
render(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows canvas (empty) when localStorage has no saved data', async () => {
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const mockLoad = vi.fn()
|
||||||
|
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||||
|
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||||
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
|
setSearch('')
|
||||||
|
render(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,40 +1,64 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
|
ControlButton,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
|
SelectionMode,
|
||||||
|
useReactFlow,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Connection,
|
type Connection,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
|
import { MousePointer2, Hand } from 'lucide-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 { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
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 { SearchBar } from './SearchBar'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
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
|
||||||
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||||
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode,
|
setSelectedNode, snapshotHistory,
|
||||||
|
fitViewPending, clearFitViewPending,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.12, duration: 350 })
|
||||||
|
clearFitViewPending()
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
setSelectedNode(null)
|
||||||
|
} else {
|
||||||
setSelectedNode(node.id)
|
setSelectedNode(node.id)
|
||||||
|
}
|
||||||
}, [setSelectedNode])
|
}, [setSelectedNode])
|
||||||
|
|
||||||
const onPaneClick = useCallback(() => {
|
const onPaneClick = useCallback(() => {
|
||||||
@@ -45,6 +69,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick?.(edge)
|
onEdgeDoubleClick?.(edge)
|
||||||
}, [onEdgeDoubleClick])
|
}, [onEdgeDoubleClick])
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
|
onNodeDoubleClick?.(node)
|
||||||
|
}, [onNodeDoubleClick])
|
||||||
|
|
||||||
|
const handleBeforeDelete = useCallback(async () => {
|
||||||
|
snapshotHistory()
|
||||||
|
return true
|
||||||
|
}, [snapshotHistory])
|
||||||
|
|
||||||
|
const isValidConnection = useCallback(
|
||||||
|
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -56,24 +94,41 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
onBeforeDelete={handleBeforeDelete}
|
||||||
|
selectionOnDrag={lassoMode}
|
||||||
|
panOnDrag={lassoMode ? [1, 2] : true}
|
||||||
|
panActivationKeyCode="Space"
|
||||||
|
selectionMode={SelectionMode.Partial}
|
||||||
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2.5}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[16, 16]}
|
snapGrid={[8, 8]}
|
||||||
fitView
|
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
isValidConnection={(connection) => connection.source !== connection.target}
|
isValidConnection={isValidConnection}
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
variant={BackgroundVariant.Dots}
|
variant={BackgroundVariant.Dots}
|
||||||
gap={24}
|
gap={16}
|
||||||
size={1}
|
size={1}
|
||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<Controls />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
|
<Controls>
|
||||||
|
<ControlButton
|
||||||
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
|
||||||
|
>
|
||||||
|
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
|
||||||
|
</ControlButton>
|
||||||
|
</Controls>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useReactFlow } from '@xyflow/react'
|
||||||
|
import { Search, X } from 'lucide-react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { NODE_TYPE_LABELS } from '@/types'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
onOpenPending?: (deviceId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { nodes, setSelectedNode } = useCanvasStore()
|
||||||
|
const { setCenter } = useReactFlow()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) inputRef.current?.focus()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const q = query.toLowerCase().trim()
|
||||||
|
const nodeResults = q
|
||||||
|
? nodes.filter((n) => {
|
||||||
|
if (n.data.type === 'groupRect') return false
|
||||||
|
return (
|
||||||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
|
n.data.hostname?.toLowerCase().includes(q) ||
|
||||||
|
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const pendingResults = q
|
||||||
|
? pendingDevices.filter((d) =>
|
||||||
|
d.ip.toLowerCase().includes(q) ||
|
||||||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.services.some((s) =>
|
||||||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
|
s.category?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const totalResults = nodeResults.length + pendingResults.length
|
||||||
|
|
||||||
|
const goToNode = (id: string) => {
|
||||||
|
const node = nodes.find((n) => n.id === id)
|
||||||
|
if (!node) return
|
||||||
|
setSelectedNode(id)
|
||||||
|
// For grouped nodes, add parent's absolute position
|
||||||
|
let absX = node.position.x
|
||||||
|
let absY = node.position.y
|
||||||
|
if (node.parentId) {
|
||||||
|
const parent = nodes.find((n) => n.id === node.parentId)
|
||||||
|
if (parent) { absX += parent.position.x; absY += parent.position.y }
|
||||||
|
}
|
||||||
|
const w = node.measured?.width ?? node.width ?? 200
|
||||||
|
const h = node.measured?.height ?? node.height ?? 80
|
||||||
|
setCenter(absX + w / 2, absY + h / 2, { zoom: 1.5, duration: 500 })
|
||||||
|
setOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="nodrag nowheel"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
width: 360,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: '#161b22',
|
||||||
|
border: '1px solid #30363d',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
|
||||||
|
<Search size={14} style={{ color: '#8b949e', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by name, IP, hostname or service…"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
color: '#e6edf3',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
||||||
|
{totalResults} result{totalResults !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen(false); setQuery('') }}
|
||||||
|
aria-label="Close search"
|
||||||
|
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalResults > 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||||
|
{nodeResults.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => goToNode(n.id)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '7px 12px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{n.data.label}
|
||||||
|
</span>
|
||||||
|
{n.data.ip && (
|
||||||
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
|
{n.data.ip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
|
||||||
|
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||||
|
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
||||||
|
)}
|
||||||
|
{pendingResults.map((d) => {
|
||||||
|
const serviceName = d.services.find((s) => s.service_name)?.service_name
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '7px 12px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{d.hostname ?? d.ip}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
|
{serviceName ?? d.ip}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q && totalResults === 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
||||||
|
No results for “{query}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Server } from 'lucide-react'
|
||||||
|
import { BaseNode } from '../nodes/BaseNode'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
let mockZoom = 1
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
Handle: () => null,
|
||||||
|
Position: { Top: 'top', Bottom: 'bottom' },
|
||||||
|
NodeResizer: () => null,
|
||||||
|
useUpdateNodeInternals: () => vi.fn(),
|
||||||
|
useViewport: () => ({ zoom: mockZoom }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/themeStore', () => ({
|
||||||
|
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
|
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/themes', () => ({
|
||||||
|
THEMES: {
|
||||||
|
dark: {
|
||||||
|
colors: {
|
||||||
|
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
|
||||||
|
nodeSubtextColor: '#8b949e',
|
||||||
|
nodeLabelColor: '#e6edf3',
|
||||||
|
nodeIconBackground: '#21262d',
|
||||||
|
handleBackground: '#30363d',
|
||||||
|
handleBorder: '#30363d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeColors', () => ({
|
||||||
|
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeIcons', () => ({
|
||||||
|
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/maskIp', () => ({
|
||||||
|
maskIp: (ip: string) => ip,
|
||||||
|
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/propertyIcons', () => ({
|
||||||
|
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
|
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||||
|
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
|
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 renderBaseNode(data: Partial<NodeData>) {
|
||||||
|
const node = makeNode(data)
|
||||||
|
return render(
|
||||||
|
<BaseNode
|
||||||
|
id={node.id}
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
icon={Server}
|
||||||
|
type="server"
|
||||||
|
dragging={false}
|
||||||
|
zIndex={0}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BaseNode — borderWidth zoom scaling', () => {
|
||||||
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
|
it('borderWidth is 1px at zoom=1', () => {
|
||||||
|
mockZoom = 1
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borderWidth scales to 2px at zoom=0.5', () => {
|
||||||
|
mockZoom = 0.5
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borderWidth is clamped to 1px at zoom=2', () => {
|
||||||
|
mockZoom = 2
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
||||||
|
mockZoom = 0.5
|
||||||
|
const node = makeNode({ status: 'online' })
|
||||||
|
const { container } = render(
|
||||||
|
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
|
||||||
|
type="server" dragging={false} zIndex={0} isConnectable={true}
|
||||||
|
positionAbsoluteX={0} positionAbsoluteY={0} />
|
||||||
|
)
|
||||||
|
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BaseNode — properties rendering', () => {
|
||||||
|
it('renders visible properties on the node', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: [
|
||||||
|
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||||
|
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||||
|
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||||
|
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render properties with visible=false', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: [
|
||||||
|
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Secret')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when properties array is empty', () => {
|
||||||
|
const { container } = renderBaseNode({ properties: [] })
|
||||||
|
// No properties section — only the main node card
|
||||||
|
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders label and ip regardless of properties', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
label: 'My Server',
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('My Server')).toBeDefined()
|
||||||
|
expect(screen.getByText('192.168.1.10')).toBeDefined()
|
||||||
|
expect(screen.getByText('OS')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BaseNode — legacy hardware fallback', () => {
|
||||||
|
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: undefined,
|
||||||
|
show_hardware: true,
|
||||||
|
cpu_model: 'Intel Xeon E5-2680',
|
||||||
|
ram_gb: 32,
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render legacy hardware when properties array is present (even if empty)', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: [],
|
||||||
|
show_hardware: true,
|
||||||
|
cpu_model: 'Intel Xeon E5-2680',
|
||||||
|
})
|
||||||
|
// properties array exists → new system, legacy section skipped
|
||||||
|
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render legacy hardware when show_hardware is false', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: undefined,
|
||||||
|
show_hardware: false,
|
||||||
|
cpu_model: 'Intel Xeon E5-2680',
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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,
|
||||||
|
ControlButton: () => null,
|
||||||
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
SelectionMode: { Partial: 'partial' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Node double-click ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
|
||||||
|
const onNodeDoubleClick = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
|
||||||
|
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||||
|
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw when onNodeDoubleClick is not provided', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(() => {
|
||||||
|
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||||
|
}).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 [8, 8]', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.snapGrid).toEqual([8, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Delete key ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('sets deleteKeyCode to include both Backspace and Delete', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Lasso / multi-select ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('enables selectionOnDrag for lasso selection', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.selectionOnDrag).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets panActivationKeyCode to Space', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.panActivationKeyCode).toBe('Space')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets panOnDrag to [1, 2]', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.panOnDrag).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets selectionMode to Partial', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.selectionMode).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets multiSelectionKeyCode to Meta and Control', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
|
||||||
|
{ ctrlKey: true, metaKey: false } as unknown as MouseEvent,
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears selectedNode (sets null) on Cmd+click', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
|
||||||
|
{ ctrlKey: false, metaKey: true } as unknown as MouseEvent,
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
|
useCanvasStore.setState({ snapshotHistory } as unknown as Parameters<typeof useCanvasStore.setState>[0])
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
const result = await (rfProps.onBeforeDelete as () => Promise<boolean>)()
|
||||||
|
expect(snapshotHistory).toHaveBeenCalledOnce()
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { GroupNode } from '../nodes/GroupNode'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||||
|
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||||
|
),
|
||||||
|
useReactFlow: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
|
||||||
|
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'g1',
|
||||||
|
type: 'group',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 400,
|
||||||
|
height: 250,
|
||||||
|
data: {
|
||||||
|
label: 'My Group',
|
||||||
|
type: 'group',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: { show_border: true },
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
|
||||||
|
const node = makeGroupNode(props.data)
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: storeNodes,
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<GroupNode
|
||||||
|
id="g1"
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
dragging={false}
|
||||||
|
zIndex={1}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the group label when show_border is true', () => {
|
||||||
|
renderGroupNode()
|
||||||
|
expect(screen.getByText('My Group')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the header when show_border is false and not selected', () => {
|
||||||
|
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
|
||||||
|
expect(screen.queryByText('My Group')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows header when show_border is false but node is selected', () => {
|
||||||
|
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
|
||||||
|
expect(screen.getByText('My Group')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows NodeResizer only when selected', () => {
|
||||||
|
const { rerender } = renderGroupNode({ selected: false })
|
||||||
|
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
|
||||||
|
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<GroupNode
|
||||||
|
id="g1"
|
||||||
|
data={makeGroupNode().data}
|
||||||
|
selected={true}
|
||||||
|
dragging={false}
|
||||||
|
zIndex={1}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows dragging from the header while keeping rename controls nodrag', () => {
|
||||||
|
renderGroupNode({ selected: true })
|
||||||
|
|
||||||
|
expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag')
|
||||||
|
|
||||||
|
const renameButton = screen.getByTitle('Rename group')
|
||||||
|
expect(renameButton).toHaveClass('nodrag')
|
||||||
|
|
||||||
|
fireEvent.click(renameButton)
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows online/offline status summary from children', () => {
|
||||||
|
const storeNodes = [
|
||||||
|
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
||||||
|
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
|
||||||
|
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
|
||||||
|
]
|
||||||
|
|
||||||
|
renderGroupNode({}, storeNodes)
|
||||||
|
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
|
||||||
|
const statusSpans = screen.getAllByText(/● \d+/)
|
||||||
|
expect(statusSpans).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show status summary when group has no children', () => {
|
||||||
|
renderGroupNode()
|
||||||
|
expect(screen.queryByText(/●/)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { GroupRectNode } from '../nodes/GroupRectNode'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
Handle: ({ id, type }: { id: string; type: string }) => <div data-testid={`handle-${id}`} data-type={type} />,
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
|
NodeResizer: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
|
useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) =>
|
||||||
|
sel({ setEditingGroupRectId: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'zone1',
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderZone(overrides: Partial<NodeData> = {}) {
|
||||||
|
const node = makeNode(overrides)
|
||||||
|
return render(
|
||||||
|
<GroupRectNode
|
||||||
|
id={node.id}
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
type="groupRect"
|
||||||
|
dragging={false}
|
||||||
|
zIndex={0}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupRectNode — handles', () => {
|
||||||
|
it('renders source handles on all four sides', () => {
|
||||||
|
renderZone()
|
||||||
|
expect(screen.getByTestId('handle-zone-top')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-right')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-bottom')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-left')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders target handles on all four sides', () => {
|
||||||
|
renderZone()
|
||||||
|
expect(screen.getByTestId('handle-zone-top-t')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-right-t')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-left-t')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 8 handles total (4 source + 4 target)', () => {
|
||||||
|
renderZone()
|
||||||
|
expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GroupRectNode — label', () => {
|
||||||
|
it('renders inside label by default', () => {
|
||||||
|
renderZone({ label: 'DMZ' })
|
||||||
|
expect(screen.getByText('DMZ')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no label when label is empty', () => {
|
||||||
|
renderZone({ label: '' })
|
||||||
|
expect(screen.queryByText('DMZ')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { SearchBar } from '../SearchBar'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
useReactFlow: () => ({ setCenter: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeNode(id: string, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: id, type: 'server', status: 'online', services: [], ip: null, hostname: null },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStore(nodes: unknown[] = []) {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes,
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SearchBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setupStore([])
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden by default', () => {
|
||||||
|
render(<SearchBar />)
|
||||||
|
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on Ctrl+F', () => {
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on Cmd+F', () => {
|
||||||
|
render(<SearchBar />)
|
||||||
|
fireEvent.keyDown(window, { key: 'f', metaKey: true })
|
||||||
|
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape', () => {
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' })
|
||||||
|
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes when X button is clicked', () => {
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.click(screen.getByLabelText('Close search'))
|
||||||
|
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by label', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [], ip: null, hostname: null } }),
|
||||||
|
makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'online', services: [], ip: null, hostname: null } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'router' } })
|
||||||
|
expect(screen.getByText('My Router')).toBeDefined()
|
||||||
|
expect(screen.queryByText('My NAS')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by IP', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: '192.168.1.10', hostname: null } }),
|
||||||
|
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: '10.0.0.1', hostname: null } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: '192.168' } })
|
||||||
|
expect(screen.getByText('Server A')).toBeDefined()
|
||||||
|
expect(screen.queryByText('Server B')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by service name', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Web Server', type: 'server', status: 'online', services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], ip: null, hostname: null } }),
|
||||||
|
makeNode('n2', { data: { label: 'DB Server', type: 'server', status: 'online', services: [{ service_name: 'mysql', port: 3306, protocol: 'tcp' }], ip: null, hostname: null } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'nginx' } })
|
||||||
|
expect(screen.getByText('Web Server')).toBeDefined()
|
||||||
|
expect(screen.queryByText('DB Server')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes from results', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'dmz' } })
|
||||||
|
expect(screen.queryByText('DMZ Zone')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no-results message when query has no matches', () => {
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzznomatch' } })
|
||||||
|
expect(screen.getByText(/no results/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls setSelectedNode when a result is clicked', () => {
|
||||||
|
const setSelectedNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode('n1', { data: { label: 'My Server', type: 'server', status: 'online', services: [], ip: null, hostname: null } })],
|
||||||
|
setSelectedNode,
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'my server' } })
|
||||||
|
fireEvent.click(screen.getByText('My Server'))
|
||||||
|
expect(setSelectedNode).toHaveBeenCalledWith('n1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows result count', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Alpha', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
|
||||||
|
makeNode('n2', { data: { label: 'Beta', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'a' } })
|
||||||
|
expect(screen.getByText(/2 results/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
|
||||||
|
|
||||||
|
describe('buildWaypointPath — bezier (default)', () => {
|
||||||
|
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||||
|
// With only 2 pts (src + target), catmull-rom = cubic bezier
|
||||||
|
const path = buildWaypointPath(0, 0, [], 100, 100)
|
||||||
|
expect(path).toMatch(/^M 0 0 C/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes through a single waypoint with smooth curve', () => {
|
||||||
|
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100)
|
||||||
|
expect(path).toMatch(/^M 0 0 C/)
|
||||||
|
// Should not be a straight polyline
|
||||||
|
expect(path).not.toContain(' L ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes through multiple waypoints', () => {
|
||||||
|
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100)
|
||||||
|
expect(path).toMatch(/^M 0 0 C/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildWaypointPath — smooth style', () => {
|
||||||
|
it('builds a direct straight line with no waypoints (no bend)', () => {
|
||||||
|
// Only 2 points → no intermediate vertex → no rounding needed
|
||||||
|
expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes through a single waypoint with straight lines (no intermediate bend)', () => {
|
||||||
|
// 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp
|
||||||
|
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth')
|
||||||
|
// Should start at source and end at target
|
||||||
|
expect(path).toMatch(/^M 0 0/)
|
||||||
|
expect(path).toMatch(/100 100$/)
|
||||||
|
// Should contain a quadratic bezier at the waypoint corner
|
||||||
|
expect(path).toContain('Q')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes through multiple waypoints with rounded corners', () => {
|
||||||
|
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
|
||||||
|
expect(path).toMatch(/^M 0 0/)
|
||||||
|
expect(path).toMatch(/100 100$/)
|
||||||
|
expect(path).toContain('Q')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not round corners when segment is too short (r clamped to 0)', () => {
|
||||||
|
// Adjacent waypoints very close together — r → 0, falls back to L
|
||||||
|
const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth')
|
||||||
|
expect(path).toMatch(/^M 0 0/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('snap45', () => {
|
||||||
|
// Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px)
|
||||||
|
it('snaps horizontal direction when close (deviation < threshold)', () => {
|
||||||
|
// (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps
|
||||||
|
const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 })
|
||||||
|
expect(r.y).toBe(0)
|
||||||
|
expect(r.x).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps vertical direction when close', () => {
|
||||||
|
const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 })
|
||||||
|
expect(r.x).toBe(0)
|
||||||
|
expect(r.y).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps 45° diagonal when close', () => {
|
||||||
|
// (80, 83) — nearly 45°, deviation ≈ 2px → snaps
|
||||||
|
const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 })
|
||||||
|
expect(r.x).toBe(r.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT snap when deviation exceeds threshold', () => {
|
||||||
|
// (100, 40) — deviation from 0° is ~40px > 15 → no snap
|
||||||
|
const pos = { x: 100, y: 40 }
|
||||||
|
const r = snap45({ x: 0, y: 0 }, pos)
|
||||||
|
expect(r).toEqual(pos)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns pos unchanged when distance < 1', () => {
|
||||||
|
const pos = { x: 5, y: 5 }
|
||||||
|
expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves distance from origin when snapping', () => {
|
||||||
|
const from = { x: 0, y: 0 }
|
||||||
|
const pos = { x: 100, y: 3 } // close to horizontal
|
||||||
|
const r = snap45(from, pos)
|
||||||
|
const origDist = Math.hypot(pos.x - from.x, pos.y - from.y)
|
||||||
|
const snapDist = Math.hypot(r.x - from.x, r.y - from.y)
|
||||||
|
expect(snapDist).toBeCloseTo(origDist, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('snap45both', () => {
|
||||||
|
it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => {
|
||||||
|
// prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both
|
||||||
|
const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 })
|
||||||
|
// Result must be on a 45°-ray from (0,0)
|
||||||
|
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
|
||||||
|
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
|
||||||
|
// Result must be on a 45°-ray from (100,100)
|
||||||
|
const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4)
|
||||||
|
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => {
|
||||||
|
// prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each)
|
||||||
|
// pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100)
|
||||||
|
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 })
|
||||||
|
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
|
||||||
|
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
|
||||||
|
const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4)
|
||||||
|
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns raw pos when beyond threshold', () => {
|
||||||
|
// pos=(100,80) is 20px from nearest intersection (100,100) → no snap
|
||||||
|
const pos = { x: 100, y: 80 }
|
||||||
|
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos)
|
||||||
|
expect(r).toEqual(pos)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back gracefully when prev === next', () => {
|
||||||
|
// No valid intersection → fallback to snap45
|
||||||
|
const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 })
|
||||||
|
expect(r).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('distToSegment', () => {
|
||||||
|
it('returns 0 when point is on the segment', () => {
|
||||||
|
expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns perpendicular distance when point is beside segment', () => {
|
||||||
|
expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns distance to nearest endpoint when point is past the segment', () => {
|
||||||
|
expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles zero-length segment (a === b)', () => {
|
||||||
|
expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findInsertIndex', () => {
|
||||||
|
it('returns 0 when there are no waypoints (only one segment)', () => {
|
||||||
|
expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts before first waypoint when click is on first segment', () => {
|
||||||
|
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 })
|
||||||
|
expect(idx).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts after first waypoint when click is on second segment', () => {
|
||||||
|
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 })
|
||||||
|
expect(idx).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks the closest segment among multiple', () => {
|
||||||
|
const idx = findInsertIndex(
|
||||||
|
0, 0,
|
||||||
|
[{ x: 100, y: 0 }, { x: 100, y: 100 }],
|
||||||
|
200, 100,
|
||||||
|
{ x: 150, y: 105 },
|
||||||
|
)
|
||||||
|
expect(idx).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
|
useReactFlow,
|
||||||
useStore,
|
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, Waypoint } from '@/types'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
||||||
|
|
||||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
|
|
||||||
@@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string {
|
|||||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface WaypointHandleProps {
|
||||||
|
edgeId: string
|
||||||
|
index: number
|
||||||
|
waypoint: Waypoint
|
||||||
|
waypoints: Waypoint[]
|
||||||
|
color: string
|
||||||
|
pathStyle?: string
|
||||||
|
prevPoint: Waypoint
|
||||||
|
nextPoint: Waypoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) {
|
||||||
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
const updateEdge = useCanvasStore((s) => s.updateEdge)
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.buttons !== 1) return
|
||||||
|
let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY })
|
||||||
|
if (pathStyle === 'smooth') {
|
||||||
|
// Find the intersection of 45°-rays from both adjacent points so that
|
||||||
|
// ALL segments (prev→this and this→next) snap to 45° simultaneously.
|
||||||
|
pos = snap45both(prevPoint, nextPoint, pos)
|
||||||
|
}
|
||||||
|
const next = [...waypoints]
|
||||||
|
next[index] = pos
|
||||||
|
updateEdge(edgeId, { waypoints: next })
|
||||||
|
}, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint])
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) })
|
||||||
|
}, [edgeId, waypoints, index, updateEdge])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
border: '2px solid #0d1117',
|
||||||
|
cursor: 'grab',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
title="Drag to move · Double-click to remove"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add waypoint handle (+ button at segment midpoints) ──────────────────────
|
||||||
|
|
||||||
|
interface AddWaypointHandleProps {
|
||||||
|
edgeId: string
|
||||||
|
insertIndex: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
waypoints: Waypoint[]
|
||||||
|
color: string
|
||||||
|
pathStyle?: string
|
||||||
|
prevPoint: Waypoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) {
|
||||||
|
const updateEdge = useCanvasStore((s) => s.updateEdge)
|
||||||
|
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
let pos = { x, y }
|
||||||
|
if (pathStyle === 'smooth') pos = snap45(prevPoint, pos)
|
||||||
|
const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)]
|
||||||
|
updateEdge(edgeId, { waypoints: next })
|
||||||
|
}, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#0d1117',
|
||||||
|
border: `1.5px solid ${color}`,
|
||||||
|
color,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1,
|
||||||
|
cursor: 'crosshair',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
zIndex: 9,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
title="Click to add waypoint"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Segment midpoints ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute + handle positions for each path segment.
|
||||||
|
* For smooth style: bias the first + handle to the source handle axis and the
|
||||||
|
* last + handle to the target handle axis, so clicking always gives a clean
|
||||||
|
* perpendicular exit/entry (no diagonal guesswork near the nodes).
|
||||||
|
*/
|
||||||
|
function segmentMidpoints(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
pathStyle?: string,
|
||||||
|
sourcePosition?: string,
|
||||||
|
): { x: number; y: number; insertIndex: number }[] {
|
||||||
|
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
const isSmooth = pathStyle === 'smooth'
|
||||||
|
|
||||||
|
return pts.slice(0, -1).map((a, i) => {
|
||||||
|
const b = pts[i + 1]
|
||||||
|
let mx = (a.x + b.x) / 2
|
||||||
|
const my = (a.y + b.y) / 2
|
||||||
|
|
||||||
|
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||||
|
// the source handle axis so clicking it creates a perpendicular exit.
|
||||||
|
// Only applies to bottom/top handles (vertical exits) and only when the edge
|
||||||
|
// has no waypoints yet — once waypoints exist, all + handles stay at the
|
||||||
|
// real segment midpoint so they remain visually on the edge.
|
||||||
|
if (isSmooth && i === 0 && pts.length === 2) {
|
||||||
|
const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top'
|
||||||
|
if (vertSrc) mx = a.x // same X as source → + sits directly below/above node
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: mx, y: my, insertIndex: i }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main edge component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||||
|
|
||||||
|
const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0
|
||||||
|
? data.waypoints as Waypoint[]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const hasWaypoints = waypoints.length > 0
|
||||||
|
|
||||||
|
const pathStyle = data?.path_style as string | undefined
|
||||||
|
|
||||||
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 [autoPath, labelX] = pathStyle === 'smooth'
|
||||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||||
: getBezierPath(pathArgs)
|
: getBezierPath(pathArgs)
|
||||||
|
|
||||||
|
const edgePath = hasWaypoints
|
||||||
|
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
|
: autoPath
|
||||||
|
|
||||||
|
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
||||||
|
const midY = (sourceY + targetY) / 2
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
const edgeColors = theme.colors.edgeColors
|
const edgeColors = theme.colors.edgeColors
|
||||||
|
|
||||||
@@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customColor = data?.custom_color as string | undefined
|
const customColor = data?.custom_color as string | undefined
|
||||||
|
const strokeColor: string = selected
|
||||||
|
? theme.colors.edgeSelectedColor
|
||||||
|
: customColor
|
||||||
|
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
...BASE_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) } : {}),
|
||||||
@@ -50,48 +233,74 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animated dot: slightly brighter + thicker than the base edge, travels source→target
|
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
|
||||||
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
||||||
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
|
data?.animated === 'flow' ? 'flow' :
|
||||||
|
data?.animated === 'basic' ? 'basic' : 'none'
|
||||||
|
|
||||||
|
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||||
|
|
||||||
|
const midpoints = selected
|
||||||
|
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||||
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} />
|
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
|
||||||
{data?.animated && (
|
|
||||||
|
{animMode === 'basic' && (
|
||||||
<path
|
<path
|
||||||
d={edgePath}
|
d={edgePath}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={dotColor}
|
stroke={strokeColor}
|
||||||
strokeWidth={dotWidth}
|
strokeWidth={style.strokeWidth as number ?? 2}
|
||||||
|
strokeDasharray="5"
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
animation: 'homelable-basic-dash 0.5s linear infinite',
|
||||||
|
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{animMode === 'snake' && (
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={animColor}
|
||||||
|
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
||||||
strokeDasharray="20 10000"
|
strokeDasharray="20 10000"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
{isBidirectional ? (
|
{isBidirectional ? (
|
||||||
<animate
|
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
||||||
attributeName="stroke-dashoffset"
|
|
||||||
values="-10000;0;-10000"
|
|
||||||
keyTimes="0;0.5;1"
|
|
||||||
dur="20s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<animate
|
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
||||||
attributeName="stroke-dashoffset"
|
|
||||||
from="-10000"
|
|
||||||
to="0"
|
|
||||||
dur="10s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</path>
|
</path>
|
||||||
)}
|
)}
|
||||||
{data?.label && (
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
|
{data?.label && (
|
||||||
<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.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: theme.colors.edgeLabelColor,
|
color: theme.colors.edgeLabelColor,
|
||||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
@@ -99,8 +308,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
>
|
>
|
||||||
{data.label as string}
|
{data.label as string}
|
||||||
</div>
|
</div>
|
||||||
</EdgeLabelRenderer>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Existing waypoint drag handles */}
|
||||||
|
{selected && waypoints.map((wp, idx) => {
|
||||||
|
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||||
|
const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1]
|
||||||
|
return (
|
||||||
|
<WaypointHandle
|
||||||
|
key={`wp-${idx}`}
|
||||||
|
edgeId={id}
|
||||||
|
index={idx}
|
||||||
|
waypoint={wp}
|
||||||
|
waypoints={waypoints}
|
||||||
|
color={strokeColor}
|
||||||
|
pathStyle={pathStyle}
|
||||||
|
prevPoint={prevPoint}
|
||||||
|
nextPoint={nextPoint}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* + handles at segment midpoints to add new waypoints */}
|
||||||
|
{selected && midpoints.map((mp) => {
|
||||||
|
const prevPoint = mp.insertIndex === 0
|
||||||
|
? { x: sourceX, y: sourceY }
|
||||||
|
: waypoints[mp.insertIndex - 1]
|
||||||
|
return (
|
||||||
|
<AddWaypointHandle
|
||||||
|
key={`add-${mp.insertIndex}`}
|
||||||
|
edgeId={id}
|
||||||
|
insertIndex={mp.insertIndex}
|
||||||
|
x={mp.x}
|
||||||
|
y={mp.y}
|
||||||
|
waypoints={waypoints}
|
||||||
|
color={strokeColor}
|
||||||
|
pathStyle={pathStyle}
|
||||||
|
prevPoint={prevPoint}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</EdgeLabelRenderer>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { Waypoint } from '@/types'
|
||||||
|
|
||||||
|
// ── Path builders ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Catmull-Rom → cubic bezier for smooth curves through waypoints */
|
||||||
|
function buildCatmullRomPath(pts: Waypoint[]): string {
|
||||||
|
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||||
|
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const p0 = pts[Math.max(i - 1, 0)]
|
||||||
|
const p1 = pts[i]
|
||||||
|
const p2 = pts[i + 1]
|
||||||
|
const p3 = pts[Math.min(i + 2, pts.length - 1)]
|
||||||
|
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||||||
|
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||||||
|
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||||||
|
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||||||
|
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */
|
||||||
|
function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string {
|
||||||
|
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||||
|
if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`
|
||||||
|
|
||||||
|
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||||
|
|
||||||
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
|
const prev = pts[i - 1]
|
||||||
|
const curr = pts[i]
|
||||||
|
const next = pts[i + 1]
|
||||||
|
|
||||||
|
const dx1 = curr.x - prev.x
|
||||||
|
const dy1 = curr.y - prev.y
|
||||||
|
const len1 = Math.hypot(dx1, dy1)
|
||||||
|
|
||||||
|
const dx2 = next.x - curr.x
|
||||||
|
const dy2 = next.y - curr.y
|
||||||
|
const len2 = Math.hypot(dx2, dy2)
|
||||||
|
|
||||||
|
if (len1 < 1 || len2 < 1) {
|
||||||
|
d += ` L ${curr.x} ${curr.y}`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||||
|
|
||||||
|
// Approach point (on segment prev→curr, r units before corner)
|
||||||
|
const bx = curr.x - (dx1 / len1) * r
|
||||||
|
const by = curr.y - (dy1 / len1) * r
|
||||||
|
|
||||||
|
// Departure point (on segment curr→next, r units after corner)
|
||||||
|
const ax = curr.x + (dx2 / len2) * r
|
||||||
|
const ay = curr.y + (dy2 / len2) * r
|
||||||
|
|
||||||
|
d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}`
|
||||||
|
}
|
||||||
|
|
||||||
|
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWaypointPath(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
pathStyle: string = 'bezier',
|
||||||
|
): string {
|
||||||
|
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap `pos` to the nearest 45°-multiple direction from `from`.
|
||||||
|
* Only snaps when within SNAP_THRESHOLD px of a 45° position.
|
||||||
|
*/
|
||||||
|
export function snap45(from: Waypoint, pos: Waypoint): Waypoint {
|
||||||
|
const dx = pos.x - from.x
|
||||||
|
const dy = pos.y - from.y
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
if (dist < 1) return pos
|
||||||
|
const angle = Math.atan2(dy, dx)
|
||||||
|
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||||
|
const candidate = {
|
||||||
|
x: Math.round(from.x + dist * Math.cos(snapped)),
|
||||||
|
y: Math.round(from.y + dist * Math.sin(snapped)),
|
||||||
|
}
|
||||||
|
const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y)
|
||||||
|
return deviation <= SNAP_THRESHOLD ? candidate : pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */
|
||||||
|
const SNAP_THRESHOLD = 15
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the position closest to `pos` that lies simultaneously on a 45°-ray
|
||||||
|
* from `prev` AND on a 45°-ray from `next`.
|
||||||
|
*
|
||||||
|
* Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px —
|
||||||
|
* outside that zone the raw drag position is returned, allowing free placement.
|
||||||
|
*/
|
||||||
|
export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint {
|
||||||
|
let best: Waypoint | null = null
|
||||||
|
let bestDist = Infinity
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const a1 = i * Math.PI / 4
|
||||||
|
const c1 = Math.cos(a1), s1 = Math.sin(a1)
|
||||||
|
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
const a2 = j * Math.PI / 4
|
||||||
|
const c2 = Math.cos(a2), s2 = Math.sin(a2)
|
||||||
|
|
||||||
|
const dx = next.x - prev.x
|
||||||
|
const dy = next.y - prev.y
|
||||||
|
const det = -c1 * s2 + c2 * s1
|
||||||
|
if (Math.abs(det) < 1e-6) continue
|
||||||
|
|
||||||
|
const t = (-dx * s2 + c2 * dy) / det
|
||||||
|
const s = (c1 * dy - s1 * dx) / det
|
||||||
|
if (t < -1e-6 || s < -1e-6) continue
|
||||||
|
|
||||||
|
const ix = prev.x + t * c1
|
||||||
|
const iy = prev.y + t * s1
|
||||||
|
const d = Math.hypot(ix - pos.x, iy - pos.y)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
best = { x: Math.round(ix), y: Math.round(iy) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only snap if close enough — otherwise let the waypoint move freely
|
||||||
|
if (best === null || bestDist > SNAP_THRESHOLD) return pos
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number {
|
||||||
|
const dx = b.x - a.x
|
||||||
|
const dy = b.y - a.y
|
||||||
|
const lenSq = dx * dx + dy * dy
|
||||||
|
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
|
||||||
|
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
|
||||||
|
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findInsertIndex(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
point: Waypoint,
|
||||||
|
): number {
|
||||||
|
const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
let minDist = Infinity
|
||||||
|
let best = 0
|
||||||
|
for (let i = 0; i < allPts.length - 1; i++) {
|
||||||
|
const d = distToSegment(point, allPts[i], allPts[i + 1])
|
||||||
|
if (d < minDist) { minDist = d; best = i }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, 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 } 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 { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
|
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||||
|
|
||||||
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({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||||
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
|
const { zoom } = useViewport()
|
||||||
|
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -23,22 +36,38 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
|
||||||
|
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
||||||
|
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
||||||
|
const showLegacyHardware = !data.properties && 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 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
borderWidth: selected ? 2 : 1,
|
borderWidth,
|
||||||
boxShadow: isOnline
|
boxShadow: isOnline && selected
|
||||||
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
|
: isOnline
|
||||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
: selected
|
: selected
|
||||||
? `0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: '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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={140}
|
||||||
|
minHeight={50}
|
||||||
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||||
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
@@ -47,6 +76,8 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
/>
|
/>
|
||||||
<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 }} />
|
||||||
|
|
||||||
|
{/* Main row */}
|
||||||
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
@@ -58,25 +89,82 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
{createElement(resolvedIcon, { size: 15 })}
|
{createElement(resolvedIcon, { size: 15 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Label + IP */}
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<div
|
<div
|
||||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
className="text-xs font-medium leading-tight truncate"
|
||||||
style={{ color: theme.colors.nodeLabelColor }}
|
style={{ color: theme.colors.nodeLabelColor }}
|
||||||
title={data.label}
|
title={data.label}
|
||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</div>
|
</div>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<div
|
<div
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[10px] truncate"
|
className="font-mono text-[10px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
title={data.ip}
|
title={ip}
|
||||||
>
|
>
|
||||||
{hideIp ? maskIp(data.ip) : data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Properties section (new system) */}
|
||||||
|
{visibleProperties && visibleProperties.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
|
{visibleProperties.map((prop) => {
|
||||||
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
|
return (
|
||||||
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
|
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||||
|
{showLegacyHardware && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
|
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||||
|
{(data.cpu_model || data.cpu_count != null) && (
|
||||||
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
|
<Cpu size={9} className="shrink-0" />
|
||||||
|
{data.cpu_model && (
|
||||||
|
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
||||||
|
)}
|
||||||
|
{data.cpu_count != null && (
|
||||||
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(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
|
||||||
@@ -85,13 +173,26 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
title={data.status}
|
title={data.status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
||||||
|
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
||||||
|
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
||||||
|
return (
|
||||||
|
<span key={sourceId}>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="bottom"
|
id={sourceId}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
style={{ left: `${leftPct}%`, 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={targetId}
|
||||||
|
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
||||||
|
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||||
|
|
||||||
|
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
|
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||||
|
const showBorder = data.custom_colors?.show_border !== false
|
||||||
|
const isVisible = showBorder || selected
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [labelDraft, setLabelDraft] = useState(data.label)
|
||||||
|
|
||||||
|
const children = nodes.filter((n) => n.parentId === id)
|
||||||
|
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||||
|
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||||
|
const unknownCount = children.length - onlineCount - offlineCount
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (labelDraft.trim()) {
|
||||||
|
snapshotHistory()
|
||||||
|
updateNode(id, { label: labelDraft.trim() })
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColor = selected ? '#00d4ff' : '#30363d'
|
||||||
|
const borderStyle = selected ? 'solid' : 'dashed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={120}
|
||||||
|
minHeight={80}
|
||||||
|
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
|
||||||
|
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
{isVisible && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '5px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
|
||||||
|
borderRadius: '6px 6px 0 0',
|
||||||
|
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="nodrag"
|
||||||
|
value={labelDraft}
|
||||||
|
onChange={(e) => setLabelDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleRename()
|
||||||
|
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
color: '#e6edf3',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<button className="nodrag" onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||||
|
<button className="nodrag" onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="nodrag"
|
||||||
|
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
||||||
|
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
||||||
|
title="Rename group"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status summary */}
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
||||||
|
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}>● {onlineCount}</span>}
|
||||||
|
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount}</span>}
|
||||||
|
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}>● {unknownCount}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { useState } from 'react'
|
||||||
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import type { NodeData, TextPosition } from '@/types'
|
import type { NodeData, TextPosition } from '@/types'
|
||||||
|
|
||||||
@@ -26,17 +27,58 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
|||||||
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HANDLE_SIDES = [
|
||||||
|
{ id: 'zone-top', position: Position.Top },
|
||||||
|
{ id: 'zone-right', position: Position.Right },
|
||||||
|
{ id: 'zone-bottom', position: Position.Bottom },
|
||||||
|
{ id: 'zone-left', position: Position.Left },
|
||||||
|
] as const
|
||||||
|
|
||||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
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 backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||||
const textColor = rc.text_color ?? '#e6edf3'
|
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 fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||||
const posStyle = POSITION_STYLES[textPos]
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStyle: React.CSSProperties = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: borderColor,
|
||||||
|
border: '2px solid #0d1117',
|
||||||
|
borderRadius: '50%',
|
||||||
|
opacity: hovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
@@ -50,10 +92,20 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
background: '#00d4ff',
|
background: '#00d4ff',
|
||||||
border: '1px solid #0d1117',
|
border: '1px solid #0d1117',
|
||||||
}}
|
}}
|
||||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{HANDLE_SIDES.map(({ id: hid, position }) => (
|
||||||
|
<span key={hid}>
|
||||||
|
<Handle type="source" id={hid} position={position} style={handleStyle} />
|
||||||
|
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -61,22 +113,37 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
justifyContent: posStyle.justifyContent,
|
justifyContent: posStyle.justifyContent,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: backgroundColor,
|
background: backgroundColor,
|
||||||
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
|
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||||
|
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
fontFamily,
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditingGroupRectId(id)
|
setEditingGroupRectId(id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.label && (
|
{labelPosition === 'outside' && data.label && (
|
||||||
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
|
<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}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
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 } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
@@ -41,6 +44,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
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
|
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
||||||
|
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -80,7 +84,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
background: theme.colors.nodeIconBackground,
|
background: theme.colors.nodeIconBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Layers size={12} />
|
{createElement(resolvedIcon, { 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
|
<span
|
||||||
@@ -106,6 +110,27 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
|
||||||
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={prop.key}
|
||||||
|
className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden px-2.5 shrink-0"
|
||||||
|
style={{
|
||||||
|
color: theme.colors.nodeSubtextColor,
|
||||||
|
paddingTop: i === 0 ? 4 : 2,
|
||||||
|
paddingBottom: i === arr.length - 1 ? 4 : 2,
|
||||||
|
borderTop: i === 0 ? `1px solid ${glow}22` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
|
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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, Package,
|
||||||
} 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,6 @@ 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 DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
|
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
|
import { GroupNode } from './GroupNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
@@ -17,6 +18,9 @@ export const nodeTypes = {
|
|||||||
printer: PrinterNode,
|
printer: PrinterNode,
|
||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
|
docker_host: DockerHostNode,
|
||||||
|
docker_container: DockerContainerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
|
group: GroupNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,32 @@ 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' | 'basic' | 'snake' | 'flow'
|
||||||
|
|
||||||
|
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
||||||
|
if (v === true || v === 'snake') return 'snake'
|
||||||
|
if (v === 'flow') return 'flow'
|
||||||
|
if (v === 'basic') return 'basic'
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
interface EdgeModalProps {
|
interface EdgeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: EdgeData) => void
|
onSubmit: (data: EdgeData) => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
|
onClearWaypoints?: () => void
|
||||||
initial?: Partial<EdgeData>
|
initial?: Partial<EdgeData>
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||||
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
||||||
const [label, setLabel] = useState(initial?.label ?? '')
|
const [label, setLabel] = useState(initial?.label ?? '')
|
||||||
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 [animated, setAnimated] = useState(initial?.animated ?? false)
|
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
@@ -37,7 +47,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: animated || undefined,
|
animated: animation !== 'none' ? animation : undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -115,20 +125,25 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Flow Animation</Label>
|
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||||
|
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
||||||
|
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||||
<button
|
<button
|
||||||
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAnimated((a) => !a)}
|
onClick={() => setAnimation(mode)}
|
||||||
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
|
className="flex-1 py-1 text-xs capitalize transition-colors"
|
||||||
style={{ background: animated ? '#00d4ff' : '#30363d' }}
|
style={{
|
||||||
aria-pressed={animated}
|
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||||
|
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||||
|
borderRight: i < 3 ? '1px solid #30363d' : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||||
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
|
||||||
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -162,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onClearWaypoints(); onClose() }}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
|
||||||
|
>
|
||||||
|
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Download, Loader2 } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export'
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
getElement: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
||||||
|
const [quality, setQuality] = useState<ExportQuality>('high')
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const el = getElement()
|
||||||
|
if (!el) return
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
await exportToPng(el, quality)
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground">Export as PNG</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
{EXPORT_QUALITY_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuality(opt.value)}
|
||||||
|
className={[
|
||||||
|
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
|
||||||
|
quality === opt.value
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
|
||||||
|
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-xs opacity-70">{opt.hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={exporting}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting}
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
>
|
||||||
|
{exporting
|
||||||
|
? <><Loader2 size={14} className="animate-spin mr-1.5" />Exporting…</>
|
||||||
|
: <><Download size={14} className="mr-1.5" />Download</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,23 +5,66 @@ 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, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import type { TextPosition } from '@/types'
|
import type { TextPosition } from '@/types'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
|
||||||
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
|
export type LabelPosition = 'inside' | 'outside'
|
||||||
|
|
||||||
export interface GroupRectFormData {
|
export interface GroupRectFormData {
|
||||||
label: string
|
label: string
|
||||||
font: string
|
font: string
|
||||||
text_color: string
|
text_color: string
|
||||||
text_position: TextPosition
|
text_position: TextPosition
|
||||||
|
text_size: number
|
||||||
|
label_position: LabelPosition
|
||||||
border_color: string
|
border_color: string
|
||||||
|
border_style: BorderStyle
|
||||||
|
border_width: number
|
||||||
background_color: string
|
background_color: string
|
||||||
z_order: number
|
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 = {
|
const DEFAULT_FORM: GroupRectFormData = {
|
||||||
label: '',
|
label: '',
|
||||||
font: 'inter',
|
font: 'inter',
|
||||||
text_color: '#e6edf3',
|
text_color: '#e6edf3',
|
||||||
text_position: 'top-left',
|
text_position: 'top-left',
|
||||||
|
text_size: 12,
|
||||||
|
label_position: 'inside',
|
||||||
border_color: '#00d4ff',
|
border_color: '#00d4ff',
|
||||||
|
border_style: 'solid',
|
||||||
|
border_width: 2,
|
||||||
background_color: '#00d4ff0d',
|
background_color: '#00d4ff0d',
|
||||||
z_order: 1,
|
z_order: 1,
|
||||||
}
|
}
|
||||||
@@ -53,7 +96,7 @@ interface GroupRectModalProps {
|
|||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
|
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) {
|
||||||
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
|
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
|
||||||
|
|
||||||
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
|
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
|
||||||
@@ -133,11 +176,38 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Colors */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{colorFields.map(({ key, label }) => (
|
{colorFields.map(({ key, label }) => {
|
||||||
|
const { hex6, alpha } = hexToRgba(form[key])
|
||||||
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1 items-center">
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
<label
|
<label
|
||||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||||
@@ -145,15 +215,103 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={form[key]}
|
value={hex6}
|
||||||
onChange={(e) => set(key, e.target.value)}
|
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||||
/>
|
/>
|
||||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={alpha}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
|
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title={`Opacity: ${alpha}%`}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,7 +349,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||||
{title === 'Add Rectangle' ? 'Add' : 'Save'}
|
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import { createElement, useState } from 'react'
|
import { Fragment, createElement, useState } from 'react'
|
||||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
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, NODE_TYPE_DEFAULT_ICONS } 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_host', 'docker_container'] },
|
||||||
|
{ 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']
|
||||||
|
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
|
|
||||||
|
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||||
|
none: 'None',
|
||||||
|
ping: 'Ping',
|
||||||
|
http: 'HTTP',
|
||||||
|
https: 'HTTPS',
|
||||||
|
tcp: 'TCP',
|
||||||
|
ssh: 'SSH',
|
||||||
|
prometheus: 'Prometheus',
|
||||||
|
health: 'Health',
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_DATA: Partial<NodeData> = {
|
const DEFAULT_DATA: Partial<NodeData> = {
|
||||||
type: 'server',
|
type: 'server',
|
||||||
@@ -21,7 +38,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
check_method: 'ping',
|
check_method: 'ping',
|
||||||
services: [],
|
services: [],
|
||||||
container_mode: true,
|
container_mode: false,
|
||||||
custom_colors: undefined,
|
custom_colors: undefined,
|
||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
@@ -32,14 +49,12 @@ interface NodeModalProps {
|
|||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
proxmoxNodes?: { id: string; label: string }[]
|
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough — no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
||||||
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)
|
||||||
@@ -55,10 +70,19 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLabelError(false)
|
setLabelError(false)
|
||||||
onSubmit(form)
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
|
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||||
|
onSubmit({
|
||||||
|
...form,
|
||||||
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredParentNodes = form.type === 'docker_container'
|
||||||
|
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
||||||
|
: parentContainerNodes
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
||||||
@@ -68,25 +92,35 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Type */}
|
{/* Type + Icon on the same row */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
||||||
<SelectValue />
|
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</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">
|
<Fragment key={group.label}>
|
||||||
{label}
|
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
|
||||||
|
<SelectGroup>
|
||||||
|
<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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<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">Icon</Label>
|
<Label className="text-xs text-muted-foreground">Icon</Label>
|
||||||
{form.custom_icon && (
|
{form.custom_icon && (
|
||||||
@@ -103,22 +137,25 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIconPickerOpen((o) => !o)}
|
onClick={() => setIconPickerOpen((o) => !o)}
|
||||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
|
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||||
}
|
}
|
||||||
return <span className="text-muted-foreground">Default (from type)</span>
|
const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
|
||||||
|
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
</button>
|
</button>
|
||||||
{/* Inline picker panel */}
|
</div>
|
||||||
|
|
||||||
|
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||||
<Input
|
<Input
|
||||||
value={iconSearch}
|
value={iconSearch}
|
||||||
onChange={(e) => setIconSearch(e.target.value)}
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
@@ -165,7 +202,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
@@ -192,11 +228,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
{/* IP */}
|
{/* IP */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.ip ?? ''}
|
value={form.ip ?? ''}
|
||||||
onChange={(e) => set('ip', e.target.value)}
|
onChange={(e) => set('ip', e.target.value)}
|
||||||
placeholder="192.168.1.x"
|
placeholder="192.168.1.x, 2001:db8::1"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,11 +242,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
<SelectValue />
|
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{CHECK_METHODS.map((m) => (
|
{CHECK_METHODS.map((m) => (
|
||||||
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
|
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -227,20 +263,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parent Proxmox (VM / LXC only) */}
|
{/* Parent container */}
|
||||||
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
|
||||||
<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">Parent Proxmox</Label>
|
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.parent_id ?? 'none'}
|
value={form.parent_id ?? 'none'}
|
||||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
<SelectValue placeholder="None (standalone)" />
|
<SelectValue placeholder="None (standalone)">
|
||||||
|
{form.parent_id
|
||||||
|
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
||||||
|
: 'None (standalone)'}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
||||||
{proxmoxNodes.map((n) => (
|
{filteredParentNodes.map((n) => (
|
||||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -248,24 +288,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Container mode (proxmox only) */}
|
{/* Container mode */}
|
||||||
{form.type === 'proxmox' && (
|
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||||
<div className="flex items-center justify-between col-span-2 py-1">
|
<div className="flex items-center justify-between col-span-2 py-1">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||||
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
|
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={!!form.container_mode}
|
aria-checked={!!form.container_mode}
|
||||||
onClick={() => set('container_mode', !form.container_mode)}
|
onClick={() => set('container_mode', !form.container_mode)}
|
||||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
||||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
||||||
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
|
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,6 +355,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom connection points (not for group containers) */}
|
||||||
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||||
|
<Select
|
||||||
|
value={String(form.bottom_handles ?? 1)}
|
||||||
|
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
||||||
|
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
||||||
|
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
||||||
|
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface PendingDevice {
|
|||||||
services: Service[]
|
services: Service[]
|
||||||
suggested_type: string | null
|
suggested_type: string | null
|
||||||
status: string
|
status: string
|
||||||
|
discovery_source: string | null
|
||||||
discovered_at: string
|
discovered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
|
|
||||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
|
|
||||||
const handleApprove = () => { onApprove(device); onClose() }
|
const handleApprove = () => { onApprove(device) }
|
||||||
const handleHide = () => { onHide(device); onClose() }
|
const handleHide = () => { onHide(device); onClose() }
|
||||||
const handleIgnore = () => { onIgnore(device); onClose() }
|
const handleIgnore = () => { onIgnore(device); onClose() }
|
||||||
|
|
||||||
@@ -101,7 +102,10 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
{device.suggested_type && (
|
{device.suggested_type && (
|
||||||
<InfoRow label="Type" value={device.suggested_type} />
|
<InfoRow label="Type" value={device.suggested_type} />
|
||||||
)}
|
)}
|
||||||
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} />
|
{device.discovery_source && (
|
||||||
|
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
|
||||||
|
)}
|
||||||
|
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2, Settings } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -15,40 +15,29 @@ interface ScanConfigModalProps {
|
|||||||
|
|
||||||
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
|
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
|
||||||
const [ranges, setRanges] = useState<string[]>([''])
|
const [ranges, setRanges] = useState<string[]>([''])
|
||||||
const [interval, setInterval] = useState(60)
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
scanApi.getConfig()
|
scanApi.getConfig()
|
||||||
.then((res) => {
|
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
|
||||||
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
|
|
||||||
setInterval(res.data.interval_seconds)
|
|
||||||
})
|
|
||||||
.catch(() => {/* use defaults */})
|
.catch(() => {/* use defaults */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval })
|
|
||||||
toast.success('Scan config saved')
|
|
||||||
onClose()
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to save config')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScanNow = async () => {
|
const handleScanNow = async () => {
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
||||||
await handleSave()
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await scanApi.saveConfig({ ranges: cleaned })
|
||||||
|
await scanApi.trigger()
|
||||||
onScanNow()
|
onScanNow()
|
||||||
onClose()
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to start scan')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,23 +84,14 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status check interval */}
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
<div className="space-y-1.5">
|
<Settings size={11} />
|
||||||
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label>
|
Status check interval can be configured in the sidebar Settings.
|
||||||
<Input
|
</p>
|
||||||
type="number"
|
|
||||||
min={10}
|
|
||||||
max={3600}
|
|
||||||
value={interval}
|
|
||||||
onChange={(e) => setInterval(Number(e.target.value))}
|
|
||||||
className="font-mono text-sm bg-[#0d1117] border-border w-32"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleScanNow}
|
onClick={handleScanNow}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -1,34 +1,61 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { useReactFlow } from '@xyflow/react'
|
import { useReactFlow } from '@xyflow/react'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
interface SearchModalProps {
|
interface SearchModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onOpenPending: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchModal({ open, onClose }: SearchModalProps) {
|
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||||
const nodes = useCanvasStore((s) => s.nodes)
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
|
||||||
|
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
|
||||||
n.data.label?.toLowerCase().includes(q) ||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
n.data.ip?.toLowerCase().includes(q) ||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
n.data.hostname?.toLowerCase().includes(q)
|
n.data.hostname?.toLowerCase().includes(q)
|
||||||
).slice(0, 8)
|
).slice(0, 6)
|
||||||
|
|
||||||
const handleSelect = useCallback((nodeId: string) => {
|
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||||
|
d.ip.toLowerCase().includes(q) ||
|
||||||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.services.some((s) =>
|
||||||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
|
s.category?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
|
||||||
|
const totalResults = nodeResults.length + pendingResults.length
|
||||||
|
|
||||||
|
const handleSelectNode = useCallback((nodeId: string) => {
|
||||||
setSelectedNode(nodeId)
|
setSelectedNode(nodeId)
|
||||||
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||||
onClose()
|
onClose()
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}, [fitView, setSelectedNode, onClose])
|
}, [fitView, setSelectedNode, onClose])
|
||||||
|
|
||||||
|
const handleSelectPending = useCallback((deviceId: string) => {
|
||||||
|
onOpenPending(deviceId)
|
||||||
|
onClose()
|
||||||
|
setQuery('')
|
||||||
|
}, [onOpenPending, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search nodes by label, IP, hostname…"
|
placeholder="Search nodes, pending devices by IP or service…"
|
||||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
|
||||||
|
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{totalResults > 0 && (
|
||||||
<ul className="py-1 max-h-64 overflow-y-auto">
|
<ul className="py-1 max-h-72 overflow-y-auto">
|
||||||
{results.map((node) => (
|
{nodeResults.map((node) => (
|
||||||
<li
|
<li
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
onClick={() => handleSelect(node.id)}
|
onClick={() => handleSelectNode(node.id)}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
<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>
|
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||||
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||||
|
<li className="px-4 py-1">
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{pendingResults.map((device) => {
|
||||||
|
const serviceName = device.services.find((s) => s.service_name)?.service_name
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
|
onClick={() => handleSelectPending(device.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
|
||||||
|
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.length > 0 && results.length === 0 && (
|
{q.length > 0 && totalResults === 0 && (
|
||||||
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
<p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.length === 0 && (
|
{q.length === 0 && (
|
||||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices…</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
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 Basic sends animated: "basic"', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Basic'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills animation from initial "basic" string', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Waypoints / Clear path ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('does not show Clear path button when onClearWaypoints is not provided', () => {
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
|
||||||
|
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show Clear path button when waypoints are empty', () => {
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
|
||||||
|
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show Clear path button when no initial waypoints', () => {
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
|
||||||
|
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Clear path button with count when waypoints exist', () => {
|
||||||
|
render(
|
||||||
|
<EdgeModal
|
||||||
|
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||||
|
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows singular "point" when only one waypoint', () => {
|
||||||
|
render(
|
||||||
|
<EdgeModal
|
||||||
|
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||||
|
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Clear path (1 point)')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
|
||||||
|
const onClearWaypoints = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<EdgeModal
|
||||||
|
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
|
||||||
|
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Clear path (1 point)'))
|
||||||
|
expect(onClearWaypoints).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ExportModal } from '../ExportModal'
|
||||||
|
|
||||||
|
const mockExportToPng = vi.fn()
|
||||||
|
vi.mock('@/utils/export', () => ({
|
||||||
|
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
|
||||||
|
EXPORT_QUALITY_OPTIONS: [
|
||||||
|
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
|
||||||
|
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
|
||||||
|
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const el = document.createElement('div')
|
||||||
|
const getElement = () => el
|
||||||
|
const onClose = vi.fn()
|
||||||
|
|
||||||
|
describe('ExportModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockExportToPng.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all three quality options', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
expect(screen.getByText('Standard')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('High')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Ultra')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects High by default', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
const highBtn = screen.getByText('High').closest('button')!
|
||||||
|
expect(highBtn.className).toContain('border-[#00d4ff]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes selection when another option is clicked', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('Ultra').closest('button')!)
|
||||||
|
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
|
||||||
|
expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls exportToPng with selected quality on Download click', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('Standard').closest('button')!)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes after successful export', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call exportToPng when getElement returns null', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={() => null} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
|
||||||
|
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,14 +13,15 @@ describe('GroupRectModal', () => {
|
|||||||
it('renders form fields when open', () => {
|
it('renders form fields when open', () => {
|
||||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
|
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
|
||||||
expect(screen.getByText('Add Rectangle')).toBeDefined()
|
expect(screen.getByText('Add Zone')).toBeDefined()
|
||||||
expect(screen.getByText('Text Position')).toBeDefined()
|
expect(screen.getByText('Text Position')).toBeDefined()
|
||||||
|
expect(screen.getByText('Border Width')).toBeDefined()
|
||||||
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Edit Rectangle title when provided', () => {
|
it('renders Edit Zone title when provided', () => {
|
||||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
|
||||||
expect(screen.getByText('Edit Rectangle')).toBeDefined()
|
expect(screen.getByText('Edit Zone')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onSubmit with form data on submit', () => {
|
it('calls onSubmit with form data on submit', () => {
|
||||||
@@ -80,4 +81,230 @@ describe('GroupRectModal', () => {
|
|||||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
expect(submitted.text_position).toBe('bottom-right')
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows opacity sliders for all three color fields', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
expect(sliders).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('default background_color is 8-digit hex with low alpha', () => {
|
||||||
|
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.background_color).toBe('#00d4ff0d')
|
||||||
|
expect(submitted.background_color.length).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moving background opacity slider updates background_color alpha', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
// background slider is the third one (Text, Border, Background)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
fireEvent.change(sliders[2], { target: { value: '50' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
// alpha 50% → 0x80 = 128
|
||||||
|
expect(submitted.background_color).toBe('#00d4ff80')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moving border opacity slider to 0 makes border fully transparent', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
fireEvent.change(sliders[1], { target: { value: '0' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_color).toBe('#00d4ff00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills opacity from 8-digit initial background_color', () => {
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ background_color: '#ff6e0080' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
expect((sliders[2] as HTMLInputElement).value).toBe('50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows opacity percentage in label', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
// Background default is 5% opacity
|
||||||
|
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,65 +1,362 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { NodeModal } from '../NodeModal'
|
import { NodeModal } from '../NodeModal'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
// ── Mock Shadcn Select with native <select> for testability ───────────────
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/select', () => ({
|
||||||
|
Select: ({ value, onValueChange, children }: {
|
||||||
|
value?: string; onValueChange?: (v: string) => void; children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
SelectValue: () => null,
|
||||||
|
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
SelectLabel: () => null,
|
||||||
|
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
|
||||||
|
<option value={value}>{children}</option>
|
||||||
|
),
|
||||||
|
SelectSeparator: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderModal(props: Partial<Parameters<typeof NodeModal>[0]> = {}) {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||||
|
return { onClose, onSubmit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get <select> elements in document order: [0]=Type, [1]=CheckMethod, [2]=BottomHandles */
|
||||||
|
function selects() { return screen.getAllByRole('combobox') as HTMLSelectElement[] }
|
||||||
|
|
||||||
|
const BASE: Partial<NodeData> = {
|
||||||
|
type: 'server', label: 'My Server', hostname: 'server.lan',
|
||||||
|
ip: '192.168.1.10', check_method: 'ping', services: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('NodeModal', () => {
|
describe('NodeModal', () => {
|
||||||
|
|
||||||
|
// ── Visibility ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders nothing when closed', () => {
|
it('renders nothing when closed', () => {
|
||||||
const { container } = render(
|
const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
|
||||||
)
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders form fields when open', () => {
|
it('renders form fields when open', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
||||||
expect(screen.getByText('Add Node')).toBeDefined()
|
expect(screen.getByText('Add Node')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not call onSubmit when label is empty and shows error', () => {
|
it('shows "Add" button for default title', () => {
|
||||||
const onSubmit = vi.fn()
|
renderModal()
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
|
||||||
fireEvent.click(screen.getByText('Add'))
|
})
|
||||||
|
|
||||||
|
it('shows "Save" button when title is Edit Node', () => {
|
||||||
|
renderModal({ title: 'Edit Node' })
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills form from initial prop', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
|
||||||
|
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan')
|
||||||
|
expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Cancel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
const { onClose } = renderModal()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
|
const { onSubmit } = renderModal()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect(onSubmit).not.toHaveBeenCalled()
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
expect(screen.getByText('Label is required')).toBeDefined()
|
expect(screen.getByText('Label is required')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onSubmit with form data when label is filled', () => {
|
it('blocks submit when label is whitespace only', () => {
|
||||||
const onSubmit = vi.fn()
|
const { onSubmit } = renderModal()
|
||||||
const onClose = vi.fn()
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
|
||||||
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
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', () => {
|
it('clears label error when user starts typing', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
renderModal()
|
||||||
fireEvent.click(screen.getByText('Add'))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect(screen.getByText('Label is required')).toBeDefined()
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
||||||
expect(screen.queryByText('Label is required')).toBeNull()
|
expect(screen.queryByText('Label is required')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills form from initial prop', () => {
|
// ── Form submission ───────────────────────────────────────────────────
|
||||||
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', () => {
|
it('calls onSubmit and onClose with form data on valid submit', () => {
|
||||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
|
const { onSubmit, onClose } = renderModal({ initial: BASE })
|
||||||
expect(screen.getByText('Save')).toBeDefined()
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
})
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
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()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(data.label).toBe('My Server')
|
||||||
|
expect(data.type).toBe('server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits updated hostname, IP and notes', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(data.hostname).toBe('nas.local')
|
||||||
|
expect(data.ip).toBe('10.0.0.1')
|
||||||
|
expect(data.notes).toBe('rack A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets form values when reopened in Add mode', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
|
||||||
|
const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
|
||||||
|
|
||||||
|
rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
|
||||||
|
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits check_target', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Type selector ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('pre-fills type from initial', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'router' } })
|
||||||
|
expect(selects()[0].value).toBe('router')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes type and submits it', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[0], { target: { value: 'nas' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Check method ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('pre-fills check_method from initial', () => {
|
||||||
|
renderModal({ initial: { ...BASE, check_method: 'http' } })
|
||||||
|
expect(selects()[1].value).toBe('http')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes check_method and submits it', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[1], { target: { value: 'ssh' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('ssh')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Icon picker ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows "Default" label when no custom icon', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('Default')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens icon picker on trigger button click', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
expect(screen.getByPlaceholderText('Search icons…')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes picker and shows icon label after selecting an icon', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
|
||||||
|
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||||
|
expect(screen.getByText('Database (SQL/NoSQL)')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits custom_icon key after picking', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).custom_icon).toBe('database')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Reset button when custom_icon is set', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
|
||||||
|
expect(screen.getByRole('button', { name: /Reset/i })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Reset button when no custom_icon', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByRole('button', { name: /Reset/i })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets custom_icon and shows Default on Reset click', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Reset/i }))
|
||||||
|
expect(screen.getByText('Default')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters icons by search query', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByText('Default'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
|
||||||
|
expect(screen.getByTitle('Grafana / Kibana')).toBeDefined()
|
||||||
|
expect(screen.queryByTitle('Router')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Container mode ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
|
||||||
|
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
|
||||||
|
|
||||||
|
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles container_mode on click', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Parent container ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
||||||
|
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
||||||
|
|
||||||
|
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type },
|
||||||
|
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
expect(screen.getByText('Container 01')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('docker_container shows only docker_host parents', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentContainerNodes: [
|
||||||
|
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
|
||||||
|
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('My Docker Host')).toBeDefined()
|
||||||
|
expect(screen.queryByText('My Proxmox')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('docker_container hides Parent Container when no docker_host is available', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('border')).toBeDefined()
|
||||||
|
expect(screen.getByText('background')).toBeDefined()
|
||||||
|
expect(screen.getByText('icon')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default colors hint when no custom_colors', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Reset to defaults when custom_colors are set', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
|
||||||
|
expect(screen.getByText('Reset to defaults')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets custom_colors on Reset to defaults click', () => {
|
||||||
|
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
|
||||||
|
fireEvent.click(screen.getByText('Reset to defaults'))
|
||||||
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Bottom connection points ───────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows Bottom Connection Points for server type', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Bottom Connection Points for groupRect', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||||
|
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Bottom Connection Points for group', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'group' } })
|
||||||
|
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults bottom_handles to 1', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(selects()[2].value).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills bottom_handles from initial', () => {
|
||||||
|
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||||
|
expect(selects()[2].value).toBe('3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits updated bottom_handles', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[2], { target: { value: '4' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 when Approve is clicked (parent controls close on success)', () => {
|
||||||
|
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).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,111 @@
|
|||||||
|
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(),
|
||||||
|
trigger: 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'] } }
|
||||||
|
|
||||||
|
describe('ScanConfigModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||||
|
vi.mocked(scanApi.saveConfig).mockReset()
|
||||||
|
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||||
|
vi.mocked(scanApi.trigger).mockReset()
|
||||||
|
vi.mocked(scanApi.trigger).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('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')
|
||||||
|
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'] } } as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
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: [''] } } as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
|
||||||
|
})
|
||||||
|
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves config, triggers scan, calls onScanNow and closes 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).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||||
|
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||||
|
expect(onScanNow).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).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 scanning', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.saveConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||