Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 529c75a175 | |||
| fc765fa255 | |||
| 77159ce1cd | |||
| f8635df1c5 | |||
| 1cc9b7c52f | |||
| fdf2b1f2be | |||
| 5630e7d202 | |||
| be705f0cb9 | |||
| 1790d24ee7 | |||
| 8b97f578c6 | |||
| 48e7b3c690 | |||
| 3a4df578ec | |||
| 43426e96ac | |||
| 280d5a2ae1 | |||
| cdc6091bd0 | |||
| 14597ab86f | |||
| 14719a5c39 | |||
| ea451885af | |||
| 9cd93ef294 | |||
| 8d937b2a0d | |||
| 60ea0fa11b | |||
| ff0d33128c | |||
| 17d4ddd070 | |||
| 4795dc40b8 | |||
| 994ed9d77a | |||
| 4d4befa916 | |||
| 8f741691d0 | |||
| c21fbac599 | |||
| e9c66fceda | |||
| 373960f6ea | |||
| aa17edf1d0 | |||
| 5f87c64dcf | |||
| 5822a1483a | |||
| 3a303a1376 | |||
| f5e0e68806 | |||
| 8541922386 | |||
| 3ed9cb0d4f | |||
| fff11a4b6a | |||
| 890463373a | |||
| 0680566081 | |||
| 2f5a90a00e | |||
| 928f63df0f | |||
| e84a4e0eb3 | |||
| 3b0dbd7a8b | |||
| 3a9b3b2650 | |||
| ff02f3b5db | |||
| a66e6aa906 | |||
| 8e59d9a0d1 | |||
| ee4136b506 | |||
| 8e8f01d6ca | |||
| aba4f34168 | |||
| 6524efe126 | |||
| 062a08f116 | |||
| 5e9963db4d | |||
| 82203f67d8 | |||
| 004a8f19c1 | |||
| 5ab0bdeb7f | |||
| 3f9866e8a1 | |||
| ebdf6cb55b | |||
| ec8f1c87f1 | |||
| 2b91f31807 | |||
| c6965b827b | |||
| afdea65d4e | |||
| 32eb9fbe23 | |||
| 27fd78ece6 | |||
| e672cb086a | |||
| 3cddcf5b81 | |||
| 19791abf5b | |||
| d9d4be9a53 | |||
| c165b8b016 | |||
| b16e81cf29 | |||
| 3028e5a3f7 | |||
| 340bd15099 | |||
| d181cef4c1 | |||
| 4a048d0ab5 | |||
| f32c32f6a5 | |||
| d1f170d5db | |||
| 0a680fb9f2 | |||
| 9db41e886a | |||
| 0863c2db94 | |||
| b17299f531 | |||
| 277879488f | |||
| 456f26a85a | |||
| 2a6140a6b1 | |||
| 3ae159d8d6 | |||
| 5e567d4628 | |||
| 05db9a59f5 | |||
| a46e505505 | |||
| 5b5866d8d6 | |||
| 9970780e7a | |||
| 53aeb82af1 | |||
| d49610163f | |||
| 517130a9ef | |||
| c90538b1d0 | |||
| 67b0620be2 | |||
| cc9c010002 | |||
| 103e24e5fa | |||
| 8110ee075d | |||
| 2994f8653a | |||
| 5fb77ab00b | |||
| 56cfbd1e76 | |||
| 43761c60cb | |||
| ad958feabd | |||
| e3876e934c | |||
| dfa4a9c849 | |||
| 785be6a5dd | |||
| d5f4a9f729 | |||
| 8859893e42 | |||
| 93b98f760c | |||
| 30ed78c240 | |||
| 39f8d16ef1 | |||
| 0095bf8425 | |||
| 69436c438c | |||
| 2cc97a6de9 | |||
| 29a2ef1b20 | |||
| 0c836e0575 | |||
| 6518eb313b | |||
| 09a591f5f4 | |||
| 988b804b90 | |||
| 6fa0ada325 | |||
| 17613f42d1 | |||
| 0a44b69c4e | |||
| 84235d81bf | |||
| 31b61904ac | |||
| babbcb1dc5 | |||
| 12fde681ba | |||
| 63922f0841 | |||
| 896cd4fa21 | |||
| 8c9f1a23e0 | |||
| 18f9bb7bdf | |||
| da287d459c | |||
| adb2088752 | |||
| cd0e08fb91 | |||
| 3ccdde0bea | |||
| d0a49d0a0d | |||
| 31b5bc4515 | |||
| b05d70663c | |||
| 4e68af7cac | |||
| 528c362633 | |||
| bfe520cd49 | |||
| 29c97ae501 | |||
| 4ecd241bf4 | |||
| 96786f155b | |||
| 1d6127fed3 | |||
| 26633f760d | |||
| 745002593f | |||
| 1a978c5e51 | |||
| 16adff5cff | |||
| 45b0965fb7 | |||
| f1bcd6ef78 | |||
| 26be37f731 | |||
| ebc1d41d5c | |||
| fe5e3c9858 | |||
| 78b47384da | |||
| 38e6604f50 | |||
| 0e70b45e8a | |||
| 7312132767 | |||
| cb5b1bd2e2 | |||
| c9a402fa0a | |||
| 9f880395da | |||
| 585df726e7 | |||
| 1a3cde3a02 | |||
| bfd7ccd36c | |||
| ada30311ed | |||
| a8ca4f1cca | |||
| feb05a1df5 | |||
| 0204a7ddeb | |||
| 6ead20125d | |||
| 03e1e32af1 | |||
| c9cd6a08fc | |||
| daa78a036a | |||
| 3deb750441 | |||
| 88554ef952 | |||
| 110592f89e | |||
| 8b8da5584c | |||
| 8626fb2ca4 | |||
| 5952274c27 | |||
| 9c035e2be2 | |||
| cad3add223 | |||
| fc888629c3 | |||
| 6a21cc729e | |||
| 06370529c6 | |||
| 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 | |||
| 892710faac | |||
| eb235cb101 | |||
| 04a1c63558 | |||
| 88f0c03c57 | |||
| b0a67744f5 | |||
| 431fb47498 |
@@ -28,3 +28,9 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# Off by default. Set to a random secret to enable.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# LIVEVIEW_KEY=
|
||||
|
||||
# Gethomepage widget — read-only stats at /api/v1/stats/summary
|
||||
# Off by default. Set to a random secret to enable; clients must send
|
||||
# the same value in the `X-API-Key` header.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# HOMEPAGE_API_KEY=
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: pouzor
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -16,14 +16,21 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- image: ghcr.io/pouzor/homelable-backend
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: "VITE_STANDALONE=true"
|
||||
- image: ghcr.io/pouzor/homelable-mcp
|
||||
context: ./mcp
|
||||
dockerfile: Dockerfile.mcp
|
||||
build_args: ""
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -55,8 +62,8 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
+276
@@ -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.
|
||||
+3
-2
@@ -2,13 +2,14 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||
# 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 iputils-ping curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
COPY VERSION /app/VERSION
|
||||
|
||||
# Create data directory (volume mount point)
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ .
|
||||
COPY VERSION ../VERSION
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve
|
||||
|
||||
@@ -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.
|
||||
@@ -1,13 +1,15 @@
|
||||
# Homelable
|
||||
|
||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
|
||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
||||
|
||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||
|
||||
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||
|
||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||
|
||||
If you are running <img width="35" height="35" align="middle" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -16,8 +18,9 @@ If you just like the design, you can only run the frontend and export your desig
|
||||
<p align="center">
|
||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
|
||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
||||
<img width="48%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/06caab68-6637-4dda-ab16-7e83f63d3972" />
|
||||
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -74,6 +77,38 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
||||
|
||||
---
|
||||
|
||||
## Zigbee2MQTT Import
|
||||
|
||||
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||
- **Zigbee2MQTT** connected to the broker with at least one device paired
|
||||
|
||||
### Usage
|
||||
|
||||
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
|
||||
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
|
||||
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
|
||||
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
|
||||
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
|
||||
|
||||
### Node Types
|
||||
|
||||
| Type | Z2M Device | Icon |
|
||||
|------|-----------|------|
|
||||
| `zigbee_coordinator` | Coordinator | Network hub |
|
||||
| `zigbee_router` | Router (mains-powered) | Radio |
|
||||
| `zigbee_enddevice` | End Device (battery) | Antenna |
|
||||
|
||||
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
|
||||
LQI (Link Quality Indicator) is stored as a node property.
|
||||
|
||||
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
|
||||
|
||||
---
|
||||
|
||||
## Live View (read-only public canvas)
|
||||
|
||||
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.
|
||||
@@ -99,7 +134,61 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration) (optionnal)
|
||||
## Gethomepage Widget (read-only stats)
|
||||
|
||||
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
|
||||
|
||||
### Activation
|
||||
|
||||
Add `HOMEPAGE_API_KEY` to your `.env`:
|
||||
|
||||
`HOMEPAGE_API_KEY=your-secret-key`
|
||||
|
||||
Restart the backend (`docker compose restart backend`).
|
||||
|
||||
### Endpoint
|
||||
|
||||
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": 12,
|
||||
"online": 9,
|
||||
"offline": 2,
|
||||
"unknown": 1,
|
||||
"pending_devices": 3,
|
||||
"zigbee_devices": 5,
|
||||
"last_scan_at": "2026-05-14T10:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### gethomepage `services.yaml` snippet
|
||||
|
||||
```yaml
|
||||
- Homelab:
|
||||
- Homelable:
|
||||
icon: mdi-lan
|
||||
href: http://homelable.local:3000
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://homelable.local:8000/api/v1/stats/summary
|
||||
method: GET
|
||||
headers:
|
||||
X-API-Key: your-secret-key
|
||||
mappings:
|
||||
- field: nodes ; label: Nodes
|
||||
- field: online ; label: Online
|
||||
- field: offline ; label: Offline
|
||||
- field: pending_devices ; label: Pending
|
||||
- field: zigbee_devices ; label: Zigbee
|
||||
- field: last_scan_at ; label: Last scan
|
||||
```
|
||||
|
||||
The backend port (`8000`) must be reachable from your gethomepage container.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration) (optional)
|
||||
|
||||
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.
|
||||
|
||||
@@ -134,6 +223,12 @@ docker compose up -d mcp
|
||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||
```
|
||||
|
||||
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
|
||||
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
|
||||
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
|
||||
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
|
||||
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
|
||||
|
||||
**3. Configure your AI client:**
|
||||
|
||||
**Claude Code** — run this command in your terminal:
|
||||
|
||||
@@ -25,6 +25,7 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,13 +68,14 @@ async def save_canvas(
|
||||
else:
|
||||
db.add(Edge(**edge_data.model_dump()))
|
||||
|
||||
# Upsert viewport
|
||||
# Upsert viewport + custom style
|
||||
state = await db.get(CanvasState, 1)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.custom_style = body.custom_style
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(id=1, viewport=body.viewport))
|
||||
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
@@ -34,8 +34,10 @@ async def liveview_canvas(
|
||||
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}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
)
|
||||
|
||||
+245
-15
@@ -11,10 +11,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import request_cancel, run_scan
|
||||
from app.services.zigbee_service import build_zigbee_properties
|
||||
|
||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
|
||||
|
||||
class ScanConfig(BaseModel):
|
||||
@@ -37,7 +44,15 @@ router = APIRouter()
|
||||
|
||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await run_scan(ranges, db, run_id)
|
||||
try:
|
||||
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)
|
||||
@@ -85,12 +100,10 @@ async def clear_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
await db.delete(device)
|
||||
from sqlalchemy import delete as sa_delete
|
||||
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
await db.commit()
|
||||
return {"deleted": len(devices)}
|
||||
return {"deleted": result.rowcount}
|
||||
|
||||
|
||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||
@@ -99,6 +112,114 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
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_type = device.suggested_type or "generic"
|
||||
is_zigbee = node_type in _ZIGBEE_TYPES
|
||||
node = Node(
|
||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||
type=node_type,
|
||||
ip=device.ip,
|
||||
hostname=device.hostname,
|
||||
status="online" if is_zigbee else "unknown",
|
||||
services=device.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=build_zigbee_properties(
|
||||
device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if is_zigbee else [],
|
||||
# Default to ping so the status checker actually polls the new node.
|
||||
# Without this the scheduler skips it (check_method NULL → no check).
|
||||
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||
)
|
||||
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]
|
||||
|
||||
all_edges: list[dict[str, str]] = []
|
||||
for device in devices:
|
||||
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"approved": len(node_ids),
|
||||
"node_ids": node_ids,
|
||||
"device_ids": approved_device_ids,
|
||||
"edges_created": len(all_edges),
|
||||
"edges": all_edges,
|
||||
"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}/restore", response_model=dict)
|
||||
async def restore_device(
|
||||
device_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if device.status != "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is not hidden")
|
||||
device.status = "pending"
|
||||
await db.commit()
|
||||
return {"restored": True, "device_id": device_id}
|
||||
|
||||
|
||||
@router.post("/pending/bulk-restore", response_model=dict)
|
||||
async def bulk_restore_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 == "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
device.status = "pending"
|
||||
await db.commit()
|
||||
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||
async def approve_device(
|
||||
device_id: str,
|
||||
@@ -107,13 +228,120 @@ async def approve_device(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if device:
|
||||
device.status = "approved"
|
||||
node = Node(**node_data.model_dump())
|
||||
db.add(node)
|
||||
await db.commit()
|
||||
return {"approved": True, "node_id": node.id}
|
||||
return {"approved": False}
|
||||
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"
|
||||
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
||||
node = Node(
|
||||
label=node_data.label,
|
||||
type=node_data.type,
|
||||
ip=node_data.ip,
|
||||
hostname=node_data.hostname,
|
||||
status="online" if _is_zigbee else node_data.status,
|
||||
services=node_data.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=build_zigbee_properties(
|
||||
device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if _is_zigbee else (node_data.properties or []),
|
||||
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if _is_zigbee else node_data.check_target,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
node_id = node.id
|
||||
|
||||
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"approved": True,
|
||||
"node_id": node_id,
|
||||
"edges_created": len(edges),
|
||||
"edges": edges,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_pending_links_for_ieee(
|
||||
db: AsyncSession, ieee: str | None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Materialize edges for any pending_device_links involving ``ieee``.
|
||||
|
||||
For each link where the other endpoint already exists as a canvas Node
|
||||
(matched by ``Node.ieee_address``), create the Edge and drop the link
|
||||
row. Links where the other endpoint is still pending are kept so they
|
||||
can resolve when that endpoint is approved later.
|
||||
"""
|
||||
if not ieee:
|
||||
return []
|
||||
|
||||
links_q = await db.execute(
|
||||
select(PendingDeviceLink).where(
|
||||
(PendingDeviceLink.source_ieee == ieee)
|
||||
| (PendingDeviceLink.target_ieee == ieee)
|
||||
)
|
||||
)
|
||||
links = list(links_q.scalars().all())
|
||||
if not links:
|
||||
return []
|
||||
|
||||
# Map every relevant ieee → Node (single query).
|
||||
other_ieees = {
|
||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||
for link in links
|
||||
}
|
||||
other_ieees.add(ieee)
|
||||
nodes_q = await db.execute(
|
||||
select(Node).where(Node.ieee_address.in_(other_ieees))
|
||||
)
|
||||
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
||||
|
||||
self_node = by_ieee.get(ieee)
|
||||
if self_node is None:
|
||||
return []
|
||||
|
||||
# Pre-fetch existing edges between these node ids so we don't create dups
|
||||
# if the user re-approves a device or had drawn the link manually.
|
||||
candidate_node_ids = [n.id for n in by_ieee.values()]
|
||||
existing_q = await db.execute(
|
||||
select(Edge).where(
|
||||
Edge.source.in_(candidate_node_ids),
|
||||
Edge.target.in_(candidate_node_ids),
|
||||
)
|
||||
)
|
||||
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
|
||||
|
||||
created: list[dict[str, str]] = []
|
||||
for link in links:
|
||||
other_ieee = (
|
||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||
)
|
||||
other_node = by_ieee.get(other_ieee)
|
||||
if other_node is None:
|
||||
continue
|
||||
if link.source_ieee == ieee:
|
||||
src_id, tgt_id = self_node.id, other_node.id
|
||||
else:
|
||||
src_id, tgt_id = other_node.id, self_node.id
|
||||
# Skip if either direction already exists.
|
||||
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
||||
await db.delete(link)
|
||||
continue
|
||||
edge = Edge(
|
||||
source=src_id,
|
||||
target=tgt_id,
|
||||
type="iot",
|
||||
source_handle="bottom",
|
||||
target_handle="top-t",
|
||||
)
|
||||
db.add(edge)
|
||||
await db.flush()
|
||||
existing_pairs.add((src_id, tgt_id))
|
||||
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
|
||||
await db.delete(link)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/hide")
|
||||
@@ -153,10 +381,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||
|
||||
@router.post("/config", response_model=ScanConfig)
|
||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||
previous = settings.scanner_ranges
|
||||
settings.scanner_ranges = payload.ranges
|
||||
try:
|
||||
settings.scanner_ranges = payload.ranges
|
||||
settings.save_overrides()
|
||||
return payload
|
||||
except Exception as 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,64 @@
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy import func, 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 Node, PendingDevice, ScanRun
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_key(x_api_key: str | None) -> None:
|
||||
if not settings.homepage_api_key:
|
||||
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
|
||||
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary(
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
"""Read-only stats payload for the gethomepage `customapi` widget.
|
||||
|
||||
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
|
||||
value in the `X-API-Key` header.
|
||||
"""
|
||||
_check_key(x_api_key)
|
||||
|
||||
status_rows = (
|
||||
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
||||
).all()
|
||||
counts = {row[0]: row[1] for row in status_rows}
|
||||
|
||||
pending = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PendingDevice)
|
||||
.where(PendingDevice.status == "pending")
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
zigbee = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
last_scan_at = (
|
||||
await db.execute(select(func.max(ScanRun.finished_at)))
|
||||
).scalar_one()
|
||||
|
||||
return {
|
||||
"nodes": sum(counts.values()),
|
||||
"online": counts.get("online", 0),
|
||||
"offline": counts.get("offline", 0),
|
||||
"unknown": counts.get("unknown", 0),
|
||||
"pending_devices": pending,
|
||||
"zigbee_devices": zigbee,
|
||||
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"""FastAPI router for Zigbee2MQTT import."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeCoordinatorOut,
|
||||
ZigbeeEdgeOut,
|
||||
ZigbeeImportPendingResponse,
|
||||
ZigbeeImportRequest,
|
||||
ZigbeeImportResponse,
|
||||
ZigbeeNodeOut,
|
||||
ZigbeeTestConnectionRequest,
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
fetch_networkmap,
|
||||
merge_zigbee_properties,
|
||||
test_mqtt_connection,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZigbeeImportResponse)
|
||||
async def import_zigbee_network(
|
||||
payload: ZigbeeImportRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeImportResponse:
|
||||
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||
|
||||
Connects to the specified MQTT broker, publishes a networkmap request to
|
||||
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
|
||||
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
|
||||
coordinator → router → end-device hierarchy.
|
||||
"""
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_networkmap(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
base_topic=payload.base_topic,
|
||||
username=payload.mqtt_username,
|
||||
password=payload.mqtt_password,
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
except ConnectionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Zigbee import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
||||
|
||||
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
||||
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
||||
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||
|
||||
|
||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||
async def import_zigbee_to_pending(
|
||||
payload: ZigbeeImportRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Queue a Zigbee2MQTT pending import as a background scan run.
|
||||
|
||||
Returns the ScanRun row immediately so the UI can close the import
|
||||
modal and surface progress under Scan History (kind=zigbee). The
|
||||
actual MQTT fetch + pending upsert happens in the background.
|
||||
"""
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="zigbee",
|
||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
||||
return run
|
||||
|
||||
|
||||
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_networkmap(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
base_topic=payload.base_topic,
|
||||
username=payload.mqtt_username,
|
||||
password=payload.mqtt_password,
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "done"
|
||||
run.devices_found = result.device_count
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.exception("Zigbee import %s failed", run_id)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "error"
|
||||
run.error = str(exc)[:500]
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _persist_pending_import(
|
||||
db: AsyncSession,
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZigbeeImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
||||
All zigbee-source links are wiped and re-inserted from the new map.
|
||||
"""
|
||||
coordinator_out: ZigbeeCoordinatorOut | None = None
|
||||
coordinator_existed = False
|
||||
pending_created = 0
|
||||
pending_updated = 0
|
||||
|
||||
for n in nodes_raw:
|
||||
ieee = n.get("ieee_address")
|
||||
if not ieee:
|
||||
continue
|
||||
props = build_zigbee_properties(
|
||||
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
||||
)
|
||||
|
||||
if n.get("device_type") == "Coordinator":
|
||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
||||
existing_node = existing.scalar_one_or_none()
|
||||
if existing_node:
|
||||
existing_node.properties = merge_zigbee_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
coordinator_out = ZigbeeCoordinatorOut(
|
||||
id=existing_node.id,
|
||||
label=existing_node.label,
|
||||
ieee_address=ieee,
|
||||
)
|
||||
coordinator_existed = True
|
||||
continue
|
||||
label = n.get("friendly_name") or ieee
|
||||
node = Node(
|
||||
label=label,
|
||||
type=n.get("type") or "zigbee_coordinator",
|
||||
status="online",
|
||||
check_method="none",
|
||||
ieee_address=ieee,
|
||||
services=[],
|
||||
properties=props,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
coordinator_out = ZigbeeCoordinatorOut(
|
||||
id=node.id, label=label, ieee_address=ieee
|
||||
)
|
||||
continue
|
||||
|
||||
# If the device has already been approved as a canvas Node, refresh
|
||||
# its properties and skip creating a pending row (keeps approved
|
||||
# devices out of pending/hidden modals on re-import).
|
||||
existing_node_q = await db.execute(
|
||||
select(Node).where(Node.ieee_address == ieee)
|
||||
)
|
||||
existing_node = existing_node_q.scalar_one_or_none()
|
||||
if existing_node:
|
||||
existing_node.properties = merge_zigbee_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
continue
|
||||
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
suggested_type=n.get("type"),
|
||||
device_subtype=n.get("device_type"),
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
pending_created += 1
|
||||
else:
|
||||
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
|
||||
pending.suggested_type = n.get("type") or pending.suggested_type
|
||||
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
||||
pending.model = n.get("model") or pending.model
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if n.get("lqi") is not None:
|
||||
pending.lqi = n.get("lqi")
|
||||
if pending.status == "hidden":
|
||||
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||
pass
|
||||
pending_updated += 1
|
||||
|
||||
# Replace all zigbee-source links with the freshly discovered set.
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for e in edges_raw:
|
||||
src = e.get("source")
|
||||
tgt = e.get("target")
|
||||
if not src or not tgt or (src, tgt) in seen:
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
PendingDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
links_recorded += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ZigbeeImportPendingResponse(
|
||||
pending_created=pending_created,
|
||||
pending_updated=pending_updated,
|
||||
coordinator=coordinator_out,
|
||||
coordinator_already_existed=coordinator_existed,
|
||||
links_recorded=links_recorded,
|
||||
device_count=len(nodes_raw),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
|
||||
async def test_zigbee_connection(
|
||||
payload: ZigbeeTestConnectionRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeTestConnectionResponse:
|
||||
"""Quick MQTT ping to validate broker connection before importing."""
|
||||
try:
|
||||
await test_mqtt_connection(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
username=payload.mqtt_username,
|
||||
password=payload.mqtt_password,
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
except (ConnectionError, TimeoutError) as exc:
|
||||
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during connection test")
|
||||
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
||||
@@ -7,6 +7,17 @@ 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):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
@@ -50,6 +61,11 @@ class Settings(BaseSettings):
|
||||
# Leave unset (or empty) to keep the feature disabled (default).
|
||||
liveview_key: str | None = None
|
||||
|
||||
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
||||
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
||||
# Leave empty to keep the feature disabled (default).
|
||||
homepage_api_key: str = ""
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not plain or not hashed:
|
||||
return False
|
||||
try:
|
||||
return bool(pwd_context.verify(plain, hashed))
|
||||
except ValueError:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return str(pwd_context.hash(password))
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
|
||||
+111
-2
@@ -1,12 +1,33 @@
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.config import APP_VERSION, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
|
||||
"""Run an idempotent migration statement, logging any error.
|
||||
|
||||
Distinguishes 'already applied' errors (debug) from genuine failures
|
||||
(warning) so silent corruption is avoided. Used for new in-commit
|
||||
migrations; existing legacy ALTERs above remain wrapped in suppress.
|
||||
"""
|
||||
try:
|
||||
await conn.exec_driver_sql(sql)
|
||||
except OperationalError as exc:
|
||||
msg = str(exc).lower()
|
||||
if "duplicate column" in msg or "already exists" in msg:
|
||||
logger.debug("Migration %s skipped (already applied): %s", label, exc)
|
||||
else:
|
||||
logger.warning("Migration %s failed: %s", label, exc)
|
||||
|
||||
# Ensure the data directory exists before SQLite tries to open the file
|
||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -23,7 +44,22 @@ class Base(DeclarativeBase):
|
||||
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:
|
||||
_backup_db()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns introduced after initial schema (idempotent)
|
||||
@@ -61,10 +97,83 @@ async def init_db() -> None:
|
||||
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 scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
|
||||
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
|
||||
zigbee_migrations: list[tuple[str, str]] = [
|
||||
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
|
||||
(
|
||||
"nodes.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
|
||||
),
|
||||
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
|
||||
(
|
||||
"pending_devices.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||
"ON pending_devices(ieee_address)",
|
||||
),
|
||||
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
|
||||
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
|
||||
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
|
||||
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
|
||||
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
|
||||
]
|
||||
for label, sql in zigbee_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
|
||||
# SQLite can't ALTER column nullability — rebuild the table if needed.
|
||||
try:
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
|
||||
cols = info.fetchall()
|
||||
ip_col = next((c for c in cols if c[1] == "ip"), None)
|
||||
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
|
||||
if ip_col and ip_col[3] == 1:
|
||||
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE pending_devices_new ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"ip VARCHAR,"
|
||||
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
||||
"suggested_type VARCHAR,"
|
||||
"status VARCHAR,"
|
||||
"discovery_source VARCHAR,"
|
||||
"ieee_address VARCHAR,"
|
||||
"friendly_name VARCHAR,"
|
||||
"device_subtype VARCHAR,"
|
||||
"model VARCHAR,"
|
||||
"vendor VARCHAR,"
|
||||
"lqi INTEGER,"
|
||||
"discovered_at DATETIME"
|
||||
")"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO pending_devices_new "
|
||||
"(id, ip, mac, hostname, os, services, suggested_type, status, "
|
||||
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
||||
"model, vendor, lqi, discovered_at) "
|
||||
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
|
||||
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
||||
"model, vendor, lqi, discovered_at FROM pending_devices"
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE pending_devices")
|
||||
await conn.exec_driver_sql(
|
||||
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||
"ON pending_devices(ieee_address)"
|
||||
)
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
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")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
|
||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||
with suppress(OperationalError):
|
||||
rows = await conn.exec_driver_sql(
|
||||
|
||||
@@ -46,6 +46,7 @@ class Node(Base):
|
||||
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)
|
||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
@@ -78,6 +79,7 @@ class CanvasState(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
@@ -85,7 +87,7 @@ class PendingDevice(Base):
|
||||
__tablename__ = "pending_devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
ip: Mapped[str] = mapped_column(String, nullable=False)
|
||||
ip: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
os: Mapped[str | None] = mapped_column(String)
|
||||
@@ -93,6 +95,31 @@ class PendingDevice(Base):
|
||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||
status: Mapped[str] = mapped_column(String, default="pending")
|
||||
discovery_source: Mapped[str | None] = mapped_column(String)
|
||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
|
||||
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class PendingDeviceLink(Base):
|
||||
"""Link between two Zigbee endpoints discovered during import.
|
||||
|
||||
Endpoints are addressed by IEEE (stable across re-imports). Either side may
|
||||
already exist as a canvas Node (resolved via Node.ieee_address) or still be
|
||||
a PendingDevice. On approval, the matching Edge is auto-created when both
|
||||
endpoints exist as canvas Nodes.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_device_links"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
@@ -101,6 +128,7 @@ class ScanRun(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
status: Mapped[str] = mapped_column(String, default="running")
|
||||
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
|
||||
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
@@ -55,6 +55,8 @@ 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(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
|
||||
nodes: list[NodeSave] = []
|
||||
edges: list[EdgeSave] = []
|
||||
viewport: dict[str, Any] = {}
|
||||
custom_style: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CanvasStateResponse(BaseModel):
|
||||
nodes: list[NodeResponse]
|
||||
edges: list[EdgeResponse]
|
||||
viewport: dict[str, Any]
|
||||
custom_style: dict[str, Any] | None = None
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
||||
|
||||
class PendingDeviceResponse(BaseModel):
|
||||
id: str
|
||||
ip: str
|
||||
ip: str | None
|
||||
mac: str | None
|
||||
hostname: str | None
|
||||
os: str | None
|
||||
@@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel):
|
||||
suggested_type: str | None
|
||||
status: str
|
||||
discovery_source: str | None
|
||||
ieee_address: str | None = None
|
||||
friendly_name: str | None = None
|
||||
device_subtype: str | None = None
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
lqi: int | None = None
|
||||
discovered_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -22,6 +28,7 @@ class PendingDeviceResponse(BaseModel):
|
||||
class ScanRunResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
kind: str = "ip"
|
||||
ranges: list[str]
|
||||
devices_found: int
|
||||
started_at: datetime
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pydantic v2 schemas for Zigbee2MQTT import."""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class ZigbeeImportRequest(BaseModel):
|
||||
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
||||
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
||||
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
||||
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
|
||||
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
||||
mqtt_tls_insecure: bool = Field(
|
||||
False, description="Skip TLS certificate verification (self-signed only)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
|
||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||
return self
|
||||
|
||||
|
||||
class ZigbeeTestConnectionRequest(BaseModel):
|
||||
mqtt_host: str
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||
mqtt_username: str | None = None
|
||||
mqtt_password: str | None = None
|
||||
mqtt_tls: bool = False
|
||||
mqtt_tls_insecure: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
|
||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||
return self
|
||||
|
||||
|
||||
class ZigbeeDeviceData(BaseModel):
|
||||
ieee_address: str
|
||||
friendly_name: str
|
||||
device_type: str # Coordinator, Router, EndDevice
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
description: str | None = None
|
||||
lqi: int | None = None
|
||||
last_seen: str | None = None
|
||||
|
||||
|
||||
class ZigbeeNodeOut(BaseModel):
|
||||
"""A homelable-ready node representation of a Zigbee device."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
|
||||
ieee_address: str
|
||||
friendly_name: str
|
||||
device_type: str
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
lqi: int | None = None
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class ZigbeeEdgeOut(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class ZigbeeImportResponse(BaseModel):
|
||||
nodes: list[ZigbeeNodeOut]
|
||||
edges: list[ZigbeeEdgeOut]
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZigbeeTestConnectionResponse(BaseModel):
|
||||
connected: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ZigbeeCoordinatorOut(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
ieee_address: str
|
||||
|
||||
|
||||
class ZigbeeImportPendingResponse(BaseModel):
|
||||
"""Result of importing a Z2M network into the pending section."""
|
||||
|
||||
pending_created: int
|
||||
pending_updated: int
|
||||
coordinator: ZigbeeCoordinatorOut | None = None
|
||||
coordinator_already_existed: bool = False
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
@@ -19,9 +19,16 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
if check_method == "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:
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
|
||||
# against arg-injection if a malicious admin sets target like "-O".
|
||||
if host.startswith("-"):
|
||||
logger.warning("Rejecting check target that starts with '-': %r", host)
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
@@ -58,8 +65,14 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
# ping(8) -W flag units differ by OS:
|
||||
# Linux: seconds (-W 1 = 1s)
|
||||
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
||||
# Windows: -w in ms (-w 1000 = 1s)
|
||||
if sys.platform == "win32":
|
||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||
elif sys.platform == "darwin":
|
||||
args = ["ping", "-c", "1", "-W", "1000", host]
|
||||
else:
|
||||
args = ["ping", "-c", "1", "-W", "1", host]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import aiomqtt
|
||||
except ImportError: # pragma: no cover
|
||||
aiomqtt = None # type: ignore[assignment]
|
||||
|
||||
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
||||
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||
"""Return a generic, credential-free message for an MQTT error.
|
||||
|
||||
The raw aiomqtt/paho error string can include the broker URI with
|
||||
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||
detail that should not leak to API clients. Map known patterns to
|
||||
coarse categories; default to a generic failure message. The original
|
||||
exception is logged at WARNING level for operator debugging.
|
||||
"""
|
||||
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||
raw = str(exc).lower()
|
||||
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||
return "Authentication failed"
|
||||
if "refused" in raw:
|
||||
return "Connection refused by broker"
|
||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||
return "Broker hostname could not be resolved"
|
||||
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||
return "TLS handshake failed"
|
||||
if "timed out" in raw or "timeout" in raw:
|
||||
return "Connection to broker timed out"
|
||||
return "MQTT connection failed"
|
||||
|
||||
|
||||
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||
ctx = ssl.create_default_context()
|
||||
if insecure:
|
||||
logger.warning(
|
||||
"MQTT TLS certificate verification is DISABLED — "
|
||||
"use only with self-signed brokers on trusted networks."
|
||||
)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def build_zigbee_properties(
|
||||
ieee: str | None,
|
||||
vendor: str | None,
|
||||
model: str | None,
|
||||
lqi: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
|
||||
|
||||
Only includes a row when the value is non-empty. Shape matches the
|
||||
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
|
||||
|
||||
New props default to ``visible=False`` — users opt in to showing them on
|
||||
the canvas card from the right panel.
|
||||
"""
|
||||
props: list[dict[str, Any]] = []
|
||||
if ieee:
|
||||
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
|
||||
if vendor:
|
||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||
if model:
|
||||
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||
if lqi is not None:
|
||||
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
|
||||
return props
|
||||
|
||||
|
||||
def merge_zigbee_properties(
|
||||
existing: list[dict[str, Any]] | None,
|
||||
new_props: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge fresh zigbee props into an existing property list.
|
||||
|
||||
For keys already present: update ``value`` but preserve the user's
|
||||
``visible`` choice. New keys are appended with whatever visibility the
|
||||
caller gave them (hidden by default per ``build_zigbee_properties``).
|
||||
Non-zigbee custom properties are preserved untouched.
|
||||
"""
|
||||
out = [dict(p) for p in (existing or [])]
|
||||
by_key = {p.get("key"): p for p in out}
|
||||
for np in new_props:
|
||||
key = np.get("key")
|
||||
if key in by_key:
|
||||
by_key[key]["value"] = np.get("value")
|
||||
else:
|
||||
out.append(dict(np))
|
||||
return out
|
||||
|
||||
|
||||
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||
"""Map a Z2M device type string to a homelable node type."""
|
||||
mapping = {
|
||||
"Coordinator": "zigbee_coordinator",
|
||||
"Router": "zigbee_router",
|
||||
"EndDevice": "zigbee_enddevice",
|
||||
}
|
||||
return mapping.get(device_type, "zigbee_enddevice")
|
||||
|
||||
|
||||
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
|
||||
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
|
||||
if not ieee:
|
||||
return None
|
||||
device_type: str = raw.get("type") or "EndDevice"
|
||||
friendly_name: str = (
|
||||
raw.get("friendlyName") or raw.get("friendly_name") or ieee
|
||||
)
|
||||
definition: dict[str, Any] = raw.get("definition") or {}
|
||||
model: str | None = (
|
||||
raw.get("modelID")
|
||||
or raw.get("model")
|
||||
or definition.get("model")
|
||||
or None
|
||||
)
|
||||
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": friendly_name,
|
||||
"type": _z2m_type_to_homelable(device_type),
|
||||
"ieee_address": ieee,
|
||||
"friendly_name": friendly_name,
|
||||
"device_type": device_type,
|
||||
"model": model,
|
||||
"vendor": vendor,
|
||||
"lqi": None,
|
||||
"parent_id": None,
|
||||
}
|
||||
|
||||
|
||||
def parse_networkmap(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
|
||||
|
||||
Z2M raw response shape::
|
||||
|
||||
{
|
||||
"data": {
|
||||
"type": "raw",
|
||||
"routes": false,
|
||||
"value": {
|
||||
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
|
||||
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
|
||||
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
|
||||
"lqi": 200, "depth": 1}]
|
||||
}
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
Older or alternate shapes may put nodes/links directly under ``data``.
|
||||
Both are accepted.
|
||||
"""
|
||||
data: dict[str, Any] = payload.get("data") or {}
|
||||
value = data.get("value")
|
||||
container: dict[str, Any] = value if isinstance(value, dict) else data
|
||||
|
||||
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
|
||||
raw_links: list[dict[str, Any]] = container.get("links") or []
|
||||
|
||||
if not isinstance(raw_nodes, list):
|
||||
raise ValueError("Malformed networkmap: 'nodes' is not a list")
|
||||
if not isinstance(raw_links, list):
|
||||
raise ValueError("Malformed networkmap: 'links' is not a list")
|
||||
|
||||
nodes_list: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
coordinator_id: str | None = None
|
||||
|
||||
for entry in raw_nodes:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
node = _node_from_z2m(entry)
|
||||
if node is None or node["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(node["id"])
|
||||
nodes_list.append(node)
|
||||
if node["device_type"] == "Coordinator":
|
||||
coordinator_id = node["id"]
|
||||
|
||||
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
|
||||
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
|
||||
# resolve which router an end device hangs off; do NOT emit edges directly
|
||||
# from links. The final edge set is the strict parent→child tree built
|
||||
# from parent_id below — that avoids duplicate edges and keeps the visual
|
||||
# flow consistent (parent bottom → child top).
|
||||
raw_edges: list[dict[str, Any]] = []
|
||||
lqi_by_id: dict[str, int] = {}
|
||||
|
||||
for link in raw_links:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
src_obj = link.get("source") or {}
|
||||
tgt_obj = link.get("target") or {}
|
||||
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
|
||||
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
|
||||
if not src or not tgt:
|
||||
continue
|
||||
if src not in seen_ids or tgt not in seen_ids:
|
||||
continue
|
||||
raw_edges.append({"source": src, "target": tgt})
|
||||
lqi = link.get("lqi") or link.get("linkquality")
|
||||
if isinstance(lqi, int) and tgt not in lqi_by_id:
|
||||
lqi_by_id[tgt] = lqi
|
||||
|
||||
for node in nodes_list:
|
||||
if node["id"] in lqi_by_id:
|
||||
node["lqi"] = lqi_by_id[node["id"]]
|
||||
|
||||
# Build parent_id hierarchy: coordinator → routers → end devices
|
||||
if coordinator_id:
|
||||
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
|
||||
for node in nodes_list:
|
||||
if node["device_type"] == "Router":
|
||||
node["parent_id"] = coordinator_id
|
||||
elif node["device_type"] == "EndDevice":
|
||||
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||
node["parent_id"] = parent or coordinator_id
|
||||
|
||||
# Final edges = strict parent → child tree (one edge per non-coordinator)
|
||||
edges_list: list[dict[str, Any]] = [
|
||||
{"source": node["parent_id"], "target": node["id"]}
|
||||
for node in nodes_list
|
||||
if node.get("parent_id")
|
||||
]
|
||||
|
||||
return nodes_list, edges_list
|
||||
|
||||
|
||||
def _find_parent_router(
|
||||
device_id: str,
|
||||
router_ids: set[str],
|
||||
edges: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Return the first router that has a direct edge to device_id."""
|
||||
for edge in edges:
|
||||
src: str = edge["source"]
|
||||
tgt: str = edge["target"]
|
||||
if tgt == device_id and src in router_ids:
|
||||
return src
|
||||
if src == device_id and tgt in router_ids:
|
||||
return tgt
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_networkmap(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
base_topic: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the broker does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is malformed.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError(
|
||||
"aiomqtt is required for Zigbee import. "
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||
|
||||
response_payload: dict[str, Any] = {}
|
||||
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=_CONNECTION_TIMEOUT,
|
||||
tls_context=tls_context,
|
||||
) as client:
|
||||
await client.subscribe(response_topic)
|
||||
# Give the broker a brief window to register the subscription
|
||||
# before we publish the request. Without this, brokers that
|
||||
# race SUBACK with our PUBLISH may deliver the response before
|
||||
# the subscription is active and we'd hang until timeout.
|
||||
await asyncio.sleep(0.1)
|
||||
await client.publish(
|
||||
request_topic,
|
||||
json.dumps({"type": "raw", "routes": False}),
|
||||
)
|
||||
|
||||
async def _wait_for_response() -> None:
|
||||
async for message in client.messages:
|
||||
if str(message.topic) != response_topic:
|
||||
continue
|
||||
raw = message.payload
|
||||
try:
|
||||
payload_str = (
|
||||
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||
)
|
||||
response_payload.update(json.loads(payload_str))
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f"Malformed networkmap response: {exc}"
|
||||
) from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Timed out waiting for networkmap response") from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty networkmap response received")
|
||||
|
||||
return parse_networkmap(response_payload)
|
||||
|
||||
|
||||
async def test_mqtt_connection(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> bool:
|
||||
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||
|
||||
Returns True on success, raises ConnectionError on failure.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError("aiomqtt is required")
|
||||
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=_CONNECTION_TIMEOUT,
|
||||
tls_context=tls_context,
|
||||
):
|
||||
return True
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Connection to broker timed out") from exc
|
||||
@@ -2,3 +2,4 @@
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
scan_config.json
|
||||
homelab.db.*
|
||||
|
||||
@@ -7,9 +7,8 @@ alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.26
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.27
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
pyyaml==6.0.2
|
||||
@@ -17,6 +16,7 @@ types-PyYAML==6.0.12.20240917
|
||||
websockets==13.1
|
||||
httpx==0.27.2
|
||||
zeroconf==0.131.0
|
||||
aiomqtt==2.3.0
|
||||
|
||||
# Dev
|
||||
ruff==0.6.9
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Generate a bcrypt password hash for config.yml."""
|
||||
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
|
||||
import sys
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
import bcrypt
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python scripts/hash_password.py <password>")
|
||||
sys.exit(1)
|
||||
|
||||
password = sys.argv[1]
|
||||
print(pwd_context.hash(password))
|
||||
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
|
||||
|
||||
@@ -5,23 +5,21 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def test_credentials():
|
||||
"""Configure test auth credentials directly on settings."""
|
||||
from app.core.config import settings
|
||||
settings.auth_username = "admin"
|
||||
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
||||
settings.auth_password_hash = hash_password("admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -68,3 +68,71 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
settings.auth_password_hash = original
|
||||
|
||||
|
||||
# --- JWT-level cases ---
|
||||
|
||||
async def test_expired_token_rejected(client: AsyncClient):
|
||||
"""A JWT whose `exp` is in the past must be refused."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"sub": "admin",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
}
|
||||
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_malformed_token_rejected(client: AsyncClient):
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||
"""A token signed with a different key must not be accepted."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"sub": "admin",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_missing_authorization_header_rejected(client: AsyncClient):
|
||||
res = await client.get("/api/v1/nodes")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
|
||||
"""No credentials configured server-side must not authenticate an empty password."""
|
||||
from app.core.config import settings
|
||||
original_hash = settings.auth_password_hash
|
||||
settings.auth_password_hash = ""
|
||||
try:
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
settings.auth_password_hash = original_hash
|
||||
|
||||
|
||||
# --- Password helper ---
|
||||
|
||||
def test_verify_password_handles_empty_inputs():
|
||||
"""verify_password must be safe against empty plain / empty hash without raising."""
|
||||
from app.core.security import hash_password, verify_password
|
||||
h = hash_password("hunter2")
|
||||
assert verify_password("hunter2", h) is True
|
||||
assert verify_password("", h) is False
|
||||
assert verify_password("hunter2", "") is False
|
||||
assert verify_password("", "") is False
|
||||
|
||||
@@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head
|
||||
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)
|
||||
@@ -548,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["label"] == "updated"
|
||||
assert edge["custom_color"] == "#ff0000"
|
||||
|
||||
|
||||
# ── custom_style ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
|
||||
custom_style = {
|
||||
"nodes": {
|
||||
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
|
||||
},
|
||||
"edges": {
|
||||
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
|
||||
},
|
||||
}
|
||||
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
|
||||
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
assert res.status_code == 200
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["custom_style"] is not None
|
||||
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
|
||||
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
|
||||
|
||||
|
||||
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/canvas", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["custom_style"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
|
||||
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert "proxmox" in canvas["custom_style"]["nodes"]
|
||||
assert "server" not in canvas["custom_style"]["nodes"]
|
||||
|
||||
@@ -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()
|
||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
assert nodes[0]["label"] == "Live Node"
|
||||
|
||||
|
||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
payload = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
res = await client.get("/api/v1/liveview?key=test-key")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["viewport"].get("theme_id") == "matrix"
|
||||
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||
|
||||
|
||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
+542
-2
@@ -37,6 +37,95 @@ async def pending_device(db_session):
|
||||
return device
|
||||
|
||||
|
||||
# --- _background_scan error handling ---
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db():
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.database import Base
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||
session rollback path must execute without a follow-on exception."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||
must NOT overwrite that terminal status with 'failed'."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
mock_run_scan.assert_awaited_once()
|
||||
|
||||
|
||||
# --- Trigger scan ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -120,8 +209,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["approved"] is False
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# --- Hide device ---
|
||||
@@ -141,6 +229,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
|
||||
assert len(hidden_res.json()) == 1
|
||||
|
||||
|
||||
# --- Restore hidden device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
||||
# Hide first
|
||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
|
||||
# Restore
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["restored"] is True
|
||||
|
||||
# Now back in pending, gone from hidden
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert len(pending_res.json()) == 1
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert hidden_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
||||
# Hide
|
||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-restore",
|
||||
headers=headers,
|
||||
json={"device_ids": [pending_device.id]},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["restored"] == 1
|
||||
assert res.json()["skipped"] == 0
|
||||
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert len(pending_res.json()) == 1
|
||||
|
||||
|
||||
# --- Ignore device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -444,3 +575,412 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
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.fixture
|
||||
async def zigbee_pending_device(db_session):
|
||||
device = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=None,
|
||||
mac=None,
|
||||
hostname=None,
|
||||
friendly_name="bulb_1",
|
||||
services=[],
|
||||
suggested_type="zigbee_enddevice",
|
||||
device_subtype="EndDevice",
|
||||
ieee_address="0xABCDEF",
|
||||
vendor="IKEA",
|
||||
model="TRADFRI",
|
||||
lqi=180,
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_device_populates_properties(
|
||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||
):
|
||||
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
payload = {
|
||||
"label": "bulb_1",
|
||||
"type": "zigbee_enddevice",
|
||||
"status": "online",
|
||||
"services": [],
|
||||
"check_method": "none",
|
||||
}
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
assert keys == {
|
||||
"IEEE": "0xABCDEF",
|
||||
"Vendor": "IKEA",
|
||||
"Model": "TRADFRI",
|
||||
"LQI": "180",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_zigbee_populates_properties(
|
||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [zigbee_pending_device.id]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
assert keys["IEEE"] == "0xABCDEF"
|
||||
assert keys["Vendor"] == "IKEA"
|
||||
assert keys["Model"] == "TRADFRI"
|
||||
assert keys["LQI"] == "180"
|
||||
assert node.check_method == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
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
|
||||
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
||||
for n in nodes:
|
||||
if n.ip:
|
||||
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||
assert node is not None
|
||||
assert node.check_method == "ping"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _seed_zigbee_pending_pair(db_session):
|
||||
"""Create a coordinator Node + a pending device + a link between them."""
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink
|
||||
|
||||
coord = Node(
|
||||
label="Coordinator",
|
||||
type="zigbee_coordinator",
|
||||
status="unknown",
|
||||
ieee_address="0xCOORD",
|
||||
)
|
||||
db_session.add(coord)
|
||||
|
||||
pending = PendingDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
device_subtype="Router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(pending)
|
||||
|
||||
db_session.add(
|
||||
PendingDeviceLink(
|
||||
source_ieee="0xCOORD",
|
||||
target_ieee="0xR1",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return coord, pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending.id}/approve",
|
||||
json={
|
||||
"label": "router_1",
|
||||
"type": "zigbee_router",
|
||||
"ip": None,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] is True
|
||||
assert data["edges_created"] == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
assert edges[0].source == coord.id
|
||||
assert edges[0].target == data["node_id"]
|
||||
assert edges[0].source_handle == "bottom"
|
||||
assert edges[0].target_handle == "top-t"
|
||||
assert edges[0].type == "iot"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_skips_duplicate_edge(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Re-running the resolution does not create a second edge for the same pair."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
|
||||
|
||||
# Simulate a second pending row + link between same coord and a new device,
|
||||
# but keep an existing edge in place to verify dedupe also handles
|
||||
# the swapped-direction case.
|
||||
new_pending = PendingDevice(
|
||||
ieee_address="0xR1B",
|
||||
friendly_name="r1b",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(new_pending)
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
|
||||
)
|
||||
assert res.json()["edges_created"] == 1 # only the new pair
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 2 # original + new, no duplicate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{a.id}/approve",
|
||||
json={
|
||||
"label": "a",
|
||||
"type": "zigbee_router",
|
||||
"ip": None,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["edges_created"] == 0
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert edges == []
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # preserved for later resolution
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""First approval keeps link; second approval creates the edge."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
|
||||
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
|
||||
assert res.json()["edges_created"] == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
assert links == [] # consumed
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_homepage_key():
|
||||
original = settings.homepage_api_key
|
||||
settings.homepage_api_key = ""
|
||||
yield
|
||||
settings.homepage_api_key = original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/stats/summary")
|
||||
assert res.status_code == 403
|
||||
assert "disabled" in res.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get("/api/v1/stats/summary")
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_empty_db(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body == {
|
||||
"nodes": 0,
|
||||
"online": 0,
|
||||
"offline": 0,
|
||||
"unknown": 0,
|
||||
"pending_devices": 0,
|
||||
"zigbee_devices": 0,
|
||||
"last_scan_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_aggregates_counts(
|
||||
client: AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||
db_session.add_all([
|
||||
Node(type="server", label="A", status="online"),
|
||||
Node(type="server", label="B", status="online"),
|
||||
Node(type="server", label="C", status="offline"),
|
||||
Node(type="server", label="D", status="unknown"),
|
||||
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
||||
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
||||
PendingDevice(ip="10.0.0.1", status="pending"),
|
||||
PendingDevice(ip="10.0.0.2", status="pending"),
|
||||
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||
ScanRun(status="success", finished_at=finished),
|
||||
ScanRun(status="success",
|
||||
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
||||
])
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["nodes"] == 6
|
||||
assert body["online"] == 4
|
||||
assert body["offline"] == 1
|
||||
assert body["unknown"] == 1
|
||||
assert body["pending_devices"] == 2
|
||||
assert body["zigbee_devices"] == 2
|
||||
# SQLite returns naive datetimes; compare prefix only.
|
||||
assert body["last_scan_at"] is not None
|
||||
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|
||||
@@ -169,6 +169,31 @@ async def test_ping_uses_unix_args_on_non_windows():
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
assert "-n" not in captured["args"]
|
||||
# Linux: -W is in seconds; 1s is the intended timeout
|
||||
w_idx = captured["args"].index("-W")
|
||||
assert captured["args"][w_idx + 1] == "1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_uses_macos_millisecond_timeout():
|
||||
"""macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
|
||||
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", "darwin"), \
|
||||
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"]
|
||||
w_idx = captured["args"].index("-W")
|
||||
assert captured["args"][w_idx + 1] == "1000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -191,6 +216,31 @@ async def test_ping_uses_windows_args_on_win32():
|
||||
assert "-c" not in captured["args"]
|
||||
|
||||
|
||||
# --- check_node target validation ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_rejects_flag_like_target():
|
||||
"""A target starting with '-' must never reach subprocess invocation."""
|
||||
from app.services.status_checker import check_node
|
||||
|
||||
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
||||
result = await check_node("ping", "-O", None)
|
||||
|
||||
mock_exec.assert_not_called()
|
||||
assert result["status"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_rejects_flag_like_ip():
|
||||
from app.services.status_checker import check_node
|
||||
|
||||
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
||||
result = await check_node("ping", None, "-O")
|
||||
|
||||
mock_exec.assert_not_called()
|
||||
assert result["status"] == "unknown"
|
||||
|
||||
|
||||
# --- _tcp_connect ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
"""API endpoint tests for /api/v1/zigbee/*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zigbee/test-connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||
mock_conn.return_value = True
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/test-connection",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["connected"] is True
|
||||
assert "success" in data["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/test-connection",
|
||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["connected"] is False
|
||||
assert "refused" in data["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/test-connection",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/test-connection",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422 # pydantic validation error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zigbee/import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLE_NODES = [
|
||||
{
|
||||
"id": "0x00000000",
|
||||
"label": "Coordinator",
|
||||
"type": "zigbee_coordinator",
|
||||
"ieee_address": "0x00000000",
|
||||
"friendly_name": "Coordinator",
|
||||
"device_type": "Coordinator",
|
||||
"model": None,
|
||||
"vendor": None,
|
||||
"lqi": None,
|
||||
"parent_id": None,
|
||||
},
|
||||
{
|
||||
"id": "0x00000001",
|
||||
"label": "router_1",
|
||||
"type": "zigbee_router",
|
||||
"ieee_address": "0x00000001",
|
||||
"friendly_name": "router_1",
|
||||
"device_type": "Router",
|
||||
"model": "CC2530",
|
||||
"vendor": "Texas Instruments",
|
||||
"lqi": 230,
|
||||
"parent_id": "0x00000000",
|
||||
},
|
||||
]
|
||||
|
||||
_SAMPLE_EDGES = [
|
||||
{"source": "0x00000000", "target": "0x00000001"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"base_topic": "zigbee2mqtt",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["device_count"] == 2
|
||||
assert len(data["nodes"]) == 2
|
||||
assert len(data["edges"]) == 1
|
||||
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
|
||||
assert coordinator["ieee_address"] == "0x00000000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"mqtt_username": "admin",
|
||||
"mqtt_password": "secret",
|
||||
"base_topic": "z2m",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
mock_fetch.assert_called_once_with(
|
||||
mqtt_host="localhost",
|
||||
mqtt_port=1883,
|
||||
base_topic="z2m",
|
||||
username="admin",
|
||||
password="secret",
|
||||
tls=False,
|
||||
tls_insecure=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 502
|
||||
assert "broker unreachable" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.side_effect = TimeoutError("timed out")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 504
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.side_effect = ValueError("malformed response")
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
|
||||
"""An empty Zigbee network (coordinator only) is a valid response."""
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["device_count"] == 0
|
||||
assert data["nodes"] == []
|
||||
assert data["edges"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={"mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
"mqtt_host": "broker.example.com",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_tls": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
kwargs = mock_fetch.call_args.kwargs
|
||||
assert kwargs["tls"] is True
|
||||
assert kwargs["tls_insecure"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import",
|
||||
json={
|
||||
"mqtt_host": "broker.example.com",
|
||||
"mqtt_port": 1883,
|
||||
"mqtt_tls": False,
|
||||
"mqtt_tls_insecure": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zigbee/import-pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PENDING_NODES = [
|
||||
{
|
||||
"id": "0xCOORD",
|
||||
"label": "Coordinator",
|
||||
"type": "zigbee_coordinator",
|
||||
"ieee_address": "0xCOORD",
|
||||
"friendly_name": "Coordinator",
|
||||
"device_type": "Coordinator",
|
||||
"model": None,
|
||||
"vendor": None,
|
||||
"lqi": None,
|
||||
"parent_id": None,
|
||||
},
|
||||
{
|
||||
"id": "0xR1",
|
||||
"label": "router_1",
|
||||
"type": "zigbee_router",
|
||||
"ieee_address": "0xR1",
|
||||
"friendly_name": "router_1",
|
||||
"device_type": "Router",
|
||||
"model": "CC2530",
|
||||
"vendor": "TI",
|
||||
"lqi": 220,
|
||||
"parent_id": "0xCOORD",
|
||||
},
|
||||
{
|
||||
"id": "0xE1",
|
||||
"label": "bulb_kitchen",
|
||||
"type": "zigbee_enddevice",
|
||||
"ieee_address": "0xE1",
|
||||
"friendly_name": "bulb_kitchen",
|
||||
"device_type": "EndDevice",
|
||||
"model": "TRADFRI",
|
||||
"vendor": "IKEA",
|
||||
"lqi": 180,
|
||||
"parent_id": "0xR1",
|
||||
},
|
||||
]
|
||||
|
||||
_PENDING_EDGES = [
|
||||
{"source": "0xCOORD", "target": "0xR1"},
|
||||
{"source": "0xR1", "target": "0xE1"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_endpoint_creates_zigbee_scan_run(
|
||||
client: AsyncClient, headers: dict
|
||||
) -> None:
|
||||
"""Endpoint returns a ScanRun (kind=zigbee, status=running) immediately;
|
||||
the actual networkmap fetch + pending persist runs in the background."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
with patch(
|
||||
"app.api.routes.zigbee._background_zigbee_import",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
run = res.json()
|
||||
assert run["kind"] == "zigbee"
|
||||
assert run["status"] == "running"
|
||||
assert run["ranges"] == ["localhost:1883"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_creates_coordinator_and_pending(
|
||||
db_session,
|
||||
) -> None:
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
assert result.device_count == 3
|
||||
assert result.pending_created == 2
|
||||
assert result.pending_updated == 0
|
||||
assert result.coordinator is not None
|
||||
assert result.coordinator.ieee_address == "0xCOORD"
|
||||
assert result.coordinator_already_existed is False
|
||||
assert result.links_recorded == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_idempotent_updates_existing(
|
||||
db_session,
|
||||
) -> None:
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["lqi"] = 99
|
||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
assert result.pending_created == 0
|
||||
assert result.pending_updated == 2
|
||||
assert result.coordinator_already_existed is True
|
||||
assert result.links_recorded == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_replaces_links(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import PendingDeviceLink
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
|
||||
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
||||
|
||||
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
|
||||
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
|
||||
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||
nodes_with_meta[0]["vendor"] = "TI"
|
||||
nodes_with_meta[0]["model"] = "CC2652"
|
||||
|
||||
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
||||
|
||||
coord = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
||||
# New zigbee props default to hidden — user opts in from the right panel.
|
||||
assert all(p["visible"] is False for p in coord.properties)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""A device already approved as a canvas Node must not reappear in pending.
|
||||
|
||||
Its properties must still be refreshed with the latest Vendor/Model/LQI.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
|
||||
# Simulate: router was approved earlier → exists as a canvas Node.
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
ieee_address="0xR1",
|
||||
services=[],
|
||||
properties=[],
|
||||
)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["lqi"] = 250 # new LQI from re-import
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
# No PendingDevice row was created for the approved router.
|
||||
pendings = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalars().all()
|
||||
assert pendings == []
|
||||
|
||||
# Node properties got refreshed.
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
|
||||
# Brand-new props on an existing Node start hidden.
|
||||
assert all(p["visible"] is False for p in refreshed.properties)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
|
||||
"""If user has already made props visible, re-import must not flip them back."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
ieee_address="0xR1",
|
||||
services=[],
|
||||
properties=[
|
||||
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
|
||||
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
|
||||
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
|
||||
],
|
||||
)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["lqi"] = 99
|
||||
bumped[1]["model"] = "CC2530"
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
).scalar_one()
|
||||
by_key = {p["key"]: p for p in refreshed.properties}
|
||||
# Existing keys keep their visibility (True).
|
||||
assert by_key["IEEE"]["visible"] is True
|
||||
assert by_key["Vendor"]["visible"] is True
|
||||
# New key arrives hidden.
|
||||
assert by_key["Model"]["visible"] is False
|
||||
assert by_key["LQI"]["visible"] is False
|
||||
assert by_key["LQI"]["value"] == "99"
|
||||
# Non-zigbee user-added prop is preserved untouched.
|
||||
assert by_key["Custom"]["value"] == "kept"
|
||||
assert by_key["Custom"]["visible"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
||||
db_session,
|
||||
) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[0]["vendor"] = "TI"
|
||||
bumped[0]["model"] = "CC2652"
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
coord = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||
assert keys["Vendor"] == "TI"
|
||||
assert keys["Model"] == "CC2652"
|
||||
# Newly added keys on re-import default to hidden.
|
||||
by_key = {p["key"]: p for p in coord.properties}
|
||||
assert by_key["Vendor"]["visible"] is False
|
||||
assert by_key["Model"]["visible"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||
mock_conn.return_value = True
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/test-connection",
|
||||
json={
|
||||
"mqtt_host": "broker.example.com",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_tls": True,
|
||||
"mqtt_tls_insecure": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
kwargs = mock_conn.call_args.kwargs
|
||||
assert kwargs["tls"] is True
|
||||
assert kwargs["tls_insecure"] is True
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Unit tests for zigbee_service: parser and hierarchy builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import aiomqtt # noqa: F401
|
||||
import pytest
|
||||
|
||||
from app.services.zigbee_service import (
|
||||
_find_parent_router,
|
||||
_z2m_type_to_homelable,
|
||||
fetch_networkmap,
|
||||
parse_networkmap,
|
||||
)
|
||||
from app.services.zigbee_service import (
|
||||
test_mqtt_connection as _test_mqtt_connection,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper builders — real Z2M `bridge/response/networkmap` shape
|
||||
# (data.value.nodes + data.value.links)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_node(
|
||||
ieee: str,
|
||||
device_type: str = "EndDevice",
|
||||
friendly_name: str | None = None,
|
||||
model: str | None = None,
|
||||
vendor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entry: dict[str, Any] = {
|
||||
"ieeeAddr": ieee,
|
||||
"type": device_type,
|
||||
"friendlyName": friendly_name or ieee,
|
||||
}
|
||||
if model or vendor:
|
||||
entry["definition"] = {"model": model, "vendor": vendor}
|
||||
return entry
|
||||
|
||||
|
||||
def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]:
|
||||
return {
|
||||
"source": {"ieeeAddr": source_ieee},
|
||||
"target": {"ieeeAddr": target_ieee},
|
||||
"lqi": lqi,
|
||||
}
|
||||
|
||||
|
||||
def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"data": {
|
||||
"type": "raw",
|
||||
"routes": False,
|
||||
"value": {"nodes": nodes, "links": links or []},
|
||||
},
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _z2m_type_to_homelable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestZ2mTypeToHomelable:
|
||||
def test_coordinator(self) -> None:
|
||||
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
|
||||
|
||||
def test_router(self) -> None:
|
||||
assert _z2m_type_to_homelable("Router") == "zigbee_router"
|
||||
|
||||
def test_enddevice(self) -> None:
|
||||
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
|
||||
|
||||
def test_unknown_defaults_to_enddevice(self) -> None:
|
||||
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_networkmap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseNetworkmap:
|
||||
def test_empty_payload(self) -> None:
|
||||
nodes, edges = parse_networkmap({})
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_empty_value(self) -> None:
|
||||
nodes, edges = parse_networkmap(_wrap([], []))
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_coordinator_only(self) -> None:
|
||||
payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")])
|
||||
nodes, edges = parse_networkmap(payload)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||
assert nodes[0]["ieee_address"] == "0x0000000000000000"
|
||||
assert edges == []
|
||||
|
||||
def test_coordinator_router_enddevice(self) -> None:
|
||||
coord_ieee = "0x0000000000000000"
|
||||
router_ieee = "0x0000000000000001"
|
||||
end_ieee = "0x0000000000000002"
|
||||
|
||||
payload = _wrap(
|
||||
nodes=[
|
||||
_make_node(coord_ieee, "Coordinator", "Coordinator"),
|
||||
_make_node(router_ieee, "Router", "my_router"),
|
||||
_make_node(end_ieee, "EndDevice"),
|
||||
],
|
||||
links=[
|
||||
_make_link(coord_ieee, router_ieee),
|
||||
_make_link(router_ieee, end_ieee),
|
||||
],
|
||||
)
|
||||
|
||||
nodes, edges = parse_networkmap(payload)
|
||||
node_by_id = {n["id"]: n for n in nodes}
|
||||
|
||||
assert coord_ieee in node_by_id
|
||||
assert router_ieee in node_by_id
|
||||
assert end_ieee in node_by_id
|
||||
|
||||
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
|
||||
assert node_by_id[router_ieee]["type"] == "zigbee_router"
|
||||
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
|
||||
|
||||
# Parent hierarchy
|
||||
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
|
||||
assert node_by_id[end_ieee]["parent_id"] == router_ieee
|
||||
assert len(edges) == 2
|
||||
|
||||
def test_no_duplicate_nodes(self) -> None:
|
||||
ieee = "0x0000000000000001"
|
||||
payload = _wrap(
|
||||
nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")],
|
||||
)
|
||||
nodes, _ = parse_networkmap(payload)
|
||||
assert len(nodes) == 1
|
||||
|
||||
def test_edges_built_correctly(self) -> None:
|
||||
coord = "0x0000"
|
||||
router = "0x0001"
|
||||
payload = _wrap(
|
||||
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
||||
links=[_make_link(coord, router)],
|
||||
)
|
||||
_, edges = parse_networkmap(payload)
|
||||
assert len(edges) == 1
|
||||
assert edges[0]["source"] == coord
|
||||
assert edges[0]["target"] == router
|
||||
|
||||
def test_friendly_name_used_as_label(self) -> None:
|
||||
payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")])
|
||||
nodes, _ = parse_networkmap(payload)
|
||||
assert nodes[0]["label"] == "Living Room Sensor"
|
||||
|
||||
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
|
||||
coord = "0x0000"
|
||||
end = "0x0003"
|
||||
payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")])
|
||||
nodes, _ = parse_networkmap(payload)
|
||||
end_node = next(n for n in nodes if n["id"] == end)
|
||||
assert end_node["parent_id"] == coord
|
||||
|
||||
def test_missing_ieee_skipped(self) -> None:
|
||||
payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr
|
||||
nodes, edges = parse_networkmap(payload)
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_lqi_propagated_from_link_to_target_node(self) -> None:
|
||||
coord = "0x0000"
|
||||
end = "0x0001"
|
||||
payload = _wrap(
|
||||
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
||||
links=[_make_link(coord, end, lqi=180)],
|
||||
)
|
||||
nodes, _ = parse_networkmap(payload)
|
||||
end_node = next(n for n in nodes if n["id"] == end)
|
||||
assert end_node["lqi"] == 180
|
||||
|
||||
def test_definition_model_and_vendor_extracted(self) -> None:
|
||||
payload = _wrap([
|
||||
_make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"),
|
||||
])
|
||||
nodes, _ = parse_networkmap(payload)
|
||||
assert nodes[0]["model"] == "WSDCGQ11LM"
|
||||
assert nodes[0]["vendor"] == "Aqara"
|
||||
|
||||
def test_legacy_shape_without_value_wrapper(self) -> None:
|
||||
"""Some Z2M variants put nodes/links directly under data."""
|
||||
payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}}
|
||||
nodes, _ = parse_networkmap(payload)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||
|
||||
def test_routes_bool_is_ignored(self) -> None:
|
||||
"""`routes: false` echo from the request must not crash the parser."""
|
||||
payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}}
|
||||
nodes, edges = parse_networkmap(payload)
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_malformed_nodes_not_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="not a list"):
|
||||
parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}})
|
||||
|
||||
def test_link_to_unknown_node_dropped(self) -> None:
|
||||
payload = _wrap(
|
||||
nodes=[_make_node("0x01", "Coordinator")],
|
||||
links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes
|
||||
)
|
||||
_, edges = parse_networkmap(payload)
|
||||
assert edges == []
|
||||
|
||||
def test_bidirectional_links_yield_single_edge(self) -> None:
|
||||
"""Z2M links are bidirectional — every pair appears twice. The output
|
||||
must collapse to a single parent→child edge (no back-link, no dup)."""
|
||||
coord = "0x0000"
|
||||
router = "0x0001"
|
||||
payload = _wrap(
|
||||
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
||||
links=[
|
||||
_make_link(coord, router),
|
||||
_make_link(router, coord), # reverse direction
|
||||
],
|
||||
)
|
||||
_, edges = parse_networkmap(payload)
|
||||
assert edges == [{"source": coord, "target": router}]
|
||||
|
||||
def test_router_mesh_siblings_dropped(self) -> None:
|
||||
"""Router↔router mesh paths in `links` must NOT produce sibling edges
|
||||
in the final tree. Each router gets exactly one edge from coordinator."""
|
||||
coord = "0x0000"
|
||||
r1 = "0x0001"
|
||||
r2 = "0x0002"
|
||||
payload = _wrap(
|
||||
nodes=[
|
||||
_make_node(coord, "Coordinator"),
|
||||
_make_node(r1, "Router"),
|
||||
_make_node(r2, "Router"),
|
||||
],
|
||||
links=[
|
||||
_make_link(coord, r1),
|
||||
_make_link(coord, r2),
|
||||
_make_link(r1, r2), # mesh sibling — must be dropped
|
||||
_make_link(r2, r1),
|
||||
],
|
||||
)
|
||||
_, edges = parse_networkmap(payload)
|
||||
pairs = {(e["source"], e["target"]) for e in edges}
|
||||
assert pairs == {(coord, r1), (coord, r2)}
|
||||
|
||||
def test_coordinator_has_no_incoming_edge(self) -> None:
|
||||
coord = "0x0000"
|
||||
end = "0x0001"
|
||||
payload = _wrap(
|
||||
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
||||
links=[_make_link(end, coord)], # back-edge from end to coord
|
||||
)
|
||||
_, edges = parse_networkmap(payload)
|
||||
# No edge should target the coordinator
|
||||
assert all(e["target"] != coord for e in edges)
|
||||
assert edges == [{"source": coord, "target": end}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_parent_router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindParentRouter:
|
||||
def test_finds_router_as_source(self) -> None:
|
||||
router_ids = {"r1"}
|
||||
edges = [{"source": "r1", "target": "e1"}]
|
||||
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||
|
||||
def test_finds_router_as_target(self) -> None:
|
||||
router_ids = {"r1"}
|
||||
edges = [{"source": "e1", "target": "r1"}]
|
||||
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||
|
||||
def test_returns_none_when_no_router(self) -> None:
|
||||
router_ids: set[str] = set()
|
||||
edges = [{"source": "e1", "target": "e2"}]
|
||||
assert _find_parent_router("e1", router_ids, edges) is None
|
||||
|
||||
def test_returns_none_empty_edges(self) -> None:
|
||||
assert _find_parent_router("e1", {"r1"}, []) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_networkmap (integration-style with mocked aiomqtt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_RESPONSE_PAYLOAD = {
|
||||
"data": {
|
||||
"type": "raw",
|
||||
"routes": False,
|
||||
"value": {
|
||||
"nodes": [
|
||||
{
|
||||
"ieeeAddr": "0x00000000",
|
||||
"type": "Coordinator",
|
||||
"friendlyName": "Coordinator",
|
||||
},
|
||||
{
|
||||
"ieeeAddr": "0x00000001",
|
||||
"type": "Router",
|
||||
"friendlyName": "router_1",
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": {"ieeeAddr": "0x00000000"},
|
||||
"target": {"ieeeAddr": "0x00000001"},
|
||||
"lqi": 230,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_networkmap_success() -> None:
|
||||
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
|
||||
|
||||
class _FakeMessage:
|
||||
topic = "zigbee2mqtt/bridge/response/networkmap"
|
||||
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
|
||||
_yielded = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._yielded:
|
||||
raise StopAsyncIteration
|
||||
self._yielded = True
|
||||
return self
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
async def subscribe(self, _topic: str) -> None:
|
||||
pass
|
||||
|
||||
async def publish(self, _topic: str, _payload: str) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def messages(self):
|
||||
return _FakeMessage()
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
nodes, edges = await fetch_networkmap(
|
||||
mqtt_host="localhost",
|
||||
mqtt_port=1883,
|
||||
base_topic="zigbee2mqtt",
|
||||
)
|
||||
|
||||
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
|
||||
assert any(n["type"] == "zigbee_router" for n in nodes)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_networkmap_connection_error() -> None:
|
||||
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
raise Exception("Connection refused")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
await fetch_networkmap(
|
||||
mqtt_host="bad-host",
|
||||
mqtt_port=1883,
|
||||
base_topic="zigbee2mqtt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_mqtt_connection_success() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
result = await _test_mqtt_connection("localhost", 1883)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_mqtt_connection_failure() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
raise Exception("refused")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
await _test_mqtt_connection("bad-host", 1883)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import ssl # noqa: E402
|
||||
|
||||
from app.services.zigbee_service import _build_tls_context # noqa: E402
|
||||
|
||||
|
||||
def test_build_tls_context_secure_verifies_cert() -> None:
|
||||
ctx = _build_tls_context(insecure=False)
|
||||
assert ctx.check_hostname is True
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||
|
||||
|
||||
def test_build_tls_context_insecure_disables_verification() -> None:
|
||||
ctx = _build_tls_context(insecure=True)
|
||||
assert ctx.check_hostname is False
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_mqtt_connection_passes_tls_context() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
await _test_mqtt_connection("host", 8883, tls=True)
|
||||
kwargs = mock_aiomqtt.Client.call_args.kwargs
|
||||
assert kwargs["tls_context"] is not None
|
||||
assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
await _test_mqtt_connection("host", 1883, tls=False)
|
||||
assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True)
|
||||
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
assert ctx.check_hostname is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanitize MQTT errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402
|
||||
|
||||
|
||||
def test_sanitize_auth_error_does_not_leak_credentials() -> None:
|
||||
msg = _sanitize_mqtt_error(
|
||||
Exception("Not authorized: bad username or password for user=admin pwd=secret")
|
||||
)
|
||||
assert msg == "Authentication failed"
|
||||
assert "admin" not in msg
|
||||
assert "secret" not in msg
|
||||
|
||||
|
||||
def test_sanitize_refused() -> None:
|
||||
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
|
||||
|
||||
|
||||
def test_sanitize_dns_failure_strips_host() -> None:
|
||||
msg = _sanitize_mqtt_error(
|
||||
Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan")
|
||||
)
|
||||
assert msg == "Broker hostname could not be resolved"
|
||||
assert "broker.internal.lan" not in msg
|
||||
|
||||
|
||||
def test_sanitize_tls_error() -> None:
|
||||
assert _sanitize_mqtt_error(
|
||||
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
||||
) == "TLS handshake failed"
|
||||
|
||||
|
||||
def test_sanitize_unknown_falls_back_to_generic() -> None:
|
||||
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state"))
|
||||
assert msg == "MQTT connection failed"
|
||||
assert "hunter2" not in msg
|
||||
assert "admin" not in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
raise Exception("Not authorized: rejected mqtt://admin:hunter2@host")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
|
||||
with pytest.raises(ConnectionError) as ei:
|
||||
await fetch_networkmap(
|
||||
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
|
||||
)
|
||||
msg = str(ei.value)
|
||||
assert "hunter2" not in msg
|
||||
assert "admin" not in msg
|
||||
assert msg == "Authentication failed"
|
||||
@@ -24,6 +24,20 @@ services:
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
mcp:
|
||||
image: ghcr.io/pouzor/homelable-mcp:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BACKEND_URL: "http://backend:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Zigbee2MQTT Network Map Importer
|
||||
|
||||
This feature lets you connect Homelable to your MQTT broker, fetch the Zigbee2MQTT network topology, and drop all Zigbee devices onto the canvas as typed nodes with proper hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **Automatic device discovery** — Requests the Z2M networkmap via the MQTT bridge API and parses the full device list
|
||||
- **Typed nodes** — Devices are mapped to three homelable node types:
|
||||
- `zigbee_coordinator` — The Zigbee coordinator (hub)
|
||||
- `zigbee_router` — Mains-powered router devices
|
||||
- `zigbee_enddevice` — Battery-powered end devices (sensors, bulbs, etc.)
|
||||
- **Hierarchy** — `parent_id` is set automatically: coordinator → routers → end devices
|
||||
- **LQI display** — Link Quality Indicator is stored as a node property
|
||||
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
|
||||
2. **Zigbee2MQTT** connected to the broker and running
|
||||
3. Z2M must respond to networkmap requests on:
|
||||
- **Request topic:** `<base_topic>/bridge/request/networkmap`
|
||||
- **Response topic:** `<base_topic>/bridge/response/networkmap`
|
||||
- The default base topic is `zigbee2mqtt`
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Usage
|
||||
|
||||
### 1. Open the Zigbee Import dialog
|
||||
|
||||
Click **Zigbee Import** in the left sidebar (below "Scan Network").
|
||||
|
||||
### 2. Configure the MQTT connection
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| Broker Host | — | IP or hostname of your MQTT broker |
|
||||
| Port | 1883 | MQTT broker port |
|
||||
| Base Topic | `zigbee2mqtt` | Zigbee2MQTT base topic |
|
||||
| Username | _(optional)_ | MQTT username if authentication is enabled |
|
||||
| Password | _(optional)_ | MQTT password |
|
||||
|
||||
### 3. Test the connection (optional)
|
||||
|
||||
Click **Test Connection** to verify broker reachability before fetching devices.
|
||||
A green indicator confirms success; red shows the error message from the broker.
|
||||
|
||||
### 4. Fetch devices
|
||||
|
||||
Click **Fetch Devices**. Homelable will:
|
||||
1. Connect to the broker
|
||||
2. Subscribe to the response topic
|
||||
3. Publish `{"type": "raw", "routes": false}` to the request topic
|
||||
4. Wait up to 60 seconds for the network map response (large meshes can take 30 s+)
|
||||
5. Parse and group devices by type
|
||||
|
||||
### 5. Select and add to canvas
|
||||
|
||||
Devices are grouped by type (Coordinator / Router / End Device).
|
||||
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
|
||||
|
||||
> **Tip:** All devices are selected by default. Uncheck any you don't want.
|
||||
|
||||
### 6. Arrange on the canvas
|
||||
|
||||
Devices are placed in a grid at the top-right of the canvas.
|
||||
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
|
||||
|
||||
---
|
||||
|
||||
## MQTT Configuration Tips
|
||||
|
||||
### Mosquitto without authentication
|
||||
|
||||
```
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
```
|
||||
|
||||
### Mosquitto with password file
|
||||
|
||||
```
|
||||
listener 1883
|
||||
password_file /etc/mosquitto/passwd
|
||||
```
|
||||
|
||||
Create a user:
|
||||
```bash
|
||||
mosquitto_passwd -c /etc/mosquitto/passwd <username>
|
||||
```
|
||||
|
||||
### Zigbee2MQTT `configuration.yaml`
|
||||
|
||||
```yaml
|
||||
mqtt:
|
||||
base_topic: zigbee2mqtt
|
||||
server: mqtt://localhost:1883
|
||||
# user: mqtt_user
|
||||
# password: mqtt_password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Z2M Versions
|
||||
|
||||
The networkmap bridge API is available in **Zigbee2MQTT 1.x and 2.x**.
|
||||
Tested against Z2M 1.35+ and 2.x.
|
||||
|
||||
The importer uses the `raw` topology format (`routes: false`) which is the most widely supported mode.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
|
||||
| "Timed out waiting for networkmap" | Z2M not running or wrong base_topic | Verify Z2M is connected, check base_topic setting |
|
||||
| 0 devices returned | Z2M has no devices paired | Pair at least one device first |
|
||||
| "Malformed networkmap response" | Z2M returned unexpected format | Check Z2M version; open an issue |
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
_(Screenshots will be added in a future release)_
|
||||
Generated
+440
-30
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.8.2",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.8.2",
|
||||
"version": "2.1.1",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
@@ -1645,9 +1646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
|
||||
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -2011,6 +2012,415 @@
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -3024,7 +3434,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -3776,12 +4186,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -5192,12 +5602,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -5283,9 +5693,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5447,9 +5857,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5786,9 +6196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.11",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
||||
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -5944,9 +6354,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -7468,9 +7878,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "2.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,9 +19,10 @@
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^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",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// Regenerate frontend/src/data/dashboardIcons.json from the upstream
|
||||
// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
|
||||
//
|
||||
// node scripts/fetch-dashboard-icons.mjs
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
|
||||
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
|
||||
|
||||
const res = await fetch(TREE_URL)
|
||||
if (!res.ok) {
|
||||
console.error(`fetch failed: ${res.status} ${res.statusText}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const tree = await res.json()
|
||||
const slugs = (tree.svg ?? [])
|
||||
.filter((f) => f.endsWith('.svg'))
|
||||
.map((f) => f.slice(0, -4))
|
||||
.sort()
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true })
|
||||
writeFileSync(OUT, JSON.stringify(slugs))
|
||||
console.log(`wrote ${slugs.length} slugs → ${OUT}`)
|
||||
+244
-53
@@ -4,8 +4,9 @@ import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||
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'
|
||||
@@ -19,9 +20,12 @@ import { LoginPage } from '@/components/LoginPage'
|
||||
import { NodeModal } from '@/components/modals/NodeModal'
|
||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -29,49 +33,61 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi } from '@/api/client'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
export default function App() {
|
||||
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 { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
|
||||
setPendingHighlightId(undefined)
|
||||
setPendingModalStatus(status)
|
||||
setPendingModalOpen(true)
|
||||
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
|
||||
}, [])
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
if (STANDALONE) {
|
||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
|
||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
const edgesToSave = edges.map(serializeEdge)
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
} catch {
|
||||
toast.error('Save failed')
|
||||
}
|
||||
}, [nodes, edges, markSaved, activeTheme])
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle])
|
||||
|
||||
// Keep a ref so the keydown handler always calls the latest version
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
@@ -83,8 +99,9 @@ export default function App() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||
if (saved) {
|
||||
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
|
||||
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
|
||||
if (theme_id) setTheme(theme_id)
|
||||
if (custom_style) setCustomStyle(custom_style)
|
||||
loadCanvas(savedNodes, savedEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
@@ -99,23 +116,24 @@ export default function App() {
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
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>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
})
|
||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||
}, [isAuthenticated, loadCanvas, setTheme])
|
||||
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||
const undoRef = useRef(undo)
|
||||
@@ -150,7 +168,7 @@ export default function App() {
|
||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||
snapshotHistory()
|
||||
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
|
||||
// Children position is relative to parent; place near top-left with padding
|
||||
const position = parentNode
|
||||
@@ -163,7 +181,7 @@ export default function App() {
|
||||
position,
|
||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||
...(isProxmox ? { width: 300, height: 200 } : {}),
|
||||
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||
}
|
||||
addNode(newNode)
|
||||
toast.success(`Added "${data.label}"`)
|
||||
@@ -225,6 +243,67 @@ export default function App() {
|
||||
setEditingGroupRectId(null)
|
||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
||||
|
||||
const handleAddText = useCallback((data: TextFormData) => {
|
||||
snapshotHistory()
|
||||
const id = generateUUID()
|
||||
const newNode: Node<NodeData> = {
|
||||
id,
|
||||
// Text lives in `label` because the API serializer only persists top-level
|
||||
// node fields; text_content is not in the schema and was lost on reload.
|
||||
// TextNode and the edit modal both already fall back to label.
|
||||
type: 'text',
|
||||
position: { x: 250, y: 250 },
|
||||
data: {
|
||||
label: data.text,
|
||||
type: 'text',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: {
|
||||
border: data.border_color,
|
||||
border_style: data.border_style,
|
||||
border_width: data.border_width,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_size: data.text_size,
|
||||
font: data.font,
|
||||
},
|
||||
},
|
||||
width: 200,
|
||||
height: 60,
|
||||
}
|
||||
addNode(newNode)
|
||||
}, [addNode, snapshotHistory])
|
||||
|
||||
const handleUpdateText = useCallback((data: TextFormData) => {
|
||||
if (!editingTextId) return
|
||||
snapshotHistory()
|
||||
const existing = nodes.find((n) => n.id === editingTextId)
|
||||
updateNode(editingTextId, {
|
||||
label: data.text,
|
||||
// Clear stale text_content if present from older builds — label is the
|
||||
// source of truth now.
|
||||
text_content: undefined,
|
||||
custom_colors: {
|
||||
...existing?.data.custom_colors,
|
||||
border: data.border_color,
|
||||
border_style: data.border_style,
|
||||
border_width: data.border_width,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_size: data.text_size,
|
||||
font: data.font,
|
||||
},
|
||||
})
|
||||
setEditingTextId(null)
|
||||
}, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory])
|
||||
|
||||
const handleDeleteText = useCallback(() => {
|
||||
if (!editingTextId) return
|
||||
snapshotHistory()
|
||||
deleteNode(editingTextId)
|
||||
setEditingTextId(null)
|
||||
}, [editingTextId, deleteNode, setEditingTextId, snapshotHistory])
|
||||
|
||||
const handleDeleteGroupRect = useCallback(() => {
|
||||
if (!editingGroupRectId) return
|
||||
snapshotHistory()
|
||||
@@ -241,13 +320,13 @@ export default function App() {
|
||||
snapshotHistory()
|
||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||
updateNode(editNodeId, data)
|
||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
|
||||
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (typeof data.container_mode === 'boolean') {
|
||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||
}
|
||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||
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 newParentId = data.parent_id ?? null
|
||||
if (oldParentId !== newParentId) {
|
||||
@@ -305,17 +384,60 @@ export default function App() {
|
||||
}
|
||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
const handleExport = useCallback(() => {
|
||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||
if (!el) { toast.error('Canvas not ready'); return }
|
||||
try {
|
||||
await exportToPng(el)
|
||||
toast.success('Exported as PNG')
|
||||
} catch {
|
||||
toast.error('Export failed')
|
||||
}
|
||||
setExportModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
||||
snapshotHistory()
|
||||
// Place nodes in a grid starting at x=500, y=100
|
||||
const COLS = 4
|
||||
const SPACING_X = 170
|
||||
const SPACING_Y = 100
|
||||
zigbeeNodes.forEach((zn, i) => {
|
||||
const id = zn.id
|
||||
const col = i % COLS
|
||||
const row = Math.floor(i / COLS)
|
||||
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
||||
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||
id,
|
||||
type: zn.type,
|
||||
position,
|
||||
data: {
|
||||
label: zn.friendly_name,
|
||||
type: zn.type as NodeData['type'],
|
||||
status: 'unknown' as const,
|
||||
services: [],
|
||||
...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}),
|
||||
...(zn.model ? { os: zn.model } : {}),
|
||||
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||
},
|
||||
}
|
||||
addNode(newNode)
|
||||
})
|
||||
// Add IoT edges between Zigbee devices: parent bottom -> child top
|
||||
zigbeeEdges.forEach((ze) => {
|
||||
onConnect({
|
||||
source: ze.source,
|
||||
sourceHandle: 'bottom',
|
||||
target: ze.target,
|
||||
targetHandle: 'top-t',
|
||||
type: 'iot',
|
||||
} as unknown as import('@xyflow/react').Connection)
|
||||
})
|
||||
// Auto-select only the freshly imported nodes so the user can drag the
|
||||
// whole subtree as a group.
|
||||
const importedIds = new Set(zigbeeNodes.map((zn) => zn.id))
|
||||
useCanvasStore.setState((state) => ({
|
||||
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||
selectedNodeIds: Array.from(importedIds),
|
||||
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||
}))
|
||||
markUnsaved()
|
||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||
|
||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||
setPendingConnection(connection)
|
||||
}, [])
|
||||
@@ -324,16 +446,18 @@ export default function App() {
|
||||
if (!pendingConnection) return
|
||||
snapshotHistory()
|
||||
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') {
|
||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||
const srcType = src?.data.type
|
||||
const tgtType = tgt?.data.type
|
||||
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
|
||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||
if (src && tgt) {
|
||||
const assignment = resolveVirtualEdgeParent(
|
||||
{ id: src.id, type: src.data.type as NodeData['type'] },
|
||||
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
|
||||
)
|
||||
if (assignment) {
|
||||
updateNode(assignment.childId, { parent_id: assignment.parentId })
|
||||
}
|
||||
}
|
||||
}
|
||||
setPendingConnection(null)
|
||||
@@ -344,6 +468,11 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
||||
// 'group' uses inline rename (pencil button in header). Opening the
|
||||
// generic NodeModal would clobber the group's height (via the
|
||||
// properties-clears-height rule in updateNode) and lose its children.
|
||||
// 'groupRect' has its own onDoubleClick that already routes to GroupRectModal.
|
||||
if (node.data.type === 'group' || node.data.type === 'groupRect') return
|
||||
handleEditNode(node.id)
|
||||
}, [handleEditNode])
|
||||
|
||||
@@ -380,11 +509,12 @@ export default function App() {
|
||||
<Sidebar
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||
onAddText={() => setAddTextOpen(true)}
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||
onSave={handleSave}
|
||||
onNodeApproved={setEditNodeId}
|
||||
forceView={sidebarForceView}
|
||||
highlightPendingId={highlightPendingId}
|
||||
onOpenPending={openPendingModal}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Toolbar
|
||||
@@ -406,14 +536,7 @@ export default function App() {
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => {
|
||||
setHighlightPendingId(deviceId)
|
||||
setSidebarForceView('pending')
|
||||
}, 0)
|
||||
}}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
/>
|
||||
</div>
|
||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||
@@ -422,11 +545,12 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<NodeModal
|
||||
key={addNodeOpen ? 'add-open' : 'add-closed'}
|
||||
open={addNodeOpen}
|
||||
onClose={() => setAddNodeOpen(false)}
|
||||
onSubmit={handleAddNode}
|
||||
title="Add Node"
|
||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||
@@ -437,7 +561,25 @@ export default function App() {
|
||||
onSubmit={handleUpdateNode}
|
||||
initial={editNode?.data}
|
||||
title="Edit Node"
|
||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
parentCandidates={(() => {
|
||||
const descendants = new Set<string>()
|
||||
if (editNodeId) {
|
||||
const queue = [editNodeId]
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!
|
||||
for (const n of nodes) {
|
||||
if (n.data.parent_id === id && !descendants.has(n.id)) {
|
||||
descendants.add(n.id)
|
||||
queue.push(n.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => !descendants.has(n.id))
|
||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
||||
})()}
|
||||
currentNodeId={editNodeId ?? undefined}
|
||||
/>
|
||||
|
||||
<EdgeModal
|
||||
@@ -475,6 +617,18 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ZigbeeImportModal
|
||||
open={zigbeeImportOpen}
|
||||
onClose={() => setZigbeeImportOpen(false)}
|
||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => setSidebarForceView('history'), 0)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GroupRectModal
|
||||
open={addGroupRectOpen}
|
||||
onClose={() => setAddGroupRectOpen(false)}
|
||||
@@ -510,6 +664,37 @@ export default function App() {
|
||||
title="Edit Zone"
|
||||
/>
|
||||
|
||||
<TextModal
|
||||
open={addTextOpen}
|
||||
onClose={() => setAddTextOpen(false)}
|
||||
onSubmit={handleAddText}
|
||||
title="Add Text"
|
||||
/>
|
||||
|
||||
<TextModal
|
||||
key={editingTextId ?? 'text-edit'}
|
||||
open={!!editingTextId}
|
||||
onClose={() => setEditingTextId(null)}
|
||||
onSubmit={handleUpdateText}
|
||||
onDelete={handleDeleteText}
|
||||
initial={(() => {
|
||||
const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null
|
||||
if (!n) return undefined
|
||||
const rc = n.data.custom_colors ?? {}
|
||||
return {
|
||||
text: n.data.text_content ?? n.data.label ?? '',
|
||||
font: rc.font ?? 'inter',
|
||||
text_color: rc.text_color ?? '#e6edf3',
|
||||
text_size: rc.text_size ?? 14,
|
||||
border_color: rc.border ?? '#30363d',
|
||||
border_style: (rc.border_style ?? 'none') as TextFormData['border_style'],
|
||||
border_width: rc.border_width ?? 1,
|
||||
background_color: rc.background ?? '#00000000',
|
||||
}
|
||||
})()}
|
||||
title="Edit Text"
|
||||
/>
|
||||
|
||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||
<ThemeModal
|
||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||
@@ -520,17 +705,23 @@ export default function App() {
|
||||
<SearchModal
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => {
|
||||
setHighlightPendingId(deviceId)
|
||||
setSidebarForceView('pending')
|
||||
}, 0)
|
||||
}}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
/>
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<PendingDevicesModal
|
||||
open={pendingModalOpen}
|
||||
onClose={() => setPendingModalOpen(false)}
|
||||
highlightId={pendingHighlightId}
|
||||
initialStatus={pendingModalStatus}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
open={exportModalOpen}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
type Interceptor<T> = {
|
||||
fulfilled?: (v: T) => T | Promise<T>
|
||||
rejected?: (e: unknown) => unknown
|
||||
}
|
||||
|
||||
interface MockInstance {
|
||||
defaults: { baseURL?: string }
|
||||
interceptors: {
|
||||
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
}
|
||||
get: ReturnType<typeof vi.fn>
|
||||
post: ReturnType<typeof vi.fn>
|
||||
patch: ReturnType<typeof vi.fn>
|
||||
delete: ReturnType<typeof vi.fn>
|
||||
__req: Interceptor<{ headers: Record<string, string> }>
|
||||
__res: Interceptor<unknown>
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] }))
|
||||
const instances = hoisted.instances as MockInstance[]
|
||||
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
create: (cfg: { baseURL?: string }) => {
|
||||
const inst: MockInstance = {
|
||||
defaults: { baseURL: cfg?.baseURL },
|
||||
interceptors: {
|
||||
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
||||
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
||||
},
|
||||
get: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
post: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
__req: {},
|
||||
__res: {},
|
||||
}
|
||||
hoisted.instances.push(inst)
|
||||
return inst
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import * as clientModule from '../client'
|
||||
|
||||
describe('api/client', () => {
|
||||
const mod = clientModule
|
||||
const [api, publicApi] = instances
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
api.get.mockClear()
|
||||
api.post.mockClear()
|
||||
api.patch.mockClear()
|
||||
api.delete.mockClear()
|
||||
publicApi.get.mockClear()
|
||||
publicApi.post.mockClear()
|
||||
})
|
||||
|
||||
it('creates two axios instances with /api/v1 baseURL', () => {
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(api.defaults.baseURL).toBe('/api/v1')
|
||||
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||
})
|
||||
|
||||
it('exports `api` matching the first created instance', () => {
|
||||
expect(mod.api).toBe(api)
|
||||
})
|
||||
|
||||
it('request interceptor adds Authorization header when token present', () => {
|
||||
useAuthStore.setState({ token: 'tok-123', isAuthenticated: true })
|
||||
const cfg = { headers: {} as Record<string, string> }
|
||||
const out = api.__req.fulfilled!(cfg)
|
||||
expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123')
|
||||
})
|
||||
|
||||
it('request interceptor leaves headers untouched when no token', () => {
|
||||
const cfg = { headers: {} as Record<string, string> }
|
||||
const out = api.__req.fulfilled!(cfg)
|
||||
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('response interceptor passes through 2xx responses', () => {
|
||||
const r = { status: 200, data: { ok: true } }
|
||||
expect(api.__res.fulfilled!(r)).toBe(r)
|
||||
})
|
||||
|
||||
it('response interceptor calls logout on 401', async () => {
|
||||
const logout = vi.spyOn(useAuthStore.getState(), 'logout')
|
||||
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
||||
const err = { response: { status: 401 } }
|
||||
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||
expect(logout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('response interceptor does not call logout on non-401', async () => {
|
||||
const logout = vi.fn()
|
||||
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
||||
const err = { response: { status: 500 } }
|
||||
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||
expect(logout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('response interceptor handles error with no response object', async () => {
|
||||
const logout = vi.fn()
|
||||
useAuthStore.setState({ logout })
|
||||
const err = { message: 'network down' }
|
||||
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||
expect(logout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publicApi has no request/response interceptors registered', () => {
|
||||
expect(publicApi.__req.fulfilled).toBeUndefined()
|
||||
expect(publicApi.__res.fulfilled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('authApi.login posts to /auth/login', () => {
|
||||
mod.authApi.login('u', 'p')
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
||||
})
|
||||
|
||||
it('canvasApi.load GETs /canvas', () => {
|
||||
mod.canvasApi.load()
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas')
|
||||
})
|
||||
|
||||
it('canvasApi.save POSTs to /canvas/save with payload', () => {
|
||||
const payload = { nodes: [], edges: [], viewport: {} }
|
||||
mod.canvasApi.save(payload)
|
||||
expect(api.post).toHaveBeenCalledWith('/canvas/save', payload)
|
||||
})
|
||||
|
||||
it('nodesApi CRUD calls correct endpoints', () => {
|
||||
mod.nodesApi.create({ a: 1 })
|
||||
expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 })
|
||||
mod.nodesApi.update('n1', { b: 2 })
|
||||
expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 })
|
||||
mod.nodesApi.delete('n1')
|
||||
expect(api.delete).toHaveBeenCalledWith('/nodes/n1')
|
||||
})
|
||||
|
||||
it('edgesApi CRUD calls correct endpoints', () => {
|
||||
mod.edgesApi.create({ s: 'a', t: 'b' })
|
||||
expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' })
|
||||
mod.edgesApi.delete('e1')
|
||||
expect(api.delete).toHaveBeenCalledWith('/edges/e1')
|
||||
})
|
||||
|
||||
it('liveviewApi.load uses publicApi with key param', () => {
|
||||
mod.liveviewApi.load('k-1')
|
||||
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } })
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scanApi endpoints route correctly', () => {
|
||||
mod.scanApi.trigger()
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||
mod.scanApi.pending()
|
||||
expect(api.get).toHaveBeenCalledWith('/scan/pending')
|
||||
mod.scanApi.hidden()
|
||||
expect(api.get).toHaveBeenCalledWith('/scan/hidden')
|
||||
mod.scanApi.runs()
|
||||
expect(api.get).toHaveBeenCalledWith('/scan/runs')
|
||||
mod.scanApi.clearPending()
|
||||
expect(api.delete).toHaveBeenCalledWith('/scan/pending')
|
||||
mod.scanApi.approve('d1', { foo: 'bar' })
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' })
|
||||
mod.scanApi.hide('d1')
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide')
|
||||
mod.scanApi.ignore('d1')
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
||||
mod.scanApi.bulkApprove(['a', 'b'])
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
|
||||
mod.scanApi.bulkHide(['a'])
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
||||
mod.scanApi.restore('d1')
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore')
|
||||
mod.scanApi.bulkRestore(['a'])
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] })
|
||||
mod.scanApi.stop('run-1')
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop')
|
||||
mod.scanApi.getConfig()
|
||||
expect(api.get).toHaveBeenCalledWith('/scan/config')
|
||||
mod.scanApi.saveConfig({ ranges: ['1.0/24'] })
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] })
|
||||
})
|
||||
|
||||
it('settingsApi get/save', () => {
|
||||
mod.settingsApi.get()
|
||||
expect(api.get).toHaveBeenCalledWith('/settings')
|
||||
mod.settingsApi.save({ interval_seconds: 30 })
|
||||
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
|
||||
})
|
||||
|
||||
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||
const cfg = { mqtt_host: 'h', mqtt_port: 1883 }
|
||||
mod.zigbeeApi.testConnection(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg)
|
||||
mod.zigbeeApi.importNetwork(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg)
|
||||
mod.zigbeeApi.importToPending(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,7 @@ export const canvasApi = {
|
||||
nodes: object[]
|
||||
edges: object[]
|
||||
viewport: object
|
||||
custom_style?: object | null
|
||||
}) => api.post('/canvas/save', payload),
|
||||
}
|
||||
|
||||
@@ -57,9 +58,27 @@ export const scanApi = {
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
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<{
|
||||
approved: boolean
|
||||
node_id: string
|
||||
edges_created: number
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||
bulkApprove: (ids: string[]) =>
|
||||
api.post<{
|
||||
approved: number
|
||||
node_ids: string[]
|
||||
device_ids: string[]
|
||||
edges_created: number
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
skipped: number
|
||||
}>('/scan/pending/bulk-approve', { device_ids: ids }),
|
||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { 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),
|
||||
@@ -69,3 +88,50 @@ export const settingsApi = {
|
||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||
}
|
||||
|
||||
export const zigbeeApi = {
|
||||
testConnection: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data),
|
||||
|
||||
importNetwork: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
base_topic?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
nodes: import('@/components/zigbee/types').ZigbeeNode[]
|
||||
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||
device_count: number
|
||||
}>('/zigbee/import', data),
|
||||
|
||||
importToPending: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
base_topic?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
id: string
|
||||
status: string
|
||||
kind: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}>('/zigbee/import-pending', data),
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ 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'
|
||||
import type { NodeData, CustomStyleDef } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STORAGE_KEY = 'homelable_canvas'
|
||||
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
|
||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||
const theme = THEMES[activeTheme]
|
||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||
// - standalone → always ready (localStorage, no key required)
|
||||
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
|
||||
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])
|
||||
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n: ApiNode) => [n.id, true])
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
|
||||
const detail: string = err.response.data?.detail ?? ''
|
||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||
})
|
||||
}, [loadCanvas])
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
|
||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +12,7 @@ vi.mock('@xyflow/react', () => ({
|
||||
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', () => ({}))
|
||||
@@ -123,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||
|
||||
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||
setSearch('?key=valid')
|
||||
const nestedPayload = {
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||
expect(ctr?.parentId).toBe('host')
|
||||
expect(ctr?.extent).toBe('parent')
|
||||
})
|
||||
|
||||
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||
|
||||
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||
setSearch('?key=valid')
|
||||
const styledPayload = {
|
||||
data: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||
})
|
||||
|
||||
// ── No editing props passed ───────────────────────────────────────────────
|
||||
|
||||
it('does not show any Access Denied when key is valid', async () => {
|
||||
@@ -143,6 +194,7 @@ const XYFLOW_MOCK = {
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useViewport } from '@xyflow/react'
|
||||
import type { Guide } from '@/utils/alignment'
|
||||
|
||||
interface AlignmentGuidesProps {
|
||||
guides: Guide[]
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG overlay that draws alignment guide lines on top of the React Flow canvas.
|
||||
* Coordinates are in canvas (flow) space; we read the viewport transform to
|
||||
* project them into screen space so lines stay locked to nodes when the user
|
||||
* pans or zooms.
|
||||
*/
|
||||
export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
|
||||
const { x: vx, y: vy, zoom } = useViewport()
|
||||
|
||||
if (guides.length === 0) return null
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 5,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{guides.map((g, i) => {
|
||||
if (g.axis === 'x') {
|
||||
const x = g.position * zoom + vx
|
||||
const y1 = g.start * zoom + vy
|
||||
const y2 = g.end * zoom + vy
|
||||
return (
|
||||
<line
|
||||
key={`x-${i}-${g.position}`}
|
||||
x1={x}
|
||||
y1={y1}
|
||||
x2={x}
|
||||
y2={y2}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 3"
|
||||
shapeRendering="crispEdges"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const y = g.position * zoom + vy
|
||||
const x1 = g.start * zoom + vx
|
||||
const x2 = g.end * zoom + vx
|
||||
return (
|
||||
<line
|
||||
key={`y-${i}-${g.position}`}
|
||||
x1={x1}
|
||||
y1={y}
|
||||
x2={x2}
|
||||
y2={y}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 3"
|
||||
shapeRendering="crispEdges"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes'
|
||||
import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { SearchBar } from './SearchBar'
|
||||
import { AlignmentGuides } from './AlignmentGuides'
|
||||
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
interface CanvasContainerProps {
|
||||
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
[]
|
||||
)
|
||||
|
||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||
|
||||
return (
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||
<ReactFlow
|
||||
@@ -96,6 +100,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
@@ -121,6 +127,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<SearchBar onOpenPending={onOpenPending} />
|
||||
<AlignmentGuides guides={guides} />
|
||||
<Controls>
|
||||
<ControlButton
|
||||
onClick={() => setLassoMode((m) => !m)}
|
||||
|
||||
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
|
||||
const pendingResults = q
|
||||
? pendingDevices.filter((d) =>
|
||||
d.ip.toLowerCase().includes(q) ||
|
||||
d.ip?.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.friendly_name?.toLowerCase().includes(q) ||
|
||||
d.ieee_address?.toLowerCase().includes(q) ||
|
||||
d.services.some((s) =>
|
||||
s.service_name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
@@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
>
|
||||
<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}
|
||||
{d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||
{serviceName ?? d.ip}
|
||||
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { AlignmentGuides } from '../AlignmentGuides'
|
||||
import type { Guide } from '@/utils/alignment'
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
useViewport: () => ({ x: 50, y: 100, zoom: 2 }),
|
||||
}))
|
||||
|
||||
describe('AlignmentGuides', () => {
|
||||
it('renders nothing when no guides', () => {
|
||||
const { container } = render(<AlignmentGuides guides={[]} />)
|
||||
expect(container.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('projects an x-axis guide through the viewport transform', () => {
|
||||
const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }]
|
||||
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||
const line = container.querySelector('line')!
|
||||
// x = position * zoom + vx → 100*2 + 50 = 250
|
||||
expect(line.getAttribute('x1')).toBe('250')
|
||||
expect(line.getAttribute('x2')).toBe('250')
|
||||
// y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500
|
||||
expect(line.getAttribute('y1')).toBe('100')
|
||||
expect(line.getAttribute('y2')).toBe('500')
|
||||
})
|
||||
|
||||
it('projects a y-axis guide horizontally', () => {
|
||||
const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }]
|
||||
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||
const line = container.querySelector('line')!
|
||||
// y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170
|
||||
expect(line.getAttribute('y1')).toBe('200')
|
||||
expect(line.getAttribute('y2')).toBe('200')
|
||||
expect(line.getAttribute('x1')).toBe('70')
|
||||
expect(line.getAttribute('x2')).toBe('170')
|
||||
})
|
||||
|
||||
it('renders one line per guide', () => {
|
||||
const guides: Guide[] = [
|
||||
{ axis: 'x', position: 100, start: 0, end: 200 },
|
||||
{ axis: 'y', position: 50, start: 10, end: 60 },
|
||||
]
|
||||
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||
expect(container.querySelectorAll('line')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -44,10 +44,13 @@ vi.mock('@/utils/nodeColors', () => ({
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
maskIp: (ip: string) => ip,
|
||||
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||
primaryIp: (ip: string) => ip ? ip.split(',')[0].trim() : '',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/propertyIcons', () => ({
|
||||
@@ -55,8 +58,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||
bottomHandlePositions: () => [50],
|
||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||
}))
|
||||
|
||||
beforeEach(() => { mockZoom = 1 })
|
||||
@@ -167,6 +171,49 @@ describe('BaseNode — properties rendering', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — services visibility toggle', () => {
|
||||
it('does not render service toggle button on the node', () => {
|
||||
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
|
||||
expect(screen.queryByTitle('Show services')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders service rows when services are toggled on', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [
|
||||
{ service_name: 'nginx', port: 80, protocol: 'tcp' },
|
||||
{ service_name: 'ssh', port: 22, protocol: 'tcp' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(screen.getByText('nginx')).toBeDefined()
|
||||
expect(screen.getByText('80')).toBeDefined()
|
||||
expect(screen.getByText('ssh')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders clickable service links for web services', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }],
|
||||
})
|
||||
|
||||
const link = screen.getByRole('link', { name: /nginx/i }) as HTMLAnchorElement
|
||||
expect(link.getAttribute('href')).toBe('http://192.168.1.10:80')
|
||||
})
|
||||
|
||||
it('keeps non-web services as non-clickable rows', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||
renderBaseNode({
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
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'
|
||||
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
|
||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||
),
|
||||
Handle: () => null,
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({}),
|
||||
}))
|
||||
|
||||
@@ -102,6 +104,19 @@ describe('GroupNode', () => {
|
||||
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' } },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
|
||||
import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils'
|
||||
|
||||
describe('buildWaypointPath — bezier (default)', () => {
|
||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
|
||||
expect(idx).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWaypointLabelPosition', () => {
|
||||
it('uses the routed midpoint for a symmetric bezier waypoint path', () => {
|
||||
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0)
|
||||
expect(point.x).toBeCloseTo(50, 0)
|
||||
expect(point.y).toBeCloseTo(100, 0)
|
||||
})
|
||||
|
||||
it('uses the routed midpoint for a smooth waypoint path', () => {
|
||||
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
|
||||
expect(point.x).toBeCloseTo(50, 0)
|
||||
expect(point.y).toBeCloseTo(50, 0)
|
||||
})
|
||||
|
||||
it('falls back to the source point when the path is degenerate', () => {
|
||||
const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth')
|
||||
expect(point).toEqual({ x: 10, y: 20 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAddWaypointHandlePosition', () => {
|
||||
it('places bezier add handle on the rendered curved segment', () => {
|
||||
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier')
|
||||
expect(point.x).toBeCloseTo(21.875, 3)
|
||||
expect(point.y).toBeCloseTo(56.25, 3)
|
||||
})
|
||||
|
||||
it('keeps smooth add handle at straight segment midpoint', () => {
|
||||
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth')
|
||||
expect(point).toEqual({ x: 50, y: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
||||
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
||||
|
||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||
|
||||
@@ -161,9 +161,9 @@ function segmentMidpoints(
|
||||
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
|
||||
const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
|
||||
let mx = base.x
|
||||
const my = base.y
|
||||
|
||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||
// the source handle axis so clicking it creates a perpendicular exit.
|
||||
@@ -179,9 +179,113 @@ function segmentMidpoints(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Endpoint dot (interactive reconnection handle pinned to handle) ──────────
|
||||
|
||||
interface EndpointDotProps {
|
||||
edgeId: string
|
||||
role: 'source' | 'target'
|
||||
x: number
|
||||
y: number
|
||||
position?: string
|
||||
color: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle: string | null | undefined
|
||||
targetHandle: string | null | undefined
|
||||
onDrag: (pos: { x: number; y: number } | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive endpoint marker rendered above the node layer (via
|
||||
* EdgeLabelRenderer). On pointerup it inspects the element under the cursor
|
||||
* for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with
|
||||
* the new endpoint. Drop on empty space leaves the edge unchanged.
|
||||
*
|
||||
* Handles are nudged 3px inward (toward the node) because React Flow's edge
|
||||
* endpoint coords sit at the outer edge of the handle box, not its center.
|
||||
*/
|
||||
function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) {
|
||||
const reconnectEdge = useCanvasStore((s) => s.reconnectEdge)
|
||||
const { screenToFlowPosition } = useReactFlow()
|
||||
|
||||
const offset = 3
|
||||
let dx = 0, dy = 0
|
||||
if (position === 'bottom') dy = -offset
|
||||
else if (position === 'top') dy = offset
|
||||
else if (position === 'left') dx = offset
|
||||
else if (position === 'right') dx = -offset
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (e.buttons !== 1) return
|
||||
onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
|
||||
}, [onDrag, screenToFlowPosition])
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
// Find the topmost handle under cursor, skipping the dragged dot itself.
|
||||
const stack = document.elementsFromPoint(e.clientX, e.clientY)
|
||||
let handleEl: HTMLElement | null = null
|
||||
for (const node of stack) {
|
||||
const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null
|
||||
if (h) { handleEl = h; break }
|
||||
}
|
||||
onDrag(null)
|
||||
if (!handleEl) return // dropped on empty space → keep edge unchanged
|
||||
const newHandleId = handleEl.getAttribute('data-handleid')
|
||||
const newNodeId = handleEl.getAttribute('data-nodeid')
|
||||
if (!newHandleId || !newNodeId) return
|
||||
if (role === 'source') {
|
||||
reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null })
|
||||
} else {
|
||||
reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId })
|
||||
}
|
||||
}, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag])
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${x + dx}px, ${y + dy}px)`,
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
border: '2px solid #0d1117',
|
||||
cursor: 'grab',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 1000,
|
||||
touchAction: 'none',
|
||||
}}
|
||||
title="Drag to reconnect"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null)
|
||||
|
||||
const sourceX = drag?.role === 'source' ? drag.x : rawSourceX
|
||||
const sourceY = drag?.role === 'source' ? drag.y : rawSourceY
|
||||
const targetX = drag?.role === 'target' ? drag.x : rawTargetX
|
||||
const targetY = drag?.role === 'target' ? drag.y : rawTargetY
|
||||
|
||||
const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||
setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null)
|
||||
}, [])
|
||||
const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||
setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null)
|
||||
}, [])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||
@@ -205,8 +309,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||
: autoPath
|
||||
|
||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
||||
const midY = (sourceY + targetY) / 2
|
||||
const labelPosition = hasWaypoints
|
||||
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||
: { x: labelX, y: (sourceY + targetY) / 2 }
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const edgeColors = theme.colors.edgeColors
|
||||
@@ -300,7 +405,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
@@ -310,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endpoint dots — visual indicators for reconnection targets */}
|
||||
{selected && (
|
||||
<>
|
||||
<EndpointDot
|
||||
edgeId={id}
|
||||
role="source"
|
||||
x={sourceX}
|
||||
y={sourceY}
|
||||
position={sourcePosition}
|
||||
color={strokeColor}
|
||||
source={source}
|
||||
target={target}
|
||||
sourceHandle={sourceHandleId}
|
||||
targetHandle={targetHandleId}
|
||||
onDrag={onSourceDrag}
|
||||
/>
|
||||
<EndpointDot
|
||||
edgeId={id}
|
||||
role="target"
|
||||
x={targetX}
|
||||
y={targetY}
|
||||
position={targetPosition}
|
||||
color={strokeColor}
|
||||
source={source}
|
||||
target={target}
|
||||
sourceHandle={sourceHandleId}
|
||||
targetHandle={targetHandleId}
|
||||
onDrag={onTargetDrag}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Existing waypoint drag handles */}
|
||||
{selected && waypoints.map((wp, idx) => {
|
||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||
|
||||
@@ -72,6 +72,216 @@ export function buildWaypointPath(
|
||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||
}
|
||||
|
||||
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * t,
|
||||
y: a.y + (b.y - a.y) * t,
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
|
||||
const mt = 1 - t
|
||||
return {
|
||||
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
|
||||
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
|
||||
const mt = 1 - t
|
||||
return {
|
||||
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
|
||||
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
|
||||
}
|
||||
}
|
||||
|
||||
function approximateLength(pointAt: (t: number) => Waypoint, steps = 24): number {
|
||||
let length = 0
|
||||
let prev = pointAt(0)
|
||||
|
||||
for (let step = 1; step <= steps; step++) {
|
||||
const next = pointAt(step / steps)
|
||||
length += Math.hypot(next.x - prev.x, next.y - prev.y)
|
||||
prev = next
|
||||
}
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
type PathSegment = {
|
||||
length: number
|
||||
pointAt: (t: number) => Waypoint
|
||||
}
|
||||
|
||||
function buildBezierSegments(pts: Waypoint[]): PathSegment[] {
|
||||
if (pts.length < 2) return []
|
||||
|
||||
return pts.slice(0, -1).map((_, 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 cp1 = {
|
||||
x: p1.x + (p2.x - p0.x) / 6,
|
||||
y: p1.y + (p2.y - p0.y) / 6,
|
||||
}
|
||||
const cp2 = {
|
||||
x: p2.x - (p3.x - p1.x) / 6,
|
||||
y: p2.y - (p3.y - p1.y) / 6,
|
||||
}
|
||||
const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t)
|
||||
|
||||
return {
|
||||
length: approximateLength(pointAt),
|
||||
pointAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] {
|
||||
if (pts.length < 2) return []
|
||||
if (pts.length === 2) {
|
||||
const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t)
|
||||
return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }]
|
||||
}
|
||||
|
||||
const segments: PathSegment[] = []
|
||||
let cursor = pts[0]
|
||||
|
||||
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) {
|
||||
const start = { x: cursor.x, y: cursor.y }
|
||||
const end = { x: curr.x, y: curr.y }
|
||||
const lineToCurr = (t: number) => interpolateLine(start, end, t)
|
||||
segments.push({
|
||||
length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y),
|
||||
pointAt: lineToCurr,
|
||||
})
|
||||
cursor = curr
|
||||
continue
|
||||
}
|
||||
|
||||
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||
const before = {
|
||||
x: curr.x - (dx1 / len1) * r,
|
||||
y: curr.y - (dy1 / len1) * r,
|
||||
}
|
||||
const after = {
|
||||
x: curr.x + (dx2 / len2) * r,
|
||||
y: curr.y + (dy2 / len2) * r,
|
||||
}
|
||||
|
||||
const lineStart = { x: cursor.x, y: cursor.y }
|
||||
const lineEnd = { x: before.x, y: before.y }
|
||||
const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t)
|
||||
segments.push({
|
||||
length: Math.hypot(before.x - cursor.x, before.y - cursor.y),
|
||||
pointAt: lineToBefore,
|
||||
})
|
||||
|
||||
const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t)
|
||||
segments.push({
|
||||
length: approximateLength(curveAroundCorner),
|
||||
pointAt: curveAroundCorner,
|
||||
})
|
||||
|
||||
cursor = after
|
||||
}
|
||||
|
||||
const targetStart = { x: cursor.x, y: cursor.y }
|
||||
const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
|
||||
const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t)
|
||||
segments.push({
|
||||
length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y),
|
||||
pointAt: lineToTarget,
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export function getWaypointLabelPosition(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
pathStyle: string = 'bezier',
|
||||
): Waypoint {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts)
|
||||
|
||||
if (segments.length === 0) return pts[0]
|
||||
|
||||
const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0)
|
||||
if (totalLength <= 0) return pts[Math.floor(pts.length / 2)]
|
||||
|
||||
let remaining = totalLength / 2
|
||||
for (const segment of segments) {
|
||||
if (remaining <= segment.length) {
|
||||
const t = segment.length === 0 ? 0 : remaining / segment.length
|
||||
return segment.pointAt(t)
|
||||
}
|
||||
remaining -= segment.length
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
return lastSegment.pointAt(1)
|
||||
}
|
||||
|
||||
function getBezierSegmentPoint(
|
||||
pts: Waypoint[],
|
||||
insertIndex: number,
|
||||
t: number,
|
||||
): Waypoint {
|
||||
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
|
||||
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 cp1 = {
|
||||
x: p1.x + (p2.x - p0.x) / 6,
|
||||
y: p1.y + (p2.y - p0.y) / 6,
|
||||
}
|
||||
const cp2 = {
|
||||
x: p2.x - (p3.x - p1.x) / 6,
|
||||
y: p2.y - (p3.y - p1.y) / 6,
|
||||
}
|
||||
|
||||
return interpolateCubic(p1, cp1, cp2, p2, t)
|
||||
}
|
||||
|
||||
export function getAddWaypointHandlePosition(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
insertIndex: number,
|
||||
pathStyle: string = 'bezier',
|
||||
): Waypoint {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
|
||||
if (pts.length < 2) return { x: sourceX, y: sourceY }
|
||||
|
||||
if (pathStyle !== 'smooth') {
|
||||
return getBezierSegmentPoint(pts, insertIndex, 0.5)
|
||||
}
|
||||
|
||||
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
|
||||
return {
|
||||
x: (pts[i].x + pts[i + 1].x) / 2,
|
||||
y: (pts[i].y + pts[i + 1].y) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createElement, useEffect, useMemo } from 'react'
|
||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp } from '@/utils/maskIp'
|
||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
|
||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
icon: LucideIcon
|
||||
@@ -35,6 +37,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
const statusColor = theme.colors.statusColors[data.status]
|
||||
const isOnline = data.status === 'online'
|
||||
const services = data.services ?? []
|
||||
const showServices = data.custom_colors?.show_services === true
|
||||
const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname
|
||||
|
||||
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
||||
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
||||
@@ -43,7 +48,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
||||
style={{
|
||||
background: colors.background,
|
||||
borderColor: colors.border,
|
||||
@@ -56,7 +61,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||
: 'none',
|
||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||
minWidth: 140,
|
||||
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
||||
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
||||
width: width ? '100%' : undefined,
|
||||
height: height ? '100%' : undefined,
|
||||
}}
|
||||
@@ -66,7 +72,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
@@ -76,8 +82,15 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
|
||||
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||
<div
|
||||
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
@@ -86,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
{createElement(resolvedIcon, { size: 15 })}
|
||||
{isBrandIconKey(data.custom_icon)
|
||||
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
|
||||
: createElement(resolvedIcon, { size: 15 })}
|
||||
</div>
|
||||
|
||||
{/* Label + IP */}
|
||||
@@ -98,15 +113,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
{data.ip && (
|
||||
{data.ip && splitIps(data.ip).map((ip) => (
|
||||
<div
|
||||
key={ip}
|
||||
className="font-mono text-[10px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
title={data.ip}
|
||||
title={ip}
|
||||
>
|
||||
{hideIp ? maskIp(data.ip) : data.ip}
|
||||
{hideIp ? maskIp(ip) : ip}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,14 +130,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
{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">
|
||||
<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]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<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" title={prop.value}>· {prop.value}</span>
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -129,6 +145,74 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
</>
|
||||
)}
|
||||
|
||||
{showServices && services.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">
|
||||
{services.map((svc, idx) => {
|
||||
const url = getServiceUrl(svc, serviceHost)
|
||||
const row = (
|
||||
<div
|
||||
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||
style={{
|
||||
background: theme.colors.nodeIconBackground,
|
||||
color: theme.colors.nodeSubtextColor,
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
{/* LEFT: service name */}
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0 }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
|
||||
{/* RIGHT: path + port */}
|
||||
<div className="flex items-center gap-2 shrink-0 min-w-0">
|
||||
{svc.path && (
|
||||
<span
|
||||
className="truncate text-[#8b949e] text-right max-w-[80px]"
|
||||
title={svc.path}
|
||||
>
|
||||
{svc.path}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="font-mono opacity-80 flex items-center gap-1">
|
||||
<span>{svc.port}</span>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className={`shrink-0 ${url ? '' : 'opacity-0'}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!url) return <div key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}>{row}</div>
|
||||
|
||||
return (
|
||||
<a
|
||||
key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block hover:opacity-85 transition-opacity"
|
||||
title={url}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||
{showLegacyHardware && (
|
||||
<>
|
||||
@@ -138,7 +222,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<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>
|
||||
<span className="truncate max-w-20" 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>
|
||||
@@ -165,16 +249,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
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`
|
||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||
const sourceId = bottomHandleId(idx)
|
||||
const targetId = `${sourceId}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
<Handle
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
||||
import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
|
||||
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||
|
||||
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
const showBorder = data.custom_colors?.show_border !== false
|
||||
const isVisible = showBorder || selected
|
||||
|
||||
@@ -49,6 +53,29 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||
/>
|
||||
|
||||
{/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */}
|
||||
{([
|
||||
['group-top', Position.Top],
|
||||
['group-right', Position.Right],
|
||||
['group-bottom', Position.Bottom],
|
||||
['group-left', Position.Left],
|
||||
] as const).map(([hid, pos]) => (
|
||||
<span key={hid}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={pos}
|
||||
id={hid}
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={pos}
|
||||
id={`${hid}-t`}
|
||||
style={{ opacity: 0, width: 12, height: 12 }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Header */}
|
||||
{isVisible && (
|
||||
<div
|
||||
@@ -66,13 +93,13 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
className="nodrag"
|
||||
>
|
||||
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="nodrag"
|
||||
value={labelDraft}
|
||||
onChange={(e) => setLabelDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -97,11 +124,12 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||
<button 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={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"
|
||||
|
||||
@@ -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 type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
||||
'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>>) {
|
||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
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 (
|
||||
<>
|
||||
<NodeResizer
|
||||
@@ -75,6 +94,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
}}
|
||||
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
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -92,6 +119,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingGroupRectId(id)
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Layers } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
||||
import { BaseNode } from './BaseNode'
|
||||
|
||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const { data, selected } = props
|
||||
const { id, data, selected } = props
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const theme = THEMES[activeTheme]
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
|
||||
@@ -41,6 +51,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const isOnline = data.status === 'online'
|
||||
const glow = colors.border
|
||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
||||
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -49,7 +60,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
minHeight={160}
|
||||
isVisible={selected}
|
||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
|
||||
/>
|
||||
|
||||
{/* Group border */}
|
||||
@@ -67,7 +78,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
||||
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
|
||||
style={{
|
||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||
@@ -80,7 +91,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
<Layers size={12} />
|
||||
{isBrandIconKey(data.custom_icon)
|
||||
? <NodeIcon typeIcon={Layers} customIconKey={data.custom_icon} size={12} />
|
||||
: createElement(resolvedIcon, { size: 12 })}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span
|
||||
@@ -89,23 +102,45 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
{data.ip && (
|
||||
{data.ip && splitIps(data.ip).map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="font-mono text-[9px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
>
|
||||
{data.ip}
|
||||
{hideIp ? maskIp(ip) : ip}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
</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-15 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 */}
|
||||
<div className="flex-1 relative" />
|
||||
</div>
|
||||
@@ -117,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||
const sourceId = bottomHandleId(idx)
|
||||
const targetId = `${sourceId}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={sourceId}
|
||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id={targetId}
|
||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Cluster handles */}
|
||||
<Handle
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
const FONT_FAMILIES: Record<string, string> = {
|
||||
inter: 'Inter, sans-serif',
|
||||
mono: '"JetBrains Mono", monospace',
|
||||
serif: 'Georgia, serif',
|
||||
sans: 'system-ui, sans-serif',
|
||||
}
|
||||
|
||||
export function TextNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const setEditingTextId = useCanvasStore((s) => s.setEditingTextId)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#30363d'
|
||||
const borderStyle = rc.border_style ?? 'none'
|
||||
const borderWidth = rc.border_width ?? 1
|
||||
const backgroundColor = rc.background ?? 'transparent'
|
||||
const textColor = rc.text_color ?? '#e6edf3'
|
||||
const textSize: number = rc.text_size ?? 14
|
||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||
|
||||
const content = data.text_content ?? data.label ?? ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={40}
|
||||
minHeight={20}
|
||||
handleStyle={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 8,
|
||||
background: backgroundColor,
|
||||
border: borderStyle === 'none' ? 'none' : `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||
borderRadius: 6,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
color: textColor,
|
||||
fontFamily,
|
||||
fontSize: textSize,
|
||||
fontWeight: 500,
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingTextId(id)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { GroupNode } from '../GroupNode'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeProps, Node } from '@xyflow/react'
|
||||
|
||||
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||
const fullData: NodeData = {
|
||||
label: 'Group A',
|
||||
type: 'group',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
...data,
|
||||
}
|
||||
const props = {
|
||||
id: 'g1',
|
||||
data: fullData,
|
||||
selected,
|
||||
type: 'group',
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
dragHandle: undefined,
|
||||
parentId: undefined,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
} as unknown as NodeProps<Node<NodeData>>
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<GroupNode {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('GroupNode', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ nodes: [], hideIp: false })
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
})
|
||||
|
||||
it('renders label', () => {
|
||||
const { getByText } = renderNode({ label: 'My Group' })
|
||||
expect(getByText('My Group')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders 4 source handles (one per side)', () => {
|
||||
const { container } = renderNode()
|
||||
expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders 4 target handles (one per side)', () => {
|
||||
const { container } = renderNode()
|
||||
expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('source handles carry side-specific ids', () => {
|
||||
const { container } = renderNode()
|
||||
expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { ProxmoxGroupNode } from '../ProxmoxGroupNode'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import type { NodeData, NodeProperty } from '@/types'
|
||||
import type { NodeProps, Node } from '@xyflow/react'
|
||||
|
||||
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||
const fullData: NodeData = {
|
||||
label: 'pve-01',
|
||||
type: 'proxmox',
|
||||
status: 'online',
|
||||
services: [],
|
||||
...data,
|
||||
}
|
||||
const props = {
|
||||
id: 'p1',
|
||||
data: fullData,
|
||||
selected,
|
||||
type: 'proxmox',
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
dragHandle: undefined,
|
||||
parentId: undefined,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
} as unknown as NodeProps<Node<NodeData>>
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<ProxmoxGroupNode {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProxmoxGroupNode', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ hideIp: false })
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
})
|
||||
|
||||
it('renders the node label', () => {
|
||||
const { getByText } = renderNode({ label: 'My Proxmox' })
|
||||
expect(getByText('My Proxmox')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders ip when provided', () => {
|
||||
const { getByText } = renderNode({ ip: '192.168.1.10' })
|
||||
expect(getByText('192.168.1.10')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders multiple ips when comma separated', () => {
|
||||
const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' })
|
||||
expect(getByText('10.0.0.1')).toBeDefined()
|
||||
expect(getByText('10.0.0.2')).toBeDefined()
|
||||
})
|
||||
|
||||
it('masks ip when hideIp is enabled in store', () => {
|
||||
useCanvasStore.setState({ hideIp: true })
|
||||
const { queryByText } = renderNode({ ip: '192.168.1.10' })
|
||||
expect(queryByText('192.168.1.10')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders visible properties only', () => {
|
||||
const properties: NodeProperty[] = [
|
||||
{ key: 'CPU', value: '16 cores', icon: null, visible: true },
|
||||
{ key: 'Hidden', value: 'should-not-show', icon: null, visible: false },
|
||||
]
|
||||
const { getByText, queryByText } = renderNode({ properties })
|
||||
expect(getByText('CPU')).toBeDefined()
|
||||
expect(getByText(/16 cores/)).toBeDefined()
|
||||
expect(queryByText('Hidden')).toBeNull()
|
||||
expect(queryByText(/should-not-show/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders status dot with title matching status', () => {
|
||||
const { container } = renderNode({ status: 'offline' })
|
||||
const dot = container.querySelector('[title="offline"]')
|
||||
expect(dot).not.toBeNull()
|
||||
})
|
||||
|
||||
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
|
||||
const { container } = renderNode({ container_mode: false })
|
||||
// NodeResizer should not be present when not group-rendered
|
||||
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
|
||||
})
|
||||
|
||||
it('container_mode default renders the group border container', () => {
|
||||
const { container } = renderNode({})
|
||||
// Group border div has rounded-xl border-2 classes
|
||||
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('container mode renders bottom_handles snap points', () => {
|
||||
const { container } = renderNode({ bottom_handles: 4 })
|
||||
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||
expect(sourceHandles.length).toBe(4)
|
||||
})
|
||||
|
||||
it('container mode default has single bottom handle', () => {
|
||||
const { container } = renderNode({})
|
||||
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||
expect(sourceHandles.length).toBe(1)
|
||||
})
|
||||
|
||||
it('renders cluster handles in both modes', () => {
|
||||
const { container: groupC } = renderNode({})
|
||||
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||
const { container: nodeC } = renderNode({ container_mode: false })
|
||||
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { TextNode } from '../TextNode'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeProps, Node } from '@xyflow/react'
|
||||
|
||||
function renderNode(data: Partial<NodeData> = {}) {
|
||||
const fullData: NodeData = {
|
||||
label: '',
|
||||
type: 'text',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
text_content: 'Hello',
|
||||
...data,
|
||||
}
|
||||
const props = {
|
||||
id: 't1',
|
||||
data: fullData,
|
||||
selected: false,
|
||||
type: 'text',
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
width: 200,
|
||||
height: 60,
|
||||
dragHandle: undefined,
|
||||
parentId: undefined,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
} as unknown as NodeProps<Node<NodeData>>
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<TextNode {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TextNode', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ editingTextId: null })
|
||||
})
|
||||
|
||||
it('renders text_content', () => {
|
||||
const { getByText } = renderNode({ text_content: 'My label' })
|
||||
expect(getByText('My label')).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to label when text_content is missing', () => {
|
||||
const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' })
|
||||
expect(getByText('Fallback')).toBeDefined()
|
||||
})
|
||||
|
||||
it('double-click sets editingTextId in store', () => {
|
||||
const { getByText } = renderNode({ text_content: 'Edit me' })
|
||||
fireEvent.doubleClick(getByText('Edit me'))
|
||||
expect(useCanvasStore.getState().editingTextId).toBe('t1')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -10,6 +10,7 @@ type N = NodeProps<Node<NodeData>>
|
||||
|
||||
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
||||
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
|
||||
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||
@@ -22,5 +23,10 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const 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} />
|
||||
// Zigbee node types
|
||||
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
import { TextNode } from './TextNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
router: RouterNode,
|
||||
firewall: FirewallNode,
|
||||
switch: SwitchNode,
|
||||
server: ServerNode,
|
||||
proxmox: ProxmoxGroupNode,
|
||||
@@ -18,8 +20,13 @@ export const nodeTypes = {
|
||||
printer: PrinterNode,
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
docker: DockerNode,
|
||||
docker_host: DockerHostNode,
|
||||
docker_container: DockerContainerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
group: GroupNode,
|
||||
text: TextNode,
|
||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||
zigbee_router: ZigbeeRouterNode,
|
||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
|
||||
import dashboardIcons from '@/data/dashboardIcons.json'
|
||||
|
||||
const SLUGS: string[] = dashboardIcons as string[]
|
||||
const PAGE = 120
|
||||
|
||||
interface BrandIconPickerProps {
|
||||
value?: string
|
||||
onSelect: (key: string) => void
|
||||
}
|
||||
|
||||
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [limit, setLimit] = useState(PAGE)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return SLUGS
|
||||
return SLUGS.filter((s) => s.includes(q))
|
||||
}, [query])
|
||||
|
||||
const visible = filtered.slice(0, limit)
|
||||
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
|
||||
placeholder={`Search ${SLUGS.length} brand icons...`}
|
||||
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
|
||||
aria-label="Brand icon search"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground/60">
|
||||
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{visible.map((slug) => {
|
||||
const selected = slug === selectedSlug
|
||||
return (
|
||||
<button
|
||||
key={slug}
|
||||
type="button"
|
||||
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
|
||||
title={slug}
|
||||
aria-label={slug}
|
||||
aria-pressed={selected}
|
||||
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/10'
|
||||
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={brandIconUrl(slug)}
|
||||
alt={slug}
|
||||
loading="lazy"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{filtered.length > limit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLimit((l) => l + PAGE)}
|
||||
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
|
||||
>
|
||||
Load more ({filtered.length - limit} remaining)
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
||||
Radio, Zap, Lightbulb,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
import type {
|
||||
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
|
||||
} from '@/types'
|
||||
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||
|
||||
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
|
||||
|
||||
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
||||
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
||||
'generic',
|
||||
]
|
||||
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
|
||||
const NODE_ICONS: Record<string, LucideIcon> = {
|
||||
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
||||
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||
docker_host: Anchor, docker_container: Package,
|
||||
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
// ── Default style for a node type (from default theme) ─────────────────────
|
||||
|
||||
function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle {
|
||||
const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic
|
||||
return {
|
||||
borderColor: accent.border,
|
||||
borderOpacity: 1,
|
||||
bgColor: THEMES.default.colors.nodeCardBackground,
|
||||
bgOpacity: 1,
|
||||
iconColor: accent.icon,
|
||||
iconOpacity: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
||||
return {
|
||||
color: THEMES.default.colors.edgeColors[edgeType],
|
||||
opacity: 1,
|
||||
pathStyle: 'bezier',
|
||||
animated: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color + opacity row ──────────────────────────────────────────────────────
|
||||
|
||||
interface ColorRowProps {
|
||||
label: string
|
||||
color: string
|
||||
opacity: number
|
||||
onColorChange: (v: string) => void
|
||||
onOpacityChange: (v: number) => void
|
||||
}
|
||||
|
||||
function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#8b949e] w-20 shrink-0">{label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={opacity}
|
||||
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
|
||||
className="flex-1 h-1 accent-[#00d4ff]"
|
||||
/>
|
||||
<span className="text-xs text-[#8b949e] w-8 text-right">
|
||||
{Math.round(opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-5 h-5 rounded border border-[#30363d] shrink-0"
|
||||
style={{ background: applyOpacity(color, opacity) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Node type editor ─────────────────────────────────────────────────────────
|
||||
|
||||
interface NodeEditorProps {
|
||||
nodeType: NodeType
|
||||
style: NodeTypeStyle
|
||||
onChange: (s: NodeTypeStyle) => void
|
||||
onApplyToExisting: () => void
|
||||
}
|
||||
|
||||
function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) {
|
||||
const set = useCallback(<K extends keyof NodeTypeStyle>(k: K, v: NodeTypeStyle[K]) => {
|
||||
onChange({ ...style, [k]: v })
|
||||
}, [style, onChange])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-sm font-semibold text-[#e6edf3]">{NODE_TYPE_LABELS[nodeType]}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ColorRow
|
||||
label="Border"
|
||||
color={style.borderColor}
|
||||
opacity={style.borderOpacity}
|
||||
onColorChange={(v) => set('borderColor', v)}
|
||||
onOpacityChange={(v) => set('borderOpacity', v)}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Background"
|
||||
color={style.bgColor}
|
||||
opacity={style.bgOpacity}
|
||||
onColorChange={(v) => set('bgColor', v)}
|
||||
onOpacityChange={(v) => set('bgOpacity', v)}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Icon"
|
||||
color={style.iconColor}
|
||||
opacity={style.iconOpacity}
|
||||
onColorChange={(v) => set('iconColor', v)}
|
||||
onOpacityChange={(v) => set('iconOpacity', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#30363d] pt-3">
|
||||
<div className="text-xs text-[#8b949e] mb-1">Default size</div>
|
||||
<div className="text-xs text-[#8b949e]/60 mb-2">0 = auto (min 140 × 50 px, grows with content)</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[#8b949e]">W</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={style.width}
|
||||
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
|
||||
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[#8b949e]">H</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={style.height}
|
||||
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
|
||||
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={onApplyToExisting}
|
||||
>
|
||||
Apply to existing {NODE_TYPE_LABELS[nodeType]} nodes
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Edge type editor ─────────────────────────────────────────────────────────
|
||||
|
||||
interface EdgeEditorProps {
|
||||
edgeType: EdgeType
|
||||
style: EdgeTypeStyle
|
||||
onChange: (s: EdgeTypeStyle) => void
|
||||
onApplyToExisting: () => void
|
||||
}
|
||||
|
||||
function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) {
|
||||
const set = useCallback(<K extends keyof EdgeTypeStyle>(k: K, v: EdgeTypeStyle[K]) => {
|
||||
onChange({ ...style, [k]: v })
|
||||
}, [style, onChange])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-sm font-semibold text-[#e6edf3]">{EDGE_TYPE_LABELS[edgeType]}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ColorRow
|
||||
label="Color"
|
||||
color={style.color}
|
||||
opacity={style.opacity}
|
||||
onColorChange={(v) => set('color', v)}
|
||||
onOpacityChange={(v) => set('opacity', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
|
||||
<div className="flex gap-2">
|
||||
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => (
|
||||
<button
|
||||
key={ps}
|
||||
type="button"
|
||||
onClick={() => set('pathStyle', ps)}
|
||||
className="px-3 py-1 text-xs rounded border transition-colors"
|
||||
style={{
|
||||
borderColor: style.pathStyle === ps ? '#00d4ff' : '#30363d',
|
||||
background: style.pathStyle === ps ? '#00d4ff22' : 'transparent',
|
||||
color: style.pathStyle === ps ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{ps.charAt(0).toUpperCase() + ps.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-[#8b949e] mb-2">Animation</div>
|
||||
<select
|
||||
value={style.animated}
|
||||
onChange={(e) => set('animated', e.target.value as EdgeTypeStyle['animated'])}
|
||||
className="w-full h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="flow">Flow</option>
|
||||
<option value="snake">Snake</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={onApplyToExisting}
|
||||
>
|
||||
Apply to existing {EDGE_TYPE_LABELS[edgeType]} edges
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = 'nodes' | 'edges'
|
||||
type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null
|
||||
|
||||
interface CustomStyleModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
||||
const { customStyle, setCustomStyle } = useThemeStore()
|
||||
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
||||
|
||||
const [tab, setTab] = useState<Tab>('nodes')
|
||||
const [selection, setSelection] = useState<Selection>(null)
|
||||
const [draft, setDraft] = useState<CustomStyleDef>(() => ({
|
||||
nodes: { ...customStyle.nodes },
|
||||
edges: { ...customStyle.edges },
|
||||
}))
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
// Reset draft to current saved customStyle on open
|
||||
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||
setSelection(null)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
|
||||
draft.nodes[t] ?? defaultNodeStyle(t)
|
||||
|
||||
const getEdgeStyle = (t: EdgeType): EdgeTypeStyle =>
|
||||
draft.edges[t] ?? defaultEdgeStyle(t)
|
||||
|
||||
const handleNodeChange = (t: NodeType, s: NodeTypeStyle) =>
|
||||
setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } }))
|
||||
|
||||
const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) =>
|
||||
setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } }))
|
||||
|
||||
const handleApplyNodeType = (t: NodeType) => {
|
||||
const style = getNodeStyle(t)
|
||||
applyTypeNodeStyle(t, style)
|
||||
toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`)
|
||||
}
|
||||
|
||||
const handleApplyEdgeType = (t: EdgeType) => {
|
||||
const style = getEdgeStyle(t)
|
||||
applyTypeEdgeStyle(t, style)
|
||||
toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setCustomStyle(draft)
|
||||
markUnsaved()
|
||||
toast.success('Custom style saved — save your canvas to persist')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleApplyAll = () => {
|
||||
setCustomStyle(draft)
|
||||
applyAllCustomStyles(draft)
|
||||
markUnsaved()
|
||||
toast.success('Custom style applied to all nodes and edges')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null
|
||||
const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b border-[#30363d]">
|
||||
<DialogTitle className="text-sm font-semibold">Custom Style Editor</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||
{/* Left panel — type list */}
|
||||
<div className="w-52 shrink-0 border-r border-[#30363d] flex flex-col overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[#30363d]">
|
||||
{(['nodes', 'edges'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => { setTab(t); setSelection(null) }}
|
||||
className="flex-1 py-2 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
borderBottom: tab === t ? '2px solid #00d4ff' : '2px solid transparent',
|
||||
color: tab === t ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Type list */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
|
||||
const Icon = NODE_ICONS[t] ?? Circle
|
||||
const style = draft.nodes[t]
|
||||
const isSelected = selection?.kind === 'node' && selection.type === t
|
||||
const swatchColor = style
|
||||
? applyOpacity(style.borderColor, style.borderOpacity)
|
||||
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||
style={{
|
||||
background: isSelected ? '#21262d' : 'transparent',
|
||||
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ background: swatchColor }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
||||
const style = draft.edges[t]
|
||||
const isSelected = selection?.kind === 'edge' && selection.type === t
|
||||
const swatchColor = style
|
||||
? applyOpacity(style.color, style.opacity)
|
||||
: THEMES.default.colors.edgeColors[t]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: 'edge', type: t })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||
style={{
|
||||
background: isSelected ? '#21262d' : 'transparent',
|
||||
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
|
||||
<span
|
||||
className="w-8 h-1.5 rounded-full shrink-0"
|
||||
style={{ background: swatchColor }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — editor */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{!selection && (
|
||||
<div className="flex items-center justify-center h-full text-xs text-[#8b949e]">
|
||||
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection?.kind === 'node' && selectedNodeStyle && (
|
||||
<NodeEditor
|
||||
key={selection.type}
|
||||
nodeType={selection.type}
|
||||
style={selectedNodeStyle}
|
||||
onChange={(s) => handleNodeChange(selection.type, s)}
|
||||
onApplyToExisting={() => handleApplyNodeType(selection.type)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection?.kind === 'edge' && selectedEdgeStyle && (
|
||||
<EdgeEditor
|
||||
key={selection.type}
|
||||
edgeType={selection.type}
|
||||
style={selectedEdgeStyle}
|
||||
onChange={(s) => handleEdgeChange(selection.type, s)}
|
||||
onApplyToExisting={() => handleApplyEdgeType(selection.type)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between gap-2 px-5 py-3 border-t border-[#30363d]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-[#30363d] text-[#e6edf3] hover:bg-[#21262d]"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Custom Style
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={handleApplyAll}
|
||||
>
|
||||
Apply All to Canvas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -68,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
|
||||
<SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{EDGE_TYPES.map(([value, label]) => (
|
||||
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
value={vlanId}
|
||||
onChange={(e) => setVlanId(e.target.value)}
|
||||
placeholder="e.g. 20"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. 1G, trunk..."
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
||||
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setPathStyle(style)}
|
||||
className="flex-1 py-1 text-xs capitalize transition-colors"
|
||||
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||
tabIndex={0}
|
||||
aria-label={`Path style ${style}`}
|
||||
style={{
|
||||
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
||||
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
||||
@@ -127,13 +130,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
||||
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setAnimation(mode)}
|
||||
className="flex-1 py-1 text-xs capitalize transition-colors"
|
||||
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||
tabIndex={0}
|
||||
aria-label={`Animation mode ${mode}`}
|
||||
style={{
|
||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer"
|
||||
className={`relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
||||
tabIndex={0}
|
||||
aria-label="Edge color picker"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||
<Button type="button" variant="ghost" size="sm" className="cursor-pointer" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||
{onDelete ? 'Save' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Slider container: strip native chrome so custom track/thumb align cleanly */
|
||||
.slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
height: 13px; /* match thumb height so vertical centering is the input's box center */
|
||||
}
|
||||
.slider-thumb:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.slider-accent::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: #00d4ff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.slider-accent::-moz-range-track {
|
||||
height: 4px;
|
||||
background: #00d4ff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Thumb — must offset on webkit so it centers on the 4px track */
|
||||
.slider-thumb::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #00d4ff;
|
||||
border: 2px solid #21262d;
|
||||
margin-top: -4.5px; /* (13 - 4) / 2 */
|
||||
}
|
||||
.slider-thumb::-moz-range-thumb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #00d4ff;
|
||||
border: 2px solid #21262d;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { TextPosition } from '@/types'
|
||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||
import styles from './GroupRectModal.module.css'
|
||||
|
||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
@@ -86,6 +89,8 @@ const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
|
||||
{ value: 'bottom-right', label: '↘' },
|
||||
]
|
||||
|
||||
const getFontLabel = (value: string) => FONTS.find((f) => f.value === value)?.label ?? value
|
||||
|
||||
interface GroupRectModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -128,7 +133,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
value={form.label}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
placeholder="Zone name…"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -136,8 +141,10 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Font selector">
|
||||
<SelectValue>
|
||||
{getFontLabel(form.font)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{FONTS.map((f) => (
|
||||
@@ -161,7 +168,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
type="button"
|
||||
title={value}
|
||||
onClick={() => set('text_position', value)}
|
||||
className="h-8 rounded text-base transition-colors"
|
||||
className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Text position ${label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -186,7 +194,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('label_position', value)}
|
||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
||||
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Label position ${label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -204,23 +213,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{colorFields.map(({ key, label }) => (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
{colorFields.map(({ key, label }) => {
|
||||
const { hex6, alpha } = hexToRgba(form[key])
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={hex6}
|
||||
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={form[key]}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={alpha}
|
||||
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||
className={`w-full cursor-pointer mt-2 ${styles['slider-thumb']} ${styles['slider-accent']}`}
|
||||
title={`Opacity: ${alpha}%`}
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,7 +256,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('text_size', value)}
|
||||
className="flex items-center justify-center h-8 rounded transition-colors"
|
||||
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Text size ${label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -262,7 +284,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
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"
|
||||
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Border style ${label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -288,7 +311,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('border_width', value)}
|
||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
||||
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Border width ${label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -306,7 +330,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Z-order selector">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
@@ -325,17 +349,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||
onClick={() => { onDelete(); onClose() }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||
Cancel
|
||||
</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 cursor-pointer">
|
||||
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { Fragment, createElement, useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -7,16 +8,33 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
||||
|
||||
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> = {
|
||||
type: 'server',
|
||||
@@ -26,29 +44,44 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
||||
status: 'unknown',
|
||||
check_method: 'ping',
|
||||
services: [],
|
||||
container_mode: true,
|
||||
container_mode: false,
|
||||
custom_colors: undefined,
|
||||
custom_icon: undefined,
|
||||
}
|
||||
|
||||
interface ParentCandidate {
|
||||
id: string
|
||||
label: string
|
||||
type: NodeType
|
||||
}
|
||||
|
||||
interface NodeModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: Partial<NodeData>) => void
|
||||
initial?: Partial<NodeData>
|
||||
title?: string
|
||||
proxmoxNodes?: { id: string; label: string }[]
|
||||
parentCandidates?: ParentCandidate[]
|
||||
currentNodeId?: string
|
||||
}
|
||||
|
||||
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
||||
|
||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||
// initial value is enough — no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
// initial value is enough - no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
||||
const merged = { ...DEFAULT_DATA, ...initial }
|
||||
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||
const hasAppearanceOverrides = Boolean(
|
||||
form.custom_colors?.border
|
||||
|| form.custom_colors?.background
|
||||
|| form.custom_colors?.icon
|
||||
)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
@@ -60,13 +93,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
return
|
||||
}
|
||||
setLabelError(false)
|
||||
onSubmit(form)
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
const validParentTypes = getValidParentTypes(selectedType)
|
||||
let safeParentId = form.parent_id
|
||||
if (validParentTypes.length === 0) {
|
||||
safeParentId = undefined
|
||||
} else if (safeParentId) {
|
||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
||||
}
|
||||
onSubmit({
|
||||
...form,
|
||||
parent_id: safeParentId,
|
||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<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 max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -76,15 +123,23 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
{/* Type + Icon on the same row */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
||||
<SelectValue />
|
||||
<Select value={form.type} onValueChange={(v) => {
|
||||
const t = v as NodeType
|
||||
setForm((f) => {
|
||||
const next: Partial<NodeData> = { ...f, type: t }
|
||||
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
||||
return next
|
||||
})
|
||||
}}>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
||||
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||
<>
|
||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||
<SelectGroup key={group.label}>
|
||||
<Fragment key={group.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>
|
||||
@@ -94,7 +149,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -118,10 +173,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<button
|
||||
type="button"
|
||||
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 w-full"
|
||||
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
|
||||
aria-label="Icon picker trigger"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{(() => {
|
||||
if (isBrandIconKey(form.custom_icon)) {
|
||||
const slug = brandIconSlug(form.custom_icon!)
|
||||
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
|
||||
}
|
||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||
if (entry) {
|
||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||
@@ -134,14 +194,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline icon picker — full width, shown below the type+icon row */}
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'generic'}
|
||||
onClick={() => setIconTab('generic')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Generic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'brand'}
|
||||
onClick={() => setIconTab('brand')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
{iconTab === 'brand' ? (
|
||||
<BrandIconPicker
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
@@ -163,7 +254,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
@@ -181,6 +273,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -191,7 +285,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.label ?? ''}
|
||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||
placeholder="My Server"
|
||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||
</div>
|
||||
@@ -203,7 +297,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.hostname ?? ''}
|
||||
onChange={(e) => set('hostname', e.target.value)}
|
||||
placeholder="server.lan"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -213,76 +307,127 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<Input
|
||||
value={form.ip ?? ''}
|
||||
onChange={(e) => set('ip', e.target.value)}
|
||||
placeholder="192.168.1.x"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
placeholder="192.168.1.x, 2001:db8::1"
|
||||
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||
</div>
|
||||
|
||||
{/* Check method */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{CHECK_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Check target */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
||||
<Input
|
||||
value={form.check_target ?? ''}
|
||||
onChange={(e) => set('check_target', e.target.value)}
|
||||
placeholder="http://..."
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parent Proxmox (VM / LXC only) */}
|
||||
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
|
||||
<Select
|
||||
value={form.parent_id ?? 'none'}
|
||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue placeholder="None (standalone)" />
|
||||
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
||||
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
||||
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
||||
{proxmoxNodes.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||
{CHECK_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container mode (proxmox only) */}
|
||||
{form.type === 'proxmox' && (
|
||||
{/* Check target — hidden for zigbee nodes */}
|
||||
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
||||
<Input
|
||||
value={form.check_target ?? ''}
|
||||
onChange={(e) => set('check_target', e.target.value)}
|
||||
placeholder="http://..."
|
||||
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent Container */}
|
||||
{(() => {
|
||||
const childType = (form.type ?? 'generic') as NodeType
|
||||
const validParentTypes = getValidParentTypes(childType)
|
||||
if (validParentTypes.length === 0) return null
|
||||
const validParents = parentCandidates.filter(
|
||||
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
||||
)
|
||||
if (validParents.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||
<Select
|
||||
value={form.parent_id ?? 'none'}
|
||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
||||
<SelectValue>
|
||||
{form.parent_id
|
||||
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
|
||||
: 'None'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="none" className="text-sm">None</SelectItem>
|
||||
{validParents.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Container mode */}
|
||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="Container Mode"
|
||||
aria-checked={!!form.container_mode}
|
||||
onClick={() => set('container_mode', !form.container_mode)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||
style={{
|
||||
transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service visibility */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex items-start justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Show Services</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Display discovered services on the node card</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!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"
|
||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||
aria-label="Show Services"
|
||||
aria-checked={showServicesEnabled}
|
||||
onClick={() => set('custom_colors', {
|
||||
...form.custom_colors,
|
||||
show_services: !showServicesEnabled,
|
||||
})}
|
||||
className="relative inline-flex h-5 w-9 mt-1 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
||||
style={{ background: showServicesEnabled ? resolvedNodeColors.icon : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
className="pointer-events-none absolute top-px left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -292,10 +437,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<div className="flex flex-col gap-2 col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
||||
{form.custom_colors && (
|
||||
{hasAppearanceOverrides && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('custom_colors', undefined)}
|
||||
onClick={() => setForm((f) => {
|
||||
if (!f.custom_colors) return f
|
||||
const { border, background, icon, ...rest } = f.custom_colors
|
||||
void border
|
||||
void background
|
||||
void icon
|
||||
return {
|
||||
...f,
|
||||
custom_colors: Object.keys(rest).length > 0 ? rest : undefined,
|
||||
}
|
||||
})}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} /> Reset to defaults
|
||||
@@ -310,9 +465,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all"
|
||||
className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
|
||||
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
||||
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
||||
tabIndex={0}
|
||||
aria-label={`Color picker for ${key}`}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
@@ -327,29 +484,34 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!form.custom_colors && (
|
||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||
)}
|
||||
<div className="min-h-3.5">
|
||||
{!hasAppearanceOverrides && (
|
||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||
)}
|
||||
</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 className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_BOTTOM_HANDLES}
|
||||
max={MAX_BOTTOM_HANDLES}
|
||||
step={1}
|
||||
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||
aria-label="Bottom connection points slider"
|
||||
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -360,25 +522,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Optional notes"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
>
|
||||
{title === 'Add Node' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{/* Show delete button only for edit mode (not add) */}
|
||||
{title !== 'Add Node' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete this node?')) {
|
||||
onSubmit({ ...form, _delete: true })
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
style={{ minWidth: 64 }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer"
|
||||
>
|
||||
{title === 'Add Node' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ interface Service {
|
||||
|
||||
export interface PendingDevice {
|
||||
id: string
|
||||
ip: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
@@ -20,6 +20,12 @@ export interface PendingDevice {
|
||||
suggested_type: string | null
|
||||
status: string
|
||||
discovery_source: string | null
|
||||
ieee_address?: string | null
|
||||
friendly_name?: string | null
|
||||
device_subtype?: string | null
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
discovered_at: string
|
||||
}
|
||||
|
||||
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
if (!device) return null
|
||||
|
||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const isZigbee = device.discovery_source === 'zigbee'
|
||||
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
|
||||
const handleApprove = () => { onApprove(device) }
|
||||
const handleHide = () => { onHide(device); onClose() }
|
||||
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||
{device.hostname ?? device.ip}
|
||||
{titleLabel}
|
||||
{isZigbee && (
|
||||
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
|
||||
Zigbee
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
{/* Device info */}
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||
<InfoRow label="IP" value={device.ip} />
|
||||
{device.ip && <InfoRow label="IP" value={device.ip} />}
|
||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
|
||||
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||
<InfoRow label="Name" value={device.friendly_name} />
|
||||
)}
|
||||
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||
{device.suggested_type && (
|
||||
<InfoRow label="Type" value={device.suggested_type} />
|
||||
)}
|
||||
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||
{!isZigbee && <div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Services found ({device.services.length})
|
||||
</p>
|
||||
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import {
|
||||
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
highlightId?: string
|
||||
initialStatus?: 'pending' | 'hidden'
|
||||
}
|
||||
|
||||
const PORT_COLORS: Record<number, string> = {
|
||||
22: '#a855f7', // SSH purple
|
||||
80: '#00d4ff', // HTTP cyan
|
||||
443: '#39d353', // HTTPS green
|
||||
53: '#e3b341', // DNS amber
|
||||
3306: '#a855f7', // MySQL
|
||||
5432: '#a855f7', // Postgres
|
||||
6379: '#f85149', // Redis
|
||||
9090: '#e3b341', // Prometheus
|
||||
3000: '#00d4ff', // Grafana/dev
|
||||
8080: '#00d4ff',
|
||||
8443: '#39d353',
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
hypervisor: '#ff6e00',
|
||||
nas: '#39d353',
|
||||
automation: '#a855f7',
|
||||
containers: '#00d4ff',
|
||||
network: '#39d353',
|
||||
security: '#f85149',
|
||||
monitoring: '#e3b341',
|
||||
database: '#a855f7',
|
||||
web: '#00d4ff',
|
||||
media: '#ff6e00',
|
||||
iot: '#e3b341',
|
||||
}
|
||||
|
||||
function serviceColor(port: number | null | undefined, category?: string | null): string {
|
||||
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
|
||||
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
|
||||
return '#8b949e'
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
switch: Network,
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
||||
type StatusFilter = 'pending' | 'hidden'
|
||||
|
||||
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
||||
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
||||
return 'ip'
|
||||
}
|
||||
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
|
||||
function specialServiceName(d: PendingDevice): string | undefined {
|
||||
const candidates = (d.services ?? []).filter(
|
||||
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||
)
|
||||
// Deprioritize generic web category so apps like home assistant / jellyfin win
|
||||
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
|
||||
return (nonWeb ?? candidates[0])?.service_name ?? undefined
|
||||
}
|
||||
|
||||
function deviceLabel(d: PendingDevice): string {
|
||||
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
||||
if (!edges || edges.length === 0) return
|
||||
useCanvasStore.setState((state) => ({
|
||||
edges: [
|
||||
...state.edges,
|
||||
...edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: 'bottom',
|
||||
targetHandle: 'top-t',
|
||||
type: 'iot',
|
||||
data: { type: 'iot' as const },
|
||||
})),
|
||||
],
|
||||
hasUnsavedChanges: true,
|
||||
}))
|
||||
}
|
||||
|
||||
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||
setDevices(res.data)
|
||||
} catch {
|
||||
toast.error(`Failed to load ${statusFilter} devices`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
|
||||
useEffect(() => { if (open) load() }, [open, load])
|
||||
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||
|
||||
// Reset transient state when reopening
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
setSearch('')
|
||||
} else {
|
||||
setStatusFilter(initialStatus)
|
||||
}
|
||||
}, [open, initialStatus])
|
||||
|
||||
const distinctTypes = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
|
||||
return [...set].sort()
|
||||
}, [devices])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return devices.filter((d) => {
|
||||
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
||||
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||
if (q) {
|
||||
const hay = [
|
||||
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||
...d.services.map((s) => s.service_name),
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [devices, search, sourceFilter, typeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId || loading || !open) return
|
||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [highlightId, loading, open, filtered])
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleCardClick = (d: PendingDevice) => {
|
||||
if (selectMode) { toggleSelect(d.id); return }
|
||||
if (statusFilter === 'hidden') { handleRestore(d); return }
|
||||
setSelected(d)
|
||||
}
|
||||
|
||||
const handleRestore = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.restore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
toast.success(`Restored ${deviceLabel(device)}`)
|
||||
} catch {
|
||||
toast.error('Failed to restore device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkRestore = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkRestore(ids)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk restore devices')
|
||||
}
|
||||
}
|
||||
|
||||
const enterSelectMode = () => {
|
||||
setSelectMode(true)
|
||||
}
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const selectAllVisible = () => {
|
||||
setSelectedIds(new Set(filtered.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
const targets = filtered
|
||||
if (targets.length === 0) return
|
||||
const filtersActive = targets.length !== devices.length
|
||||
try {
|
||||
if (filtersActive) {
|
||||
const results = await Promise.allSettled(targets.map((d) => scanApi.ignore(d.id)))
|
||||
const failed = results.filter((r) => r.status === 'rejected').length
|
||||
const removedIds = new Set(
|
||||
targets.filter((_, i) => results[i].status === 'fulfilled').map((d) => d.id)
|
||||
)
|
||||
setDevices((prev) => prev.filter((d) => !removedIds.has(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
|
||||
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
|
||||
} else {
|
||||
await scanApi.clearPending()
|
||||
setDevices([])
|
||||
setSelectedIds(new Set())
|
||||
toast.success('Pending devices cleared')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to clear pending devices')
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async (device: PendingDevice) => {
|
||||
try {
|
||||
const fallbackLabel = deviceLabel(device)
|
||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||
const zigbee = isZigbeeType(type)
|
||||
const properties = zigbee ? buildZigbeeProperties(device) : []
|
||||
const nodeData = {
|
||||
label: fallbackLabel,
|
||||
type,
|
||||
ip: device.ip ?? undefined,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: zigbee ? 'online' : 'unknown',
|
||||
services: (device.services ?? []) as ServiceInfo[],
|
||||
properties,
|
||||
}
|
||||
const res = await scanApi.approve(device.id, nodeData)
|
||||
const nodeId = res.data.node_id
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type: nodeData.type,
|
||||
position: { x: 400, y: 300 },
|
||||
data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
} catch {
|
||||
toast.error('Failed to approve device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHide = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.hide(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
toast.success('Device hidden')
|
||||
} catch {
|
||||
toast.error('Failed to hide device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.ignore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
} catch {
|
||||
toast.error('Failed to remove device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkApprove(ids)
|
||||
const deviceToNode: Record<string, string> = {}
|
||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||
approvedDevices.forEach((d, i) => {
|
||||
const nodeId = deviceToNode[d.id]
|
||||
if (!nodeId) return
|
||||
const type = (d.suggested_type ?? 'generic') as NodeType
|
||||
const zigbee = isZigbeeType(type)
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type,
|
||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||
data: {
|
||||
label: deviceLabel(d),
|
||||
type,
|
||||
ip: d.ip ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||
services: (d.services ?? []) as ServiceInfo[],
|
||||
properties: zigbee ? buildZigbeeProperties(d) : [],
|
||||
},
|
||||
})
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk approve devices')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkHide = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkHide(ids)
|
||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||
setSelectedIds(new Set())
|
||||
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to bulk hide devices')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
|
||||
if (e.key === 'Escape') {
|
||||
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
|
||||
return
|
||||
}
|
||||
if (inField) return
|
||||
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
||||
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
||||
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, selectMode, selectedIds, filtered])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="!max-w-none w-[95vw] h-[90vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||
>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{statusFilter === 'pending' && devices.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
|
||||
title={filtered.length !== devices.length ? `Remove ${filtered.length} filtered` : 'Clear all pending'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, IP, MAC, IEEE, service…"
|
||||
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
|
||||
<button
|
||||
onClick={() => setSourceFilter('all')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('ip')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
IP scan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('zigbee')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Zigbee
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||
aria-label="Type filter"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<div className="flex rounded border border-border overflow-hidden text-xs">
|
||||
<button
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Pending
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('hidden')}
|
||||
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Hidden
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||
title="Toggle select mode (s)"
|
||||
>
|
||||
{selectMode ? 'Exit select' : 'Select mode'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-10">
|
||||
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||
{filtered.map((d) => (
|
||||
<DeviceCard
|
||||
key={d.id}
|
||||
device={d}
|
||||
selected={selectedIds.has(d.id)}
|
||||
selectMode={selectMode}
|
||||
highlighted={d.id === highlightId}
|
||||
onClick={() => handleCardClick(d)}
|
||||
cardRef={d.id === highlightId ? highlightRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection action bar */}
|
||||
{selectMode && (
|
||||
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mr-1">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={selectAllVisible}
|
||||
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Select all visible ({filtered.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{statusFilter === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleBulkApprove}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Approve ({selectedIds.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkHide}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Hide ({selectedIds.size})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{statusFilter === 'hidden' && (
|
||||
<button
|
||||
onClick={handleBulkRestore}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Restore ({selectedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PendingDeviceModal
|
||||
device={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onApprove={handleApprove}
|
||||
onHide={handleHide}
|
||||
onIgnore={handleIgnore}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: PendingDevice
|
||||
selected: boolean
|
||||
selectMode: boolean
|
||||
highlighted: boolean
|
||||
onClick: () => void
|
||||
cardRef?: React.Ref<HTMLButtonElement>
|
||||
}
|
||||
|
||||
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||
const source = inferSource(device)
|
||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const label = deviceLabel(device)
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const services = device.services ?? []
|
||||
const visibleServices = services.slice(0, 4)
|
||||
const moreServices = services.length - visibleServices.length
|
||||
|
||||
const borderClass = highlighted
|
||||
? 'border-[#e3b341] bg-[#2d3748]'
|
||||
: selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
|
||||
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={cardRef}
|
||||
onClick={onClick}
|
||||
data-testid={`pending-card-${device.id}`}
|
||||
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
|
||||
>
|
||||
{selectMode && selected && (
|
||||
<CheckCircle2
|
||||
size={18}
|
||||
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
||||
/>
|
||||
)}
|
||||
{!selectMode && device.status === 'hidden' && (
|
||||
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<span
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${sourceColor}22`, color: sourceColor }}
|
||||
>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
{device.suggested_type && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||
{device.suggested_type}
|
||||
</span>
|
||||
)}
|
||||
{device.lqi != null && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||
LQI {device.lqi}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tech grid */}
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
|
||||
{device.ip && <InfoLine label="IP" value={device.ip} />}
|
||||
{device.mac && <InfoLine label="MAC" value={device.mac} />}
|
||||
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
|
||||
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
|
||||
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
|
||||
{device.model && <InfoLine label="Model" value={device.model} />}
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{visibleServices.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{visibleServices.map((s, i) => {
|
||||
const color = serviceColor(s.port, s.category)
|
||||
return (
|
||||
<span
|
||||
key={`${s.port}-${s.protocol}-${i}`}
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={{ background: `${color}22`, color }}
|
||||
title={`${s.service_name} (${s.protocol}/${s.port})`}
|
||||
>
|
||||
{s.service_name}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{moreServices > 0 && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
|
||||
+{moreServices}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
|
||||
<span className="font-mono text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,8 +33,10 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
).slice(0, 6)
|
||||
|
||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||
d.ip.toLowerCase().includes(q) ||
|
||||
d.ip?.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.friendly_name?.toLowerCase().includes(q) ||
|
||||
d.ieee_address?.toLowerCase().includes(q) ||
|
||||
d.services.some((s) =>
|
||||
s.service_name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||
|
||||
export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
export interface TextFormData {
|
||||
text: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_size: number
|
||||
border_color: string
|
||||
border_style: TextBorderStyle
|
||||
border_width: number
|
||||
background_color: string
|
||||
}
|
||||
|
||||
const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [
|
||||
{ value: 'none', label: 'None', preview: ' ' },
|
||||
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||
{ value: 'double', label: 'Double', preview: '═══' },
|
||||
]
|
||||
|
||||
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 12, label: '12' },
|
||||
{ value: 14, label: '14' },
|
||||
{ value: 18, label: '18' },
|
||||
{ value: 24, label: '24' },
|
||||
{ value: 32, label: '32' },
|
||||
]
|
||||
|
||||
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 FONTS = [
|
||||
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||
{ value: 'mono', label: 'JetBrains Mono' },
|
||||
{ value: 'serif', label: 'Serif' },
|
||||
{ value: 'sans', label: 'System Sans' },
|
||||
]
|
||||
|
||||
const DEFAULT_FORM: TextFormData = {
|
||||
text: '',
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_size: 14,
|
||||
border_color: '#30363d',
|
||||
border_style: 'none',
|
||||
border_width: 1,
|
||||
background_color: '#00000000',
|
||||
}
|
||||
|
||||
interface TextModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: TextFormData) => void
|
||||
onDelete?: () => void
|
||||
initial?: Partial<TextFormData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) {
|
||||
const [form, setForm] = useState<TextFormData>({ ...DEFAULT_FORM, ...initial })
|
||||
|
||||
const set = <K extends keyof TextFormData>(key: K, value: TextFormData[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const colorFields = [
|
||||
{ key: 'text_color' as const, label: 'Text' },
|
||||
{ key: 'border_color' as const, label: 'Border' },
|
||||
{ key: 'background_color' as const, label: 'Background' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||
{/* Text content */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||
<textarea
|
||||
value={form.text}
|
||||
onChange={(e) => set('text', e.target.value)}
|
||||
placeholder="Type text…"
|
||||
rows={3}
|
||||
className={`bg-[#21262d] border border-[#30363d] text-sm p-2 resize-y min-h-[60px] focus:outline-none focus:border-[#00d4ff] ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font (Police) */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Police</Label>
|
||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
||||
<SelectValue>
|
||||
{FONTS.find((f) => f.value === form.font)?.label ?? form.font}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{FONTS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{colorFields.map(({ key, label }) => {
|
||||
const { hex6, alpha } = hexToRgba(form[key])
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={hex6}
|
||||
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<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>
|
||||
|
||||
{/* Text size */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">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 cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
fontSize: Math.min(value, 16),
|
||||
}}
|
||||
>
|
||||
{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 cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
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 (only when style != none) */}
|
||||
{form.border_style !== 'none' && (
|
||||
<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 cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||
onClick={() => { onDelete(); onClose() }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||
{title === 'Add Text' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Check } from 'lucide-react'
|
||||
import { Check, Pencil } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { CustomStyleModal } from './CustomStyleModal'
|
||||
|
||||
// Node-type accent colors to display as preview swatches
|
||||
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
||||
@@ -14,74 +15,108 @@ interface ThemeCardProps {
|
||||
themeId: ThemeId
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
|
||||
buttonRef?: (element: HTMLButtonElement | null) => void
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
||||
function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) {
|
||||
const { customStyle } = useThemeStore()
|
||||
const preset = THEMES[themeId]
|
||||
const c = preset.colors
|
||||
const isCustom = themeId === 'custom'
|
||||
|
||||
// For custom theme, use defined node colors for preview swatches
|
||||
const swatchColors = isCustom
|
||||
? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border)
|
||||
: PREVIEW_TYPES.map((t) => c.nodeAccents[t].border)
|
||||
|
||||
const ethernetColor = isCustom
|
||||
? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet)
|
||||
: c.edgeColors.ethernet
|
||||
const wifiColor = isCustom
|
||||
? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi)
|
||||
: c.edgeColors.wifi
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
||||
style={{
|
||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||
background: c.canvasBackground,
|
||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Selected checkmark */}
|
||||
{selected && (
|
||||
<span
|
||||
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
||||
style={{ background: c.nodeAccents.isp.border }}
|
||||
>
|
||||
<Check size={10} style={{ color: c.canvasBackground }} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Mini canvas preview */}
|
||||
<div
|
||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||
<div className="relative w-full h-full">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||
style={{
|
||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||
background: c.canvasBackground,
|
||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Node accent dots */}
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{PREVIEW_TYPES.map((type) => (
|
||||
<span
|
||||
key={type}
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: c.nodeAccents[type].border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Edge line */}
|
||||
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
||||
{/* Wifi dashed line */}
|
||||
{/* Selected checkmark */}
|
||||
{selected && (
|
||||
<span
|
||||
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
||||
style={{ background: c.nodeAccents.isp.border }}
|
||||
>
|
||||
<Check size={10} style={{ color: c.canvasBackground }} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Mini canvas preview */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
width: '55%',
|
||||
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||
>
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{swatchColors.map((color, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
width: '55%',
|
||||
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
className="text-xs font-semibold leading-tight"
|
||||
style={{ color: c.nodeLabelColor }}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
||||
style={{ color: c.nodeSubtextColor }}
|
||||
>
|
||||
{preset.description}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className="text-sm font-semibold leading-tight wrap-break-word"
|
||||
style={{ color: c.nodeLabelColor }}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
|
||||
style={{ color: c.nodeSubtextColor }}
|
||||
>
|
||||
{preset.description}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Edit button — only for custom theme */}
|
||||
{isCustom && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
||||
title="Edit custom style"
|
||||
className="absolute bottom-2 right-2 flex items-center justify-center w-6 h-6 rounded-md transition-colors"
|
||||
style={{
|
||||
background: c.nodeCardBackground,
|
||||
color: c.nodeLabelColor,
|
||||
border: `1px solid ${c.handleBackground}`,
|
||||
}}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,6 +128,8 @@ interface ThemeModalProps {
|
||||
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
const { markUnsaved } = useCanvasStore()
|
||||
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const [customStyleOpen, setCustomStyleOpen] = useState(false)
|
||||
|
||||
// Capture the theme that was active when the modal opened
|
||||
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||
@@ -100,68 +137,96 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||
|
||||
const handleSelect = (id: ThemeId) => {
|
||||
setSelected(id)
|
||||
// Live-preview the selected theme on the canvas
|
||||
setTheme(id)
|
||||
}
|
||||
|
||||
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleApply()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
|
||||
event.preventDefault()
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
|
||||
const nextTheme = THEME_ORDER[nextIndex]
|
||||
|
||||
setSelected(nextTheme)
|
||||
setTheme(nextTheme)
|
||||
|
||||
const nextCard = cardRefs.current[nextIndex]
|
||||
if (!nextCard) return
|
||||
|
||||
nextCard.focus({ preventScroll: true })
|
||||
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
setTheme(selected)
|
||||
markUnsaved()
|
||||
onClose()
|
||||
toast.info('Style applied — save your canvas to make it permanent', {
|
||||
duration: 5000,
|
||||
})
|
||||
toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// Revert to the original theme
|
||||
setTheme(originalTheme)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3 py-1">
|
||||
{THEME_ORDER.map((id) => (
|
||||
<ThemeCard
|
||||
key={id}
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
|
||||
{THEME_ORDER.map((id, index) => (
|
||||
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||
<ThemeCard
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
onKeyDown={handleCardKeyDown(index)}
|
||||
buttonRef={(element) => { cardRefs.current[index] = element }}
|
||||
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
style={
|
||||
selected !== 'default'
|
||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Style
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
style={
|
||||
selected !== 'default'
|
||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Style
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { CustomStyleModal } from '../CustomStyleModal'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
import { toast } from 'sonner'
|
||||
|
||||
describe('CustomStyleModal', () => {
|
||||
beforeEach(() => {
|
||||
useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } })
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
vi.mocked(toast.success).mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<CustomStyleModal open={false} onClose={vi.fn()} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders title and tabs', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByText('Custom Style Editor')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts with empty selection placeholder', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByText(/Select a node type/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('switches to edges tab and shows the right placeholder', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('selecting a node type opens the node editor', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||
expect(screen.getByText(/Apply to existing/)).toBeDefined()
|
||||
expect(screen.getByText('Default size')).toBeDefined()
|
||||
})
|
||||
|
||||
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('Apply-to-existing node button calls store and toasts', () => {
|
||||
const applyTypeNodeStyle = vi.fn()
|
||||
useCanvasStore.setState({ applyTypeNodeStyle })
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ }))
|
||||
expect(applyTypeNodeStyle).toHaveBeenCalledOnce()
|
||||
expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router')
|
||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router'))
|
||||
})
|
||||
|
||||
it('Apply-to-existing edge button calls store and toasts', () => {
|
||||
const applyTypeEdgeStyle = vi.fn()
|
||||
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||
expect(applyTypeEdgeStyle).toHaveBeenCalledOnce()
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet')
|
||||
})
|
||||
|
||||
it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => {
|
||||
const onClose = vi.fn()
|
||||
const markUnsaved = vi.fn()
|
||||
useCanvasStore.setState({ markUnsaved })
|
||||
render(<CustomStyleModal open onClose={onClose} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
|
||||
expect(markUnsaved).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved'))
|
||||
})
|
||||
|
||||
it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => {
|
||||
const onClose = vi.fn()
|
||||
const markUnsaved = vi.fn()
|
||||
const applyAllCustomStyles = vi.fn()
|
||||
useCanvasStore.setState({ markUnsaved, applyAllCustomStyles })
|
||||
render(<CustomStyleModal open onClose={onClose} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' }))
|
||||
expect(applyAllCustomStyles).toHaveBeenCalledOnce()
|
||||
expect(markUnsaved).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('Cancel button closes without saving', () => {
|
||||
const onClose = vi.fn()
|
||||
const markUnsaved = vi.fn()
|
||||
useCanvasStore.setState({ markUnsaved })
|
||||
render(<CustomStyleModal open onClose={onClose} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(markUnsaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('editing path style updates the edge draft', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
const smoothBtn = screen.getByRole('button', { name: 'Smooth' })
|
||||
fireEvent.click(smoothBtn)
|
||||
// The clicked button should now be styled selected (cyan border)
|
||||
expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)')
|
||||
})
|
||||
|
||||
it('changing width input updates node draft', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||
const widthInputs = screen.getAllByRole('spinbutton')
|
||||
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
||||
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,16 @@ describe('GroupRectModal', () => {
|
||||
expect(submitted.z_order).toBe(1)
|
||||
})
|
||||
|
||||
it('exposes aria-labels on grid buttons and select triggers', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByLabelText('Font selector')).toBeDefined()
|
||||
expect(screen.getByLabelText('Z-order selector')).toBeDefined()
|
||||
expect(screen.getByLabelText('Text position ↘')).toBeDefined()
|
||||
expect(screen.getByLabelText('Label position Inside')).toBeDefined()
|
||||
expect(screen.getByLabelText('Border style Solid')).toBeDefined()
|
||||
expect(screen.getByLabelText('Border width 1px')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
@@ -251,4 +261,81 @@ describe('GroupRectModal', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupRectModal font label rendering', () => {
|
||||
it('renders the human font label in the Select trigger (default inter)', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
const trigger = screen.getByLabelText('Font selector')
|
||||
expect(trigger.textContent).toContain('Inter (sans-serif)')
|
||||
})
|
||||
|
||||
it('falls back to raw value when font is unknown', () => {
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ font: 'comic-sans-9000' }}
|
||||
/>
|
||||
)
|
||||
const trigger = screen.getByLabelText('Font selector')
|
||||
expect(trigger.textContent).toContain('comic-sans-9000')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
|
||||
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') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||
expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||
})
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────
|
||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Delete confirm ────────────────────────────────────────────────────
|
||||
|
||||
it('deletes and closes when Delete confirm is accepted', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Regression: bare-if without braces used to call onClose() unconditionally,
|
||||
// closing the modal even when the user cancelled the confirm dialog.
|
||||
it('does not delete or close when Delete confirm is cancelled', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
// ── Label validation ──────────────────────────────────────────────────
|
||||
|
||||
it('blocks submit and shows error when label is empty', () => {
|
||||
@@ -121,7 +143,7 @@ describe('NodeModal', () => {
|
||||
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'), { target: { value: '10.0.0.1' } })
|
||||
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>
|
||||
@@ -130,6 +152,21 @@ describe('NodeModal', () => {
|
||||
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' } })
|
||||
@@ -219,52 +256,101 @@ describe('NodeModal', () => {
|
||||
expect(screen.queryByTitle('Router')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Container mode (proxmox only) ─────────────────────────────────────
|
||||
// ── Container mode ─────────────────────────────────────────────────────
|
||||
|
||||
it('shows Container Mode toggle for proxmox type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
||||
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('hides Container Mode for non-proxmox types', () => {
|
||||
renderModal({ initial: BASE })
|
||||
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('switch', { name: 'Container Mode' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||
})
|
||||
|
||||
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||
// ── Show services toggle (modal-only) ───────────────────────────────
|
||||
|
||||
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'vm' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('PVE-01')).toBeDefined()
|
||||
it('shows Show Services toggle for regular nodes', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(screen.getByText('Show Services')).toBeDefined()
|
||||
expect(screen.getByRole('switch', { name: 'Show Services' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
|
||||
it('hides Show Services toggle for groupRect', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||
expect(screen.queryByText('Show Services')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits custom_colors.show_services=true when toggled on', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||
expect(data.custom_colors?.show_services).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps default colors hint visible when Show Services is toggled on', () => {
|
||||
renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show Appearance reset when only Show Services is set', () => {
|
||||
renderModal({ initial: BASE })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Parent Container selector ─────────────────────────────────────────
|
||||
|
||||
it('does not render Parent Container for non-child types', () => {
|
||||
renderModal({
|
||||
initial: BASE,
|
||||
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render Parent Container when no valid candidates exist', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentCandidates: [],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders Parent Container for docker_container when docker_host candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Parent Container for lxc when proxmox candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'lxc' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for server type', () => {
|
||||
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'vm' } })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
@@ -312,18 +398,57 @@ describe('NodeModal', () => {
|
||||
|
||||
it('defaults bottom_handles to 1', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(selects()[2].value).toBe('1')
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('1')
|
||||
})
|
||||
|
||||
it('pre-fills bottom_handles from initial', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||
expect(selects()[2].value).toBe('3')
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('3')
|
||||
})
|
||||
|
||||
it('submits updated bottom_handles', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(selects()[2], { target: { value: '4' } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
fireEvent.change(slider, { target: { value: '12' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||
})
|
||||
|
||||
it('supports the full 1..48 range', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('48')
|
||||
fireEvent.change(slider, { target: { value: '48' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
|
||||
})
|
||||
|
||||
it('clamps pre-filled out-of-range values into [1,48]', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('48')
|
||||
})
|
||||
|
||||
// ── Zigbee nodes ──────────────────────────────────────────────────────
|
||||
|
||||
const zigbeeTypes = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] as const
|
||||
|
||||
it.each(zigbeeTypes)('hides Check Method for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.queryByText('Check Method')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(zigbeeTypes)('hides Check Target for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.queryByText('Check Target')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(zigbeeTypes)('submits check_method=none for %s type', (type) => {
|
||||
const { onSubmit } = renderModal({ initial: { ...BASE, type, label: 'Zigbee Node' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
const mockBulkApprove = vi.fn()
|
||||
const mockBulkHide = vi.fn()
|
||||
const mockRestore = vi.fn()
|
||||
const mockBulkRestore = vi.fn()
|
||||
const mockApprove = vi.fn()
|
||||
const mockHide = vi.fn()
|
||||
const mockPending = vi.fn()
|
||||
const mockHidden = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
pending: (...a: unknown[]) => mockPending(...a),
|
||||
hidden: (...a: unknown[]) => mockHidden(...a),
|
||||
clearPending: vi.fn().mockResolvedValue({}),
|
||||
approve: (...a: unknown[]) => mockApprove(...a),
|
||||
hide: (...a: unknown[]) => mockHide(...a),
|
||||
ignore: vi.fn().mockResolvedValue({}),
|
||||
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
|
||||
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
|
||||
restore: (...a: unknown[]) => mockRestore(...a),
|
||||
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||
PendingDeviceModal: ({ device }: { device: unknown }) =>
|
||||
device ? <div data-testid="approval-modal" /> : null,
|
||||
}))
|
||||
|
||||
const DEVICE_IP = {
|
||||
id: 'dev-a',
|
||||
ip: '192.168.1.10',
|
||||
hostname: 'host-a',
|
||||
mac: 'aa:bb:cc:dd:ee:01',
|
||||
os: null,
|
||||
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
|
||||
suggested_type: 'server',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
discovered_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_ZIGBEE = {
|
||||
id: 'dev-b',
|
||||
ip: null,
|
||||
hostname: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'iot',
|
||||
status: 'pending',
|
||||
discovery_source: 'zigbee',
|
||||
ieee_address: '0x00124b001234abcd',
|
||||
friendly_name: 'living-room-bulb',
|
||||
vendor: 'Philips',
|
||||
model: 'Hue White',
|
||||
discovered_at: '2026-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||
// setState is used by injectAutoEdges
|
||||
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||
mockHidden.mockResolvedValue({ data: [] })
|
||||
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
||||
mockHide.mockResolvedValue({ data: {} })
|
||||
mockBulkApprove.mockResolvedValue({
|
||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
|
||||
})
|
||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
||||
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
describe('PendingDevicesModal', () => {
|
||||
it('loads and renders pending devices on open', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by search query', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by source (zigbee only)', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by suggested type', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to hidden status loads hidden devices', async () => {
|
||||
mockHidden.mockResolvedValue({
|
||||
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
|
||||
expect(mockHidden).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens approval modal when card is clicked outside select mode', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles selection in select mode instead of opening approval', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('select all visible selects only filtered devices', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk approve calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
})
|
||||
|
||||
it('bulk hide calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hide \(1\)/ }))
|
||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
|
||||
})
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(<PendingDevicesModal {...baseProps} open={false} />)
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects initialStatus=hidden', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clicking a hidden card restores it instead of opening approval', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
|
||||
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk restore in hidden mode calls API with selected ids', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TextModal, type TextFormData } from '../TextModal'
|
||||
|
||||
describe('TextModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<TextModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form fields when open', () => {
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Type text…')).toBeDefined()
|
||||
expect(screen.getByText('Add Text')).toBeDefined()
|
||||
expect(screen.getByText('Police')).toBeDefined()
|
||||
expect(screen.getByText('Border Style')).toBeDefined()
|
||||
expect(screen.getByText('Size')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Edit Text title when provided', () => {
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Text" />)
|
||||
expect(screen.getByText('Edit Text')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data on submit', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
const ta = screen.getByPlaceholderText('Type text…')
|
||||
fireEvent.change(ta, { target: { value: 'Hello' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
const submitted = onSubmit.mock.calls[0][0] as TextFormData
|
||||
expect(submitted.text).toBe('Hello')
|
||||
expect(submitted.font).toBe('inter')
|
||||
expect(submitted.border_style).toBe('none')
|
||||
})
|
||||
|
||||
it('hides Border Width when style is none, shows when not', () => {
|
||||
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('Border Width')).toBeNull()
|
||||
fireEvent.click(screen.getByTitle('Solid'))
|
||||
expect(screen.getByText('Border Width')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Delete button and calls handlers when provided', () => {
|
||||
const onDelete = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByText('Delete'))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('pre-fills from initial prop', () => {
|
||||
render(
|
||||
<TextModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ text: 'Pre-filled', text_size: 24, font: 'mono' }}
|
||||
/>
|
||||
)
|
||||
const ta = screen.getByPlaceholderText('Type text…') as HTMLTextAreaElement
|
||||
expect(ta.value).toBe('Pre-filled')
|
||||
})
|
||||
|
||||
it('cancel calls onClose', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SidebarItem pointer on hover */
|
||||
.sidebar-pointer:hover {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
/* Consistent border radius for all modal input/select/button elements */
|
||||
.modal-radius {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* Pointer cursor for close X */
|
||||
.modal-close-pointer:hover {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
/* Subtle hover background for cancel button */
|
||||
.modal-cancel-hover:hover {
|
||||
background: #21262d !important;
|
||||
}
|
||||
/* Shared hover/focus border effect for interactive modal elements */
|
||||
.modal-interactive {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.modal-interactive:hover,
|
||||
.modal-interactive:focus {
|
||||
border-color: #8b949e !important;
|
||||
outline: none;
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { createElement, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
@@ -12,8 +14,8 @@ interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
|
||||
|
||||
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
@@ -83,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { data } = node
|
||||
const services = data.services ?? []
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
const host = data.ip ?? data.hostname
|
||||
const ipAddresses = data.ip ? splitIps(data.ip) : []
|
||||
const host = ipAddresses[0] ?? data.hostname
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Delete "${data.label}"?`)) {
|
||||
@@ -93,10 +96,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
}
|
||||
|
||||
const handleAddService = () => {
|
||||
const port = parseInt(newSvc.port, 10)
|
||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const trimmedPort = newSvc.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||
if (!newSvc.service_name.trim()) return
|
||||
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
snapshotHistory()
|
||||
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||
const path = newSvc.path.trim()
|
||||
const svc: ServiceInfo = {
|
||||
...(port != null ? { port } : {}),
|
||||
protocol: newSvc.protocol,
|
||||
service_name: newSvc.service_name.trim(),
|
||||
...(path ? { path } : {}),
|
||||
}
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
setAddingForNode(null)
|
||||
@@ -112,18 +123,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const handleStartEdit = (index: number) => {
|
||||
const svc = services[index]
|
||||
if (!svc) return
|
||||
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
|
||||
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
|
||||
setEditingFor({ nodeId: node.id, index })
|
||||
setAddingForNode(null)
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null) return
|
||||
const port = parseInt(editSvc.port, 10)
|
||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const trimmedPort = editSvc.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||
if (!editSvc.service_name.trim()) return
|
||||
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
snapshotHistory()
|
||||
const path = editSvc.path.trim()
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||
i === editingIndex
|
||||
? {
|
||||
...svc,
|
||||
protocol: editSvc.protocol,
|
||||
service_name: editSvc.service_name.trim(),
|
||||
...(port != null ? { port } : { port: undefined }),
|
||||
...(path ? { path } : { path: undefined }),
|
||||
}
|
||||
: svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
setEditingFor(null)
|
||||
@@ -179,7 +201,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -202,11 +224,31 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||
{ipAddresses.length > 0 && (
|
||||
<div className="flex justify-between gap-2 items-start">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
|
||||
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||||
{ipAddresses.map((ip, index) => (
|
||||
<span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
|
||||
<a
|
||||
href={`http://${ip}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
|
||||
title={ip}
|
||||
>
|
||||
{ip}
|
||||
<ExternalLink size={10} className="shrink-0" />
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{/* Properties section */}
|
||||
@@ -215,7 +257,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
@@ -261,7 +303,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
|
||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
@@ -272,7 +314,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
editingIndex === i ? (
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -288,10 +330,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
|
||||
<Edit size={14} /> Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
|
||||
<Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -472,23 +514,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
}
|
||||
|
||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const setPort = (value: string) => {
|
||||
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
|
||||
onChange({ ...form, port: digitsOnly })
|
||||
}
|
||||
|
||||
const clampPort = (value: string) => {
|
||||
if (!value) return ''
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) return ''
|
||||
return String(Math.max(1, Math.min(65535, parsed)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<div className="flex gap-1.5">
|
||||
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
|
||||
placeholder="Port"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||
@@ -611,20 +676,85 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||
const inner = (
|
||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
||||
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium truncate min-w-0 flex-1"
|
||||
style={{ color }}
|
||||
title={svc.service_name}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{svc.service_name}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="font-medium truncate min-w-0 flex-1"
|
||||
style={{ color }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{pathLabel && (
|
||||
<span
|
||||
className="shrink-0 text-[#8b949e] text-right w-16 truncate"
|
||||
title={pathLabel}
|
||||
>
|
||||
{pathLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
|
||||
{svc.port != null && (
|
||||
<span className="font-mono text-[#8b949e]">
|
||||
{svc.port}/{svc.protocol}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-2.5 h-2.5 items-center justify-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} className="text-muted-foreground" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-2.5" />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
|
||||
title="Edit service"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5 cursor-pointer"
|
||||
title="Remove service"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
|
||||
return inner
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import {
|
||||
type AlignmentSettings,
|
||||
readAlignmentSettings,
|
||||
writeAlignmentSettings,
|
||||
subscribeAlignmentSettings,
|
||||
} from '@/utils/alignmentSettings'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
|
||||
type SidebarView = 'canvas' | 'history' | 'settings'
|
||||
|
||||
const ALL_VIEWS = [
|
||||
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
||||
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
|
||||
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
|
||||
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
|
||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||
]
|
||||
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
|
||||
|
||||
interface ScanRun {
|
||||
id: string
|
||||
status: string
|
||||
kind?: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
@@ -33,24 +37,32 @@ interface ScanRun {
|
||||
interface SidebarProps {
|
||||
onAddNode: () => void
|
||||
onAddGroupRect: () => void
|
||||
onAddText: () => void
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onSave: () => void
|
||||
onNodeApproved: (nodeId: string) => void
|
||||
forceView?: SidebarView
|
||||
highlightPendingId?: string
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||
const [_collapsed, setCollapsed] = useState(false)
|
||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
|
||||
// When forceView is set, override local state without useEffect
|
||||
const collapsed = forceView ? false : _collapsed
|
||||
const activeView = forceView ?? _activeView
|
||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||
if (forceView !== prevForceView) {
|
||||
setPrevForceView(forceView)
|
||||
if (forceView) {
|
||||
setActiveView(forceView)
|
||||
setCollapsed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||
|
||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||
|
||||
@@ -78,23 +90,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
|
||||
{/* Views */}
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
{VIEWS.map(({ id, icon: Icon, label }) => (
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Canvas"
|
||||
collapsed={collapsed}
|
||||
active={activeView === 'canvas'}
|
||||
onClick={() => setActiveView('canvas')}
|
||||
/>
|
||||
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||
<SidebarItem
|
||||
key={id}
|
||||
icon={Icon}
|
||||
label={label}
|
||||
key={t.kind}
|
||||
icon={t.icon}
|
||||
label={t.label}
|
||||
collapsed={collapsed}
|
||||
active={activeView === id}
|
||||
onClick={() => setActiveView(id)}
|
||||
onClick={() => onOpenPending(undefined, t.kind)}
|
||||
/>
|
||||
))}
|
||||
{!STANDALONE && (
|
||||
<SidebarItem
|
||||
icon={Clock}
|
||||
label="Scan History"
|
||||
collapsed={collapsed}
|
||||
active={activeView === 'history'}
|
||||
onClick={() => setActiveView('history')}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* View content (only when expanded) */}
|
||||
{!collapsed && activeView !== 'canvas' && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
||||
{activeView === 'history' && <ScanHistoryPanel />}
|
||||
{activeView === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
@@ -127,7 +152,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
<SidebarItem
|
||||
icon={hideIp ? EyeOff : Eye}
|
||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
||||
@@ -152,6 +179,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
||||
/>
|
||||
)}
|
||||
{!STANDALONE && (
|
||||
<SidebarItem
|
||||
icon={LogOut}
|
||||
label="Logout"
|
||||
collapsed={collapsed}
|
||||
onClick={logout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && <VersionBadge />}
|
||||
@@ -159,228 +194,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
)
|
||||
}
|
||||
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
|
||||
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.pending()
|
||||
setDevices(res.data)
|
||||
} catch {
|
||||
toast.error('Failed to load pending devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await scanApi.clearPending()
|
||||
setDevices([])
|
||||
toast.success('Pending devices cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear pending devices')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (scanEventTs > 0) load()
|
||||
}, [scanEventTs, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId || loading) return
|
||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [highlightId, loading])
|
||||
|
||||
const handleApprove = async (device: PendingDevice) => {
|
||||
try {
|
||||
const nodeData = {
|
||||
label: device.hostname ?? device.ip,
|
||||
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
||||
ip: device.ip,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: 'unknown',
|
||||
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
||||
}
|
||||
const res = await scanApi.approve(device.id, nodeData)
|
||||
const nodeId = res.data.node_id
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type: nodeData.type,
|
||||
position: { x: 400, y: 300 },
|
||||
data: { ...nodeData, status: 'unknown' as const },
|
||||
})
|
||||
toast.success(`Approved ${nodeData.label}`)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
onNodeApproved(nodeId)
|
||||
} catch {
|
||||
toast.error('Failed to approve device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHide = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.hide(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
toast.success('Device hidden')
|
||||
} catch {
|
||||
toast.error('Failed to hide device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = async (device: PendingDevice) => {
|
||||
try {
|
||||
await scanApi.ignore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
} catch {
|
||||
toast.error('Failed to ignore device')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
{devices.length > 0 && (
|
||||
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && devices.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
|
||||
const titleService = namedService
|
||||
?? d.services.find((s) => s.port === 80)
|
||||
?? d.services.find((s) => s.port === 443)
|
||||
?? d.services.find((s) => s.port === 22)
|
||||
const title = titleService?.service_name ?? d.hostname ?? d.ip
|
||||
const showIpBelow = title !== d.ip
|
||||
const hasSsh = d.services.some((s) => s.port === 22)
|
||||
const hasHttp = d.services.some((s) => s.port === 80)
|
||||
const hasHttps = d.services.some((s) => s.port === 443)
|
||||
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
||||
const virtualBadge = detectVirtualBadge(d.mac)
|
||||
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
||||
const isHighlighted = d.id === highlightId
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
ref={isHighlighted ? highlightRef : null}
|
||||
onClick={() => setSelected(d)}
|
||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||
<span className="text-foreground truncate font-medium">{title}</span>
|
||||
</div>
|
||||
{showIpBelow && (
|
||||
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
||||
)}
|
||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
|
||||
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
||||
{virtualBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasSsh && <ServiceBadge label="SSH" color="#a855f7" />}
|
||||
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
|
||||
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
|
||||
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PendingDeviceModal
|
||||
device={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onApprove={handleApprove}
|
||||
onHide={handleHide}
|
||||
onIgnore={handleIgnore}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function HiddenDevicesPanel() {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.hidden()
|
||||
setDevices(res.data)
|
||||
} catch {
|
||||
toast.error('Failed to load hidden devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleIgnore = async (id: string) => {
|
||||
try {
|
||||
await scanApi.ignore(id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
||||
} catch {
|
||||
toast.error('Failed to remove device')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && devices.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
|
||||
)}
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
||||
<div className="font-mono text-foreground">{d.ip}</div>
|
||||
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScanHistoryPanel() {
|
||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||
@@ -393,12 +206,19 @@ function ScanHistoryPanel() {
|
||||
const res = await scanApi.runs()
|
||||
const next: ScanRun[] = res.data
|
||||
|
||||
// Toast when a run transitions from running → error
|
||||
// Surface transitions and refresh dependent UI
|
||||
for (const run of next) {
|
||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||
if (prev?.status === 'running' && run.status === 'error') {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (prev?.status === 'running' && run.status === 'done') {
|
||||
if (run.kind === 'zigbee') {
|
||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
}
|
||||
// Notify pending modal/canvas to refresh
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
}
|
||||
}
|
||||
prevRunsRef.current = next
|
||||
setRuns(next)
|
||||
@@ -459,6 +279,14 @@ function ScanHistoryPanel() {
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
||||
<span
|
||||
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
|
||||
style={r.kind === 'zigbee'
|
||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||
: { background: '#a855f722', color: '#a855f7' }}
|
||||
>
|
||||
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
|
||||
</span>
|
||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||
{r.status === 'running' && (
|
||||
<Tooltip>
|
||||
@@ -486,7 +314,7 @@ function ScanHistoryPanel() {
|
||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||
)}
|
||||
{r.error && (
|
||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap">
|
||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
|
||||
{r.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -499,6 +327,7 @@ function ScanHistoryPanel() {
|
||||
function SettingsPanel() {
|
||||
const [interval, setIntervalValue] = useState(60)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get()
|
||||
@@ -506,6 +335,14 @@ function SettingsPanel() {
|
||||
.catch(() => {/* use default */})
|
||||
}, [])
|
||||
|
||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||
|
||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||
const next = { ...alignment, ...patch }
|
||||
setAlignment(next)
|
||||
writeAlignmentSettings(next)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -530,7 +367,7 @@ function SettingsPanel() {
|
||||
min={10}
|
||||
max={3600}
|
||||
value={interval}
|
||||
onChange={(e) => setIntervalValue(Number(e.target.value))}
|
||||
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">seconds</span>
|
||||
@@ -547,6 +384,41 @@ function SettingsPanel() {
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alignment.enabled}
|
||||
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||
className="cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Toggle alignment guides"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={16}
|
||||
step={1}
|
||||
value={alignment.threshold}
|
||||
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Alignment snap threshold"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -567,7 +439,7 @@ function VersionBadge() {
|
||||
</a>
|
||||
{hasUpdate && latest && (
|
||||
<a
|
||||
href={latest.url}
|
||||
href={latest.url.startsWith('https://') ? latest.url : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
||||
@@ -579,55 +451,6 @@ function VersionBadge() {
|
||||
)
|
||||
}
|
||||
|
||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||
'00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' },
|
||||
'00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' },
|
||||
'08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' },
|
||||
'00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' },
|
||||
}
|
||||
|
||||
function detectVirtualBadge(mac: string | null) {
|
||||
if (!mac) return null
|
||||
return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null
|
||||
}
|
||||
|
||||
function ServiceBadge({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[9px] font-mono font-medium leading-none border"
|
||||
style={{ color, borderColor: `${color}40`, backgroundColor: `${color}15` }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
color?: 'green' | 'red'
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
|
||||
const colorClass =
|
||||
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
|
||||
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
|
||||
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
|
||||
<Icon size={11} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
@@ -642,7 +465,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
|
||||
const btn = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${
|
||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
: accent
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||
onClick={onUndo}
|
||||
disabled={past.length === 0}
|
||||
title="Undo (Ctrl+Z)"
|
||||
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||
onClick={onRedo}
|
||||
disabled={future.length === 0}
|
||||
title="Redo (Ctrl+Y)"
|
||||
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<Redo2 size={14} />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||
<LayoutDashboard size={14} /> Auto Layout
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||
<Palette size={14} /> Style
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||
<Upload size={14} /> Import
|
||||
</Button>
|
||||
<input
|
||||
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
|
||||
<Download size={14} /> Export
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
|
||||
<FileDown size={14} /> PNG
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||
<Table2 size={14} /> MD
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||
<HelpCircle size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 relative"
|
||||
className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
|
||||
style={{
|
||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import type { ServiceInfo } from '@/types'
|
||||
|
||||
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
|
||||
port,
|
||||
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
|
||||
...(port != null ? { port } : {}),
|
||||
protocol,
|
||||
service_name,
|
||||
...(path ? { path } : {}),
|
||||
})
|
||||
|
||||
describe('getServiceUrl', () => {
|
||||
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
|
||||
it('uses host string directly (works with both IP and hostname)', () => {
|
||||
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
||||
})
|
||||
|
||||
it('uses the node port when the host already includes one', () => {
|
||||
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
|
||||
})
|
||||
|
||||
it('lets the service port override the node port', () => {
|
||||
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
|
||||
})
|
||||
|
||||
it('appends a normalized path to the final URL', () => {
|
||||
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
|
||||
})
|
||||
|
||||
it('supports path-only services inheriting the node port', () => {
|
||||
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,9 +293,35 @@ describe('DetailPanel', () => {
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
|
||||
})
|
||||
|
||||
it('allows adding a service without a port', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
|
||||
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls updateNode without the removed service when X is clicked', () => {
|
||||
@@ -332,7 +358,7 @@ describe('DetailPanel', () => {
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
|
||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||
setupStore({ services: [svc] })
|
||||
setupStore({ services: [{ ...svc, path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||
const editBtn = screen.getByTitle('Edit service')
|
||||
@@ -341,6 +367,8 @@ describe('DetailPanel', () => {
|
||||
expect(nameInput.value).toBe('nginx')
|
||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||
expect(portInput.value).toBe('80')
|
||||
const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement
|
||||
expect(pathInput.value).toBe('/admin')
|
||||
})
|
||||
|
||||
it('calls updateNode with updated values on Save', () => {
|
||||
@@ -359,11 +387,13 @@ describe('DetailPanel', () => {
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Service name')
|
||||
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
|
||||
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
|
||||
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
|
||||
})
|
||||
|
||||
it('cancels edit without updating', () => {
|
||||
@@ -385,4 +415,115 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText('nginx')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IP Address — clickable link', () => {
|
||||
it('renders a link for a single IP', () => {
|
||||
setupStore({ ip: '192.168.1.10' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
|
||||
expect(link).toBeDefined()
|
||||
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('renders no IP link when ip is absent', () => {
|
||||
setupStore({ ip: undefined })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('IP Address')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses primary IP as href for comma-separated IPs', () => {
|
||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
|
||||
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
|
||||
})
|
||||
|
||||
it('displays full comma-separated IP string as link text', () => {
|
||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined()
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined()
|
||||
expect(screen.queryByText(',')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders separate links for semicolon-separated IPs', () => {
|
||||
setupStore({ ip: '192.168.1.10; 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||
})
|
||||
|
||||
it('renders separate links for newline-separated IPs', () => {
|
||||
setupStore({ ip: '192.168.1.10\n192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ServiceBadge rendering', () => {
|
||||
it('renders service name and port/protocol label', () => {
|
||||
setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('nginx')).toBeDefined()
|
||||
expect(screen.getByText('8080/tcp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders path label when path is set', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('/admin')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders no path text when path is empty', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('/')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders port/protocol omitted when port is absent', () => {
|
||||
setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('health')).toBeDefined()
|
||||
expect(screen.queryByText(/\/tcp/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders service name as link when ip and port are set', () => {
|
||||
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: 'nginx' })
|
||||
expect(link.getAttribute('href')).toContain('192.168.1.10')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('renders service name as plain text when no url can be built', () => {
|
||||
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Last Seen formatting', () => {
|
||||
it('renders a valid date when last_seen has +00:00 offset (no Z)', () => {
|
||||
setupStore({ last_seen: '2026-05-10T17:54:38.221403+00:00' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const row = screen.getByText('Last Seen').parentElement
|
||||
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||
})
|
||||
|
||||
it('renders a valid date when last_seen ends with Z', () => {
|
||||
setupStore({ last_seen: '2026-05-10T17:54:38.221403Z' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const row = screen.getByText('Last Seen').parentElement
|
||||
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||
})
|
||||
|
||||
it('treats naive ISO strings as UTC', () => {
|
||||
setupStore({ last_seen: '2026-05-10T17:54:38' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const row = screen.getByText('Last Seen').parentElement
|
||||
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,18 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/authStore')
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
stop: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
@@ -37,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipContent: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||
PendingDeviceModal: () => null,
|
||||
}))
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||
@@ -51,6 +47,7 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
||||
})
|
||||
|
||||
const mockToggleHideIp = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
@@ -64,12 +61,19 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||
} as ReturnType<typeof useCanvasStore>)
|
||||
}
|
||||
|
||||
function mockAuth() {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
|
||||
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
onAddNode: vi.fn(),
|
||||
onAddGroupRect: vi.fn(),
|
||||
onScan: vi.fn(),
|
||||
onZigbeeImport: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onNodeApproved: vi.fn(),
|
||||
onOpenPending: vi.fn(),
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -77,6 +81,7 @@ const defaultProps = {
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
mockStore()
|
||||
mockAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -110,26 +115,22 @@ describe('Sidebar', () => {
|
||||
],
|
||||
})
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// Total (excludes groupRect)
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
// Online
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
// Offline
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes groupRect nodes from stats', () => {
|
||||
mockStore({
|
||||
nodes: [
|
||||
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
||||
makeNode('n1', 'unknown'),
|
||||
makeNode('zone', 'unknown', 'groupRect'),
|
||||
],
|
||||
})
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// Total row shows 1 (groupRect excluded), online/offline both 0
|
||||
const totalRow = screen.getByText('Total').closest('div')!
|
||||
expect(totalRow).toHaveTextContent('1')
|
||||
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
||||
expect(screen.getAllByText('0')).toHaveLength(2)
|
||||
})
|
||||
|
||||
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||
@@ -206,7 +207,6 @@ describe('Sidebar', () => {
|
||||
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||
mockStore({ hasUnsavedChanges: true })
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// The badge is a span sibling of the Save Canvas button icon
|
||||
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||
const badge = saveBtn.querySelector('span.rounded-full')
|
||||
expect(badge).toBeInTheDocument()
|
||||
@@ -222,24 +222,24 @@ describe('Sidebar', () => {
|
||||
|
||||
// ── Scan action ────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
||||
it('calls onScan prop when Scan Network is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Scan Network'))
|
||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||
|
||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
||||
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Pending Devices'))
|
||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||
})
|
||||
|
||||
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
|
||||
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||
})
|
||||
|
||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||
@@ -248,14 +248,35 @@ describe('Sidebar', () => {
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// Regression: forceView must not freeze local state across rerenders.
|
||||
it('allows switching views after forceView is set by parent', async () => {
|
||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||
fireEvent.click(screen.getByText('Canvas'))
|
||||
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('toggles Settings panel on Settings click', async () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||
)
|
||||
// Click the nav button again to close (use role to avoid matching the panel heading)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows Logout button in normal mode', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when Logout is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Logout'))
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createElement } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { resolveCustomIcon, brandIconUrl, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
|
||||
interface NodeIconProps {
|
||||
/** Default icon for the node type (lucide). Used when no customIconKey or unknown key. */
|
||||
typeIcon: LucideIcon
|
||||
/** Optional override key. Legacy lucide keys or `brand:<slug>` for dashboard-icons. */
|
||||
customIconKey?: string
|
||||
size?: number
|
||||
className?: string
|
||||
/** Optional inline color (lucide only — ignored for brand icons). */
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function NodeIcon({ typeIcon, customIconKey, size = 16, className, color }: NodeIconProps) {
|
||||
const resolved = resolveCustomIcon(customIconKey)
|
||||
if (resolved?.kind === 'brand') {
|
||||
return (
|
||||
<img
|
||||
src={resolved.url}
|
||||
alt={resolved.slug}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
style={{ width: size, height: size, objectFit: 'contain' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon
|
||||
return createElement(Icon, { size, className, color })
|
||||
}
|
||||
|
||||
export { brandIconUrl, isBrandIconKey }
|
||||
@@ -1,3 +1,4 @@
|
||||
import modalStyles from '../modals/modal-interactive.module.css'
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
@@ -63,13 +64,12 @@ function DialogContent({
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -1,64 +1,30 @@
|
||||
"use client"
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
className={[
|
||||
'z-50 overflow-hidden rounded-md border border-[#30363d] bg-[#161b22] px-2 py-1 text-xs text-[#e6edf3] shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState } from 'react'
|
||||
import { Network, Router, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { zigbeeApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from './types'
|
||||
|
||||
interface ZigbeeImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||
onPendingImported?: (
|
||||
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
type ImportMode = 'pending' | 'canvas'
|
||||
|
||||
interface ConnectionForm {
|
||||
mqtt_host: string
|
||||
mqtt_port: string
|
||||
mqtt_username: string
|
||||
mqtt_password: string
|
||||
base_topic: string
|
||||
mqtt_tls: boolean
|
||||
mqtt_tls_insecure: boolean
|
||||
port_user_edited: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: ConnectionForm = {
|
||||
mqtt_host: '',
|
||||
mqtt_port: '1883',
|
||||
mqtt_username: '',
|
||||
mqtt_password: '',
|
||||
base_topic: 'zigbee2mqtt',
|
||||
mqtt_tls: false,
|
||||
mqtt_tls_insecure: false,
|
||||
port_user_edited: false,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_ICON = {
|
||||
zigbee_coordinator: Network,
|
||||
zigbee_router: Router,
|
||||
zigbee_enddevice: Cpu,
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_LABEL = {
|
||||
zigbee_coordinator: 'Coordinator',
|
||||
zigbee_router: 'Router',
|
||||
zigbee_enddevice: 'End Device',
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_COLOR = {
|
||||
zigbee_coordinator: '#00d4ff',
|
||||
zigbee_router: '#39d353',
|
||||
zigbee_enddevice: '#e3b341',
|
||||
} as const
|
||||
|
||||
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
|
||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||
const [connectionMsg, setConnectionMsg] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<ZigbeeNode[]>([])
|
||||
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||
|
||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[field]: value,
|
||||
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||
}))
|
||||
|
||||
const toggleTls = (next: boolean) =>
|
||||
setForm((f) => {
|
||||
const port = f.port_user_edited
|
||||
? f.mqtt_port
|
||||
: next
|
||||
? '8883'
|
||||
: '1883'
|
||||
return {
|
||||
...f,
|
||||
mqtt_tls: next,
|
||||
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||
mqtt_port: port,
|
||||
}
|
||||
})
|
||||
|
||||
const buildPayload = () => ({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
base_topic: form.base_topic.trim() || 'zigbee2mqtt',
|
||||
mqtt_tls: form.mqtt_tls,
|
||||
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||
})
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setConnectionStatus('testing')
|
||||
try {
|
||||
const res = await zigbeeApi.testConnection({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
mqtt_tls: form.mqtt_tls,
|
||||
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||
})
|
||||
if (res.data.connected) {
|
||||
setConnectionStatus('ok')
|
||||
setConnectionMsg(res.data.message)
|
||||
} else {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg(res.data.message)
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg('Request failed — check broker address')
|
||||
}
|
||||
}
|
||||
|
||||
const extractError = (err: unknown): string | undefined => {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const handleFetchDevices = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
if (importMode === 'pending') {
|
||||
await zigbeeApi.importToPending(buildPayload())
|
||||
toast.success('Zigbee import started — track progress in Scan History')
|
||||
onPendingImported?.(null)
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await zigbeeApi.importNetwork(buildPayload())
|
||||
setDevices(res.data.nodes)
|
||||
setEdges(res.data.edges)
|
||||
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||
if (res.data.device_count === 0) {
|
||||
toast.info('No Zigbee devices found in the network map')
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCheck = (id: string) =>
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleAll = () => {
|
||||
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const handleAddToCanvas = () => {
|
||||
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||
onAddToCanvas(selectedDevices, selectedEdges)
|
||||
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setDevices([])
|
||||
setEdges([])
|
||||
setChecked(new Set())
|
||||
setConnectionStatus('idle')
|
||||
setConnectionMsg('')
|
||||
setImportMode('pending')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const groupedDevices = {
|
||||
zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'),
|
||||
zigbee_router: devices.filter((d) => d.type === 'zigbee_router'),
|
||||
zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_enddevice'),
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Network size={16} className="text-[#00d4ff]" />
|
||||
Zigbee2MQTT Import
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||
{/* Connection Form */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||
<Input
|
||||
value={form.mqtt_host}
|
||||
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||
placeholder="192.168.1.x or mqtt.local"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
value={form.mqtt_port}
|
||||
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||
placeholder="1883"
|
||||
type="number"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Base Topic</Label>
|
||||
<Input
|
||||
value={form.base_topic}
|
||||
onChange={(e) => updateField('base_topic', e.target.value)}
|
||||
placeholder="zigbee2mqtt"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||
<Input
|
||||
value={form.mqtt_username}
|
||||
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||
placeholder="mqtt_user"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||
<Input
|
||||
value={form.mqtt_password}
|
||||
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.mqtt_tls}
|
||||
onChange={(e) => toggleTls(e.target.checked)}
|
||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
Use TLS (port 8883)
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.mqtt_tls_insecure}
|
||||
disabled={!form.mqtt_tls}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||
}
|
||||
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
Skip cert verify (self-signed only)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
{connectionStatus !== 'idle' && (
|
||||
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||
connectionStatus === 'ok'
|
||||
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||
: connectionStatus === 'fail'
|
||||
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||
}`}>
|
||||
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Send devices to:</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="zigbee-import-mode"
|
||||
checked={importMode === 'pending'}
|
||||
onChange={() => setImportMode('pending')}
|
||||
className="accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
Pending section
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="zigbee-import-mode"
|
||||
checked={importMode === 'canvas'}
|
||||
onChange={() => setImportMode('canvas')}
|
||||
className="accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
Canvas directly
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||
onClick={handleTestConnection}
|
||||
disabled={connectionStatus === 'testing' || loading}
|
||||
>
|
||||
{connectionStatus === 'testing'
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <CheckCircle2 size={13} />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
onClick={handleFetchDevices}
|
||||
disabled={loading || connectionStatus === 'testing'}
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
Fetching the network map can take several minutes on large meshes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device List */}
|
||||
{devices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.size === devices.length}
|
||||
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||
onChange={toggleAll}
|
||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||
title="Select all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Devices ({checked.size}/{devices.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZigbeeNode[]][])
|
||||
.filter(([, group]) => group.length > 0)
|
||||
.map(([type, group]) => {
|
||||
const Icon = DEVICE_TYPE_ICON[type]
|
||||
const color = DEVICE_TYPE_COLOR[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon size={11} style={{ color }} />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||
</span>
|
||||
</div>
|
||||
{group.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||
checked.has(device.id)
|
||||
? 'bg-[#21262d] border-[#00d4ff]/40'
|
||||
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||
}`}
|
||||
onClick={() => toggleCheck(device.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(device.id)}
|
||||
onChange={() => toggleCheck(device.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||
{(device.model || device.vendor) && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{device.lqi != null && (
|
||||
<span
|
||||
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
|
||||
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
|
||||
>
|
||||
LQI {device.lqi}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||
{devices.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAddToCanvas}
|
||||
disabled={checked.size === 0}
|
||||
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add {checked.size} to Canvas
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ZigbeeImportModal } from '../ZigbeeImportModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
zigbeeApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
importToPending: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
import { zigbeeApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddToCanvas: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleNodes = [
|
||||
{
|
||||
id: '0x0000',
|
||||
label: 'Coordinator',
|
||||
type: 'zigbee_coordinator' as const,
|
||||
ieee_address: '0x0000',
|
||||
friendly_name: 'Coordinator',
|
||||
device_type: 'Coordinator',
|
||||
model: null,
|
||||
vendor: null,
|
||||
lqi: null,
|
||||
parent_id: null,
|
||||
},
|
||||
{
|
||||
id: '0x0001',
|
||||
label: 'router_1',
|
||||
type: 'zigbee_router' as const,
|
||||
ieee_address: '0x0001',
|
||||
friendly_name: 'router_1',
|
||||
device_type: 'Router',
|
||||
model: 'CC2530',
|
||||
vendor: 'TI',
|
||||
lqi: 200,
|
||||
parent_id: '0x0000',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ZigbeeImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||
vi.mocked(zigbeeApi.importNetwork).mockReset()
|
||||
vi.mocked(zigbeeApi.importToPending).mockReset()
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
vi.mocked(toast.info).mockReset()
|
||||
defaultProps.onClose.mockReset()
|
||||
defaultProps.onAddToCanvas.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ZigbeeImportModal {...defaultProps} open={false} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the modal with form fields when open', () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
expect(screen.getByText('Zigbee2MQTT Import')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('192.168.1.x or mqtt.local')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('1883')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('zigbee2mqtt')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error toast when testing connection without a host', async () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||
})
|
||||
expect(zigbeeApi.testConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success status when connection test passes', async () => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||
data: { connected: true, message: 'Connection successful' },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows failure status when connection test fails', async () => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||
data: { connected: false, message: 'Connection refused' },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection refused')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
const selectCanvasMode = () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
}
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Coordinator')).toBeDefined()
|
||||
expect(screen.getByText('router_1')).toBeDefined()
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||
})
|
||||
|
||||
it('shows info toast when no devices found', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: [], edges: [], device_count: 0 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => screen.getByText('Coordinator'))
|
||||
|
||||
// Click "Add N to Canvas" button
|
||||
const addBtn = screen.getByRole('button', { name: /add.*canvas/i })
|
||||
fireEvent.click(addBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('imports to pending by default and notifies parent', async () => {
|
||||
vi.mocked(zigbeeApi.importToPending).mockResolvedValue({
|
||||
data: {
|
||||
id: 'run-1',
|
||||
status: 'running',
|
||||
kind: 'zigbee',
|
||||
ranges: ['192.168.1.100:1883'],
|
||||
devices_found: 0,
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
},
|
||||
} as never)
|
||||
const onPendingImported = vi.fn()
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(zigbeeApi.importToPending).toHaveBeenCalled()
|
||||
expect(onPendingImported).toHaveBeenCalled()
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
expect(zigbeeApi.importNetwork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switching to canvas mode calls importNetwork and not importToPending', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled())
|
||||
expect(zigbeeApi.importToPending).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Shared Zigbee type definitions for the frontend. */
|
||||
|
||||
export interface ZigbeeNode {
|
||||
id: string
|
||||
label: string
|
||||
type: 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_enddevice'
|
||||
ieee_address: string
|
||||
friendly_name: string
|
||||
device_type: string
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface ZigbeeEdge {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface ZigbeeImportResponse {
|
||||
nodes: ZigbeeNode[]
|
||||
edges: ZigbeeEdge[]
|
||||
device_count: number
|
||||
}
|
||||
|
||||
export interface ZigbeeTestConnectionRequest {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
}
|
||||
|
||||
export interface ZigbeeTestConnectionResponse {
|
||||
connected: boolean
|
||||
message: string
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user