Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 67b0620be2 | |||
| cc9c010002 | |||
| 103e24e5fa | |||
| 5fb77ab00b | |||
| 56cfbd1e76 | |||
| 43761c60cb | |||
| ad958feabd | |||
| e3876e934c | |||
| dfa4a9c849 | |||
| 785be6a5dd | |||
| 39f8d16ef1 | |||
| 0095bf8425 | |||
| 2cc97a6de9 | |||
| 29a2ef1b20 | |||
| 0c836e0575 | |||
| 6518eb313b | |||
| 09a591f5f4 | |||
| 988b804b90 | |||
| 6fa0ada325 | |||
| 17613f42d1 | |||
| 0a44b69c4e | |||
| 84235d81bf | |||
| 31b61904ac | |||
| babbcb1dc5 | |||
| 12fde681ba | |||
| 63922f0841 | |||
| 896cd4fa21 | |||
| 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 | |||
| 718aff5918 | |||
| 70311e6331 | |||
| 6a3da5aded | |||
| 35c3d00f17 | |||
| 3a5cb0de21 | |||
| f72d44d5e5 | |||
| a7b244502e | |||
| 72d5a51b44 | |||
| 12f46715c1 | |||
| 62f674b15d | |||
| b0a67744f5 | |||
| 431fb47498 | |||
| 04069e080a | |||
| dd1f690892 | |||
| 8b04deb608 | |||
| 531fb12eab | |||
| e666abefad | |||
| f1e9fd7cf8 | |||
| 9134812e32 | |||
| 4844576c3b | |||
| 4976f2e694 | |||
| d6a7b062f4 | |||
| 94c6ac7fa7 | |||
| e0f96001e2 | |||
| 3d89ba3b6f | |||
| 3afc8ed3d8 |
@@ -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']
|
||||||
@@ -45,6 +45,7 @@ htmlcov/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.db.back
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# Contributing to Homelable
|
||||||
|
|
||||||
|
Thanks for taking the time to contribute! This document covers everything you need to get started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Ways to Contribute](#ways-to-contribute)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Suggesting Features](#suggesting-features)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Standards](#coding-standards)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||||
|
- [Commit Message Format](#commit-message-format)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ways to Contribute
|
||||||
|
|
||||||
|
- Report bugs or unexpected behavior
|
||||||
|
- Suggest new features or improvements
|
||||||
|
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
|
||||||
|
- Improve documentation
|
||||||
|
- Add service signatures to `service_signatures.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Before opening an issue, search existing ones to avoid duplicates.
|
||||||
|
|
||||||
|
When filing a bug, include:
|
||||||
|
|
||||||
|
- **Homelable version** (visible in the sidebar bottom-left)
|
||||||
|
- **Deployment method** (Docker Compose, Proxmox LXC, source)
|
||||||
|
- **Steps to reproduce**
|
||||||
|
- **Expected vs actual behavior**
|
||||||
|
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
|
||||||
|
- **Browser console errors** if it's a UI issue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggesting Features
|
||||||
|
|
||||||
|
Open an issue with the `enhancement` label. Describe:
|
||||||
|
|
||||||
|
- The problem you're trying to solve
|
||||||
|
- Your proposed solution
|
||||||
|
- Any alternatives you considered
|
||||||
|
|
||||||
|
For large changes, discuss first before writing code — it avoids wasted effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+** and **npm**
|
||||||
|
- **Python 3.11–3.13** (3.14 not yet supported by all dependencies)
|
||||||
|
- **nmap** installed on your system (required for scanner)
|
||||||
|
- **Docker + Docker Compose** (optional, for full-stack testing)
|
||||||
|
|
||||||
|
### 1. Clone the repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Pouzor/homelable.git
|
||||||
|
cd homelable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3.13 -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Copy and configure environment
|
||||||
|
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
|
||||||
|
|
||||||
|
# Start the backend (auto-reloads on change)
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
API docs available at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to `localhost:8000` — the backend must be running.
|
||||||
|
|
||||||
|
### 4. Verify tooling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify-tooling.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
homelable/
|
||||||
|
├── frontend/src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
|
||||||
|
│ │ ├── panels/ # Sidebar, detail panel, toolbar
|
||||||
|
│ │ ├── modals/ # Add/edit node, scan config, pending devices
|
||||||
|
│ │ └── ui/ # Shadcn/ui base components
|
||||||
|
│ ├── stores/ # Zustand state (canvas, auth, scan)
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── types/ # TypeScript interfaces & enums
|
||||||
|
│ ├── api/ # Axios client & typed endpoints
|
||||||
|
│ └── utils/ # Layout, export, color helpers
|
||||||
|
│
|
||||||
|
├── backend/app/
|
||||||
|
│ ├── api/routes/ # FastAPI route handlers
|
||||||
|
│ ├── services/ # Scanner, status checker, canvas service
|
||||||
|
│ ├── db/ # SQLAlchemy models, Alembic migrations
|
||||||
|
│ ├── schemas/ # Pydantic request/response schemas
|
||||||
|
│ └── core/ # Config, JWT, scheduler
|
||||||
|
│
|
||||||
|
├── docker/ # Nginx configs
|
||||||
|
├── scripts/ # LXC bootstrap, dev helpers
|
||||||
|
└── mcp/ # MCP server (AI integration)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- No untested code merged — every feature or fix must include tests
|
||||||
|
- Keep changes focused — one concern per PR
|
||||||
|
|
||||||
|
### Frontend (TypeScript + React)
|
||||||
|
|
||||||
|
- Strict TypeScript — no `any`, no type assertions unless truly necessary
|
||||||
|
- React Flow node domain fields go in `node.data`, never on the node root
|
||||||
|
- State management via Zustand stores — no prop drilling beyond 2 levels
|
||||||
|
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
|
||||||
|
- Run before committing:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Python + FastAPI)
|
||||||
|
|
||||||
|
- Python 3.11+ syntax
|
||||||
|
- Pydantic v2 schemas for all request/response types
|
||||||
|
- SQLAlchemy async sessions — never block the event loop
|
||||||
|
- Scanner logic runs in a background thread — never in an async route directly
|
||||||
|
- All schema changes via Alembic migrations — never modify tables directly
|
||||||
|
- Run before committing:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
ruff check .
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design System
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#0d1117` |
|
||||||
|
| Surface | `#161b22` |
|
||||||
|
| Card | `#21262d` |
|
||||||
|
| Accent cyan | `#00d4ff` |
|
||||||
|
| Online | `#39d353` |
|
||||||
|
| Offline | `#f85149` |
|
||||||
|
| Pending | `#e3b341` |
|
||||||
|
| Font (UI) | Inter |
|
||||||
|
| Font (IPs/ports) | JetBrains Mono |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm test # run all tests
|
||||||
|
npm run test:coverage # with coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
|
||||||
|
|
||||||
|
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
pytest # run all tests
|
||||||
|
pytest -v tests/test_nodes.py # single file
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files live in `backend/tests/test_*.py`.
|
||||||
|
|
||||||
|
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
|
||||||
|
|
||||||
|
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submitting a Pull Request
|
||||||
|
|
||||||
|
1. **Fork** the repo and create a branch from `main`:
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Make your changes** — include tests.
|
||||||
|
|
||||||
|
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
|
||||||
|
|
||||||
|
4. **Open a PR** against `main`:
|
||||||
|
- Use a clear title (see commit format below)
|
||||||
|
- Describe what changed and why
|
||||||
|
- Reference any related issues (`Closes #123`)
|
||||||
|
- Include screenshots for UI changes
|
||||||
|
|
||||||
|
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Message Format
|
||||||
|
|
||||||
|
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>: <short description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | When to use |
|
||||||
|
|---|---|
|
||||||
|
| `feat` | New feature |
|
||||||
|
| `fix` | Bug fix |
|
||||||
|
| `docs` | Documentation only |
|
||||||
|
| `refactor` | Code change with no behavior change |
|
||||||
|
| `test` | Adding or fixing tests |
|
||||||
|
| `chore` | Build, deps, tooling |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
feat: add logout button to sidebar
|
||||||
|
fix: stop click propagation on pending device checkbox
|
||||||
|
docs: add CONTRIBUTING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
|
||||||
@@ -2,13 +2,14 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
# 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 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
# Create data directory (volume mount point)
|
# Create data directory (volume mount point)
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
COPY VERSION ../VERSION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: serve
|
# Stage 2: serve
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -74,6 +74,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 (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.
|
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 +131,7 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optionnal)
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
|
custom_style=state.custom_style if state else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,13 +68,14 @@ async def save_canvas(
|
|||||||
else:
|
else:
|
||||||
db.add(Edge(**edge_data.model_dump()))
|
db.add(Edge(**edge_data.model_dump()))
|
||||||
|
|
||||||
# Upsert viewport
|
# Upsert viewport + custom style
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, 1)
|
||||||
if state:
|
if state:
|
||||||
state.viewport = body.viewport
|
state.viewport = body.viewport
|
||||||
|
state.custom_style = body.custom_style
|
||||||
state.saved_at = datetime.now(timezone.utc)
|
state.saved_at = datetime.now(timezone.utc)
|
||||||
else:
|
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()
|
await db.commit()
|
||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
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.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import request_cancel, run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
|
class BulkActionRequest(BaseModel):
|
||||||
|
device_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
|
|
||||||
@@ -37,7 +41,15 @@ router = APIRouter()
|
|||||||
|
|
||||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
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)
|
@router.post("/trigger", response_model=ScanRunResponse)
|
||||||
@@ -85,12 +97,10 @@ async def clear_pending(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
from sqlalchemy import delete as sa_delete
|
||||||
devices = result.scalars().all()
|
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
for device in devices:
|
|
||||||
await db.delete(device)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"deleted": len(devices)}
|
return {"deleted": result.rowcount}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||||
@@ -99,6 +109,109 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/bulk-approve", response_model=dict)
|
||||||
|
async def bulk_approve_devices(
|
||||||
|
payload: BulkActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
|
PendingDevice.status == "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
created_nodes: list[Node] = []
|
||||||
|
for device in devices:
|
||||||
|
device.status = "approved"
|
||||||
|
node = Node(
|
||||||
|
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||||
|
type=device.suggested_type or "generic",
|
||||||
|
ip=device.ip,
|
||||||
|
hostname=device.hostname,
|
||||||
|
status="unknown",
|
||||||
|
services=device.services or [],
|
||||||
|
ieee_address=device.ieee_address,
|
||||||
|
# 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="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)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
async def approve_device(
|
async def approve_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -107,13 +220,118 @@ async def approve_device(
|
|||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
device.status = "approved"
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
node = Node(**node_data.model_dump())
|
if device.status != "pending":
|
||||||
db.add(node)
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
await db.commit()
|
device.status = "approved"
|
||||||
return {"approved": True, "node_id": node.id}
|
node = Node(
|
||||||
return {"approved": False}
|
label=node_data.label,
|
||||||
|
type=node_data.type,
|
||||||
|
ip=node_data.ip,
|
||||||
|
hostname=node_data.hostname,
|
||||||
|
status=node_data.status,
|
||||||
|
services=node_data.services or [],
|
||||||
|
ieee_address=device.ieee_address,
|
||||||
|
# Honour caller-supplied check_method, else default to ping when an IP exists
|
||||||
|
# so the scheduler doesn't silently skip the new node.
|
||||||
|
check_method=node_data.check_method or ("ping" if node_data.ip else None),
|
||||||
|
check_target=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")
|
@router.post("/pending/{device_id}/hide")
|
||||||
@@ -153,10 +371,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
|||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
|
previous = settings.scanner_ranges
|
||||||
|
settings.scanner_ranges = payload.ranges
|
||||||
try:
|
try:
|
||||||
settings.scanner_ranges = payload.ranges
|
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
settings.scanner_ranges = previous
|
||||||
logger.error("Failed to save scan config: %s", exc)
|
logger.error("Failed to save scan config: %s", exc)
|
||||||
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""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 fetch_networkmap, 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
|
||||||
|
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:
|
||||||
|
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="unknown",
|
||||||
|
ieee_address=ieee,
|
||||||
|
services=[],
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
await db.flush()
|
||||||
|
coordinator_out = ZigbeeCoordinatorOut(
|
||||||
|
id=node.id, label=label, ieee_address=ieee
|
||||||
|
)
|
||||||
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
for candidate in [
|
||||||
|
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||||
|
Path("/app/VERSION"), # Docker image
|
||||||
|
]:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate.read_text().strip()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
APP_VERSION = _read_version()
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.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 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
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -23,7 +44,22 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_db() -> None:
|
||||||
|
db_path = Path(settings.sqlite_path)
|
||||||
|
if not db_path.exists():
|
||||||
|
return
|
||||||
|
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
|
||||||
|
if backup_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
shutil.copy2(db_path, backup_path)
|
||||||
|
logger.info("DB backup created: %s", backup_path.name)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Could not create DB backup at %s", backup_path)
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
|
_backup_db()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Add columns introduced after initial schema (idempotent)
|
# Add columns introduced after initial schema (idempotent)
|
||||||
@@ -61,6 +97,106 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
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(
|
||||||
|
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||||
|
"FROM nodes WHERE properties IS NULL"
|
||||||
|
)
|
||||||
|
for row in rows.fetchall():
|
||||||
|
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||||
|
props = []
|
||||||
|
visible = bool(show_hardware)
|
||||||
|
if cpu_model:
|
||||||
|
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||||
|
if cpu_count is not None:
|
||||||
|
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||||
|
if ram_gb is not None:
|
||||||
|
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||||
|
if disk_gb is not None:
|
||||||
|
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||||
|
import json as _json
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
|
(_json.dumps(props), node_id),
|
||||||
|
)
|
||||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||||
|
|||||||
@@ -42,14 +42,15 @@ class Node(Base):
|
|||||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: 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)
|
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))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||||
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ class Edge(Base):
|
|||||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_handle: Mapped[str | None] = mapped_column(String)
|
target_handle: Mapped[str | None] = mapped_column(String)
|
||||||
|
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ class CanvasState(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
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)
|
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +87,7 @@ class PendingDevice(Base):
|
|||||||
__tablename__ = "pending_devices"
|
__tablename__ = "pending_devices"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
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)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
os: Mapped[str | None] = mapped_column(String)
|
os: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -92,6 +95,31 @@ class PendingDevice(Base):
|
|||||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||||
status: Mapped[str] = mapped_column(String, default="pending")
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
discovery_source: Mapped[str | None] = mapped_column(String)
|
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)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +128,7 @@ class ScanRun(Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
status: Mapped[str] = mapped_column(String, default="running")
|
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)
|
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status, zigbee
|
||||||
from app.api.routes import settings as settings_routes
|
from app.api.routes import settings as settings_routes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.8.3",
|
version="1.9.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
|||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||||
|
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
properties: list[Any] = []
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
bottom_handles: int = 1
|
||||||
@@ -49,6 +50,7 @@ class EdgeSave(BaseModel):
|
|||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
@field_validator('animated', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -60,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
|
|||||||
nodes: list[NodeSave] = []
|
nodes: list[NodeSave] = []
|
||||||
edges: list[EdgeSave] = []
|
edges: list[EdgeSave] = []
|
||||||
viewport: dict[str, Any] = {}
|
viewport: dict[str, Any] = {}
|
||||||
|
custom_style: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class CanvasStateResponse(BaseModel):
|
class CanvasStateResponse(BaseModel):
|
||||||
nodes: list[NodeResponse]
|
nodes: list[NodeResponse]
|
||||||
edges: list[EdgeResponse]
|
edges: list[EdgeResponse]
|
||||||
viewport: dict[str, Any]
|
viewport: dict[str, Any]
|
||||||
|
custom_style: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class EdgeBase(BaseModel):
|
|||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
@field_validator('animated', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
animated: str | None = None
|
animated: str | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
@field_validator('animated', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
properties: list[dict[str, Any]] = []
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
bottom_handles: int = 1
|
||||||
@@ -59,6 +60,7 @@ class NodeUpdate(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool | None = None
|
show_hardware: bool | None = None
|
||||||
|
properties: list[dict[str, Any]] | None = None
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int | None = None
|
bottom_handles: int | None = None
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class PendingDeviceResponse(BaseModel):
|
class PendingDeviceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
ip: str
|
ip: str | None
|
||||||
mac: str | None
|
mac: str | None
|
||||||
hostname: str | None
|
hostname: str | None
|
||||||
os: str | None
|
os: str | None
|
||||||
@@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
discovery_source: str | None
|
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
|
discovered_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
@@ -22,6 +28,7 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
class ScanRunResponse(BaseModel):
|
class ScanRunResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
|
kind: str = "ip"
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
devices_found: int
|
devices_found: int
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
|
|||||||
return 'snake'
|
return 'snake'
|
||||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
||||||
return 'none'
|
return 'none'
|
||||||
if v in ('snake', 'flow'):
|
if v in ('snake', 'flow', 'basic'):
|
||||||
return str(v)
|
return str(v)
|
||||||
return 'none'
|
return 'none'
|
||||||
|
|||||||
@@ -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,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
if check_method == "none":
|
if check_method == "none":
|
||||||
return {"status": "online", "response_time_ms": None}
|
return {"status": "online", "response_time_ms": None}
|
||||||
|
|
||||||
host = target or ip
|
# Use only the first IP when the field contains comma-separated addresses
|
||||||
|
raw_ip = ip.split(",")[0].strip() if ip else None
|
||||||
|
host = target or raw_ip
|
||||||
if not host:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
|
||||||
@@ -58,8 +60,14 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
# 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":
|
if sys.platform == "win32":
|
||||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
args = ["ping", "-c", "1", "-W", "1000", host]
|
||||||
else:
|
else:
|
||||||
args = ["ping", "-c", "1", "-W", "1", host]
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""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 _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-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
scan_config.json
|
scan_config.json
|
||||||
|
homelab.db.*
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
omit = ["*/migrations/*", "*/tests/*"]
|
omit = ["*/migrations/*", "*/tests/*"]
|
||||||
|
concurrency = ["thread"]
|
||||||
|
core = "sysmon"
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
skip_empty = true
|
skip_empty = true
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pydantic-settings==2.5.2
|
|||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.27
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
@@ -17,10 +17,11 @@ types-PyYAML==6.0.12.20240917
|
|||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.131.0
|
zeroconf==0.131.0
|
||||||
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
mypy==1.11.2
|
mypy==1.11.2
|
||||||
pytest==8.3.3
|
pytest==9.0.3
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==1.3.0
|
||||||
pytest-cov==5.0.0
|
pytest-cov==5.0.0
|
||||||
|
|||||||
@@ -256,3 +256,343 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
|
|||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
assert canvas["nodes"][0]["width"] is None
|
assert canvas["nodes"][0]["width"] is None
|
||||||
assert canvas["nodes"][0]["height"] is None
|
assert canvas["nodes"][0]["height"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── properties ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
|
||||||
|
props = [
|
||||||
|
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
|
||||||
|
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
|
||||||
|
]
|
||||||
|
n1 = node_payload(properties=props)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
returned = canvas["nodes"][0]["properties"]
|
||||||
|
assert len(returned) == 2
|
||||||
|
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
|
||||||
|
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_updated = {**n1, "properties": [
|
||||||
|
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
|
||||||
|
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
|
||||||
|
]}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
props = canvas["nodes"][0]["properties"]
|
||||||
|
assert len(props) == 2
|
||||||
|
assert props[0]["value"] == "64 GB"
|
||||||
|
assert props[1]["key"] == "Disk"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
|
||||||
|
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
|
||||||
|
n1 = node_payload(properties=props)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["properties"][0]["icon"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_cleared = {**n1, "properties": []}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── edge waypoints & handles ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["waypoints"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
returned = canvas["edges"][0]["waypoints"]
|
||||||
|
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(bottom_handles=3)
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
edge = canvas["edges"][0]
|
||||||
|
assert edge["source_handle"] == "bottom-1"
|
||||||
|
assert edge["target_handle"] == "top"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["animated"] == "snake"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["edges"][0]["animated"] == "basic"
|
||||||
|
|
||||||
|
|
||||||
|
# ── node fields ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(
|
||||||
|
type="server",
|
||||||
|
label="Main Server",
|
||||||
|
hostname="server.local",
|
||||||
|
ip="192.168.1.10",
|
||||||
|
mac="aa:bb:cc:dd:ee:ff",
|
||||||
|
os="Ubuntu 22.04",
|
||||||
|
status="online",
|
||||||
|
check_method="http",
|
||||||
|
check_target="http://192.168.1.10",
|
||||||
|
services=[{"name": "nginx", "port": 80}],
|
||||||
|
notes="Primary web server",
|
||||||
|
pos_x=150.0,
|
||||||
|
pos_y=250.0,
|
||||||
|
bottom_handles=2,
|
||||||
|
)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["hostname"] == "server.local"
|
||||||
|
assert node["ip"] == "192.168.1.10"
|
||||||
|
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||||
|
assert node["os"] == "Ubuntu 22.04"
|
||||||
|
assert node["status"] == "online"
|
||||||
|
assert node["check_method"] == "http"
|
||||||
|
assert node["check_target"] == "http://192.168.1.10"
|
||||||
|
assert node["services"] == [{"name": "nginx", "port": 80}]
|
||||||
|
assert node["notes"] == "Primary web server"
|
||||||
|
assert node["pos_x"] == 150.0
|
||||||
|
assert node["pos_y"] == 250.0
|
||||||
|
assert node["bottom_handles"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(bottom_handles=4)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["bottom_handles"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["bottom_handles"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
|
||||||
|
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
|
||||||
|
n1 = node_payload(services=services, notes="My NAS device")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["services"] == services
|
||||||
|
assert node["notes"] == "My NAS device"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
||||||
|
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
||||||
|
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["services"] == services
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["check_method"] == "ping"
|
||||||
|
assert node["check_target"] == "192.168.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── parent/child nodes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
|
||||||
|
parent = node_payload(type="proxmox", label="PVE Host")
|
||||||
|
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||||
|
assert node_map[child["id"]]["parent_id"] == parent["id"]
|
||||||
|
assert node_map[parent["id"]]["parent_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
|
||||||
|
parent = node_payload(type="proxmox", label="PVE Host")
|
||||||
|
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
# Remove both parent and child
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── groupRect / group node ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
|
||||||
|
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
|
||||||
|
member = node_payload(type="server", label="Member", parent_id=group["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||||
|
assert node_map[group["id"]]["type"] == "group"
|
||||||
|
assert node_map[group["id"]]["width"] == 400.0
|
||||||
|
assert node_map[group["id"]]["height"] == 300.0
|
||||||
|
assert node_map[member["id"]]["parent_id"] == group["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── viewport ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
|
||||||
|
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
|
||||||
|
|
||||||
|
|
||||||
|
# ── edge types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
edge = canvas["edges"][0]
|
||||||
|
assert edge["type"] == "vlan"
|
||||||
|
assert edge["vlan_id"] == 10
|
||||||
|
assert edge["label"] == "VLAN 10"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
|
||||||
|
"""Second save updates an existing edge (exercises the db_edge branch)."""
|
||||||
|
n1 = node_payload()
|
||||||
|
n2 = node_payload()
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], label="original")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
edge = canvas["edges"][0]
|
||||||
|
assert edge["label"] == "updated"
|
||||||
|
assert edge["custom_color"] == "#ff0000"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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()
|
||||||
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
|||||||
async def test_create_node_requires_auth(client: AsyncClient):
|
async def test_create_node_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --- Properties tests ---
|
||||||
|
|
||||||
|
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
|
||||||
|
"""New node has an empty properties list by default."""
|
||||||
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
|
||||||
|
"""Node created with properties round-trips correctly."""
|
||||||
|
props = [
|
||||||
|
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
|
||||||
|
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
|
||||||
|
]
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_node_properties(client: AsyncClient, headers: dict):
|
||||||
|
"""PATCH with properties replaces the full properties array."""
|
||||||
|
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
|
||||||
|
"""PATCH that omits properties leaves existing properties untouched."""
|
||||||
|
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
# PATCH only the label — properties must survive
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
assert res.json()["label"] == "Updated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
|
||||||
|
"""PATCH with properties=[] explicitly clears all properties."""
|
||||||
|
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
|
||||||
|
"""GET /nodes/:id returns the properties field."""
|
||||||
|
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
node_id = create.json()["id"]
|
||||||
|
|
||||||
|
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["properties"] == props
|
||||||
|
|
||||||
|
|
||||||
|
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||||
|
"""A property with icon=null is valid and round-trips correctly."""
|
||||||
|
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert create.status_code == 201
|
||||||
|
assert create.json()["properties"] == props
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Tests for the hardware → properties migration logic.
|
||||||
|
|
||||||
|
We test the migration function directly against an in-memory SQLite database
|
||||||
|
so we can set up legacy rows (with hardware columns, NULL properties) and
|
||||||
|
verify the migration produces the expected properties JSON.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_legacy_table(conn):
|
||||||
|
"""Create a minimal nodes table that mimics the pre-migration schema."""
|
||||||
|
await conn.exec_driver_sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL DEFAULT 'generic',
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
cpu_model TEXT,
|
||||||
|
cpu_count INTEGER,
|
||||||
|
ram_gb REAL,
|
||||||
|
disk_gb REAL,
|
||||||
|
show_hardware BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
properties JSON
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_migration(conn):
|
||||||
|
"""Run only the properties migration portion (extracted from init_db)."""
|
||||||
|
rows = await conn.exec_driver_sql(
|
||||||
|
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||||
|
"FROM nodes WHERE properties IS NULL"
|
||||||
|
)
|
||||||
|
for row in rows.fetchall():
|
||||||
|
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||||
|
props = []
|
||||||
|
visible = bool(show_hardware)
|
||||||
|
if cpu_model:
|
||||||
|
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||||
|
if cpu_count is not None:
|
||||||
|
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||||
|
if ram_gb is not None:
|
||||||
|
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||||
|
if disk_gb is not None:
|
||||||
|
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
|
(json.dumps(props), node_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_properties(conn, node_id: str) -> list:
|
||||||
|
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
||||||
|
raw = rows.fetchone()[0]
|
||||||
|
return json.loads(raw) if raw else []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_full_hardware():
|
||||||
|
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-1")
|
||||||
|
|
||||||
|
assert len(props) == 4
|
||||||
|
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
|
||||||
|
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
|
||||||
|
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
|
||||||
|
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_partial_hardware():
|
||||||
|
"""Node with only cpu_model and ram_gb → 2 property entries."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||||
|
("node-2", "Ryzen 5 5600", 16.0, 0),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-2")
|
||||||
|
|
||||||
|
assert len(props) == 2
|
||||||
|
assert props[0]["key"] == "CPU Model"
|
||||||
|
assert props[0]["visible"] is False
|
||||||
|
assert props[1]["key"] == "RAM"
|
||||||
|
assert props[1]["icon"] == "MemoryStick"
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_no_hardware():
|
||||||
|
"""Node with no hardware fields → empty properties array."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id) VALUES (?)",
|
||||||
|
("node-3",),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-3")
|
||||||
|
|
||||||
|
assert props == []
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_idempotent():
|
||||||
|
"""Running migration twice does not duplicate properties."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
|
||||||
|
("node-4", "Core i5", 1),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
await _run_migration(conn) # second pass — node already has properties, should be skipped
|
||||||
|
props = await _get_properties(conn, "node-4")
|
||||||
|
|
||||||
|
assert len(props) == 1
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_show_hardware_false_sets_visible_false():
|
||||||
|
"""show_hardware=0 means all migrated properties have visible=False."""
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||||
|
("node-5", "ARM Cortex-A72", 4.0, 0),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-5")
|
||||||
|
|
||||||
|
assert all(p["visible"] is False for p in props)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_already_migrated_node_not_touched():
|
||||||
|
"""Node that already has properties is skipped — existing properties preserved."""
|
||||||
|
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||||
|
engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await _setup_legacy_table(conn)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
|
||||||
|
)
|
||||||
|
await _run_migration(conn)
|
||||||
|
props = await _get_properties(conn, "node-6")
|
||||||
|
|
||||||
|
assert props == existing
|
||||||
|
await engine.dispose()
|
||||||
@@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
|||||||
json=node_payload,
|
json=node_payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 404
|
||||||
assert res.json()["approved"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Hide device ---
|
# --- Hide device ---
|
||||||
@@ -141,6 +140,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
|
|||||||
assert len(hidden_res.json()) == 1
|
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 ---
|
# --- Ignore device ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -444,3 +486,331 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
|||||||
# Services and hostname should be updated
|
# Services and hostname should be updated
|
||||||
assert device.hostname == "myhost.lan"
|
assert device.hostname == "myhost.lan"
|
||||||
assert any(s["port"] == 8096 for s in device.services)
|
assert any(s["port"] == 8096 for s in device.services)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bulk approve ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def two_pending_devices(db_session):
|
||||||
|
devices = []
|
||||||
|
for i in range(2):
|
||||||
|
d = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip=f"192.168.1.{10 + i}",
|
||||||
|
mac=None,
|
||||||
|
hostname=f"host-{i}",
|
||||||
|
os=None,
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(d)
|
||||||
|
devices.append(d)
|
||||||
|
await db_session.commit()
|
||||||
|
for d in devices:
|
||||||
|
await db_session.refresh(d)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["approved"] == 2
|
||||||
|
assert len(data["node_ids"]) == 2
|
||||||
|
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
||||||
|
assert len(data["device_ids"]) == 2
|
||||||
|
assert data["skipped"] == 0
|
||||||
|
# Pending list should now be empty
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert pending_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_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
|
||||||
|
|||||||
@@ -169,6 +169,31 @@ async def test_ping_uses_unix_args_on_non_windows():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
assert "-n" not 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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""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_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"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
|
||||||
|
<!-- House body -->
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Floor line (subtle) -->
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
|
||||||
|
<!-- Door -->
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- Network lines (drawn under nodes) -->
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Center hub glow -->
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<!-- Center hub -->
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
|
||||||
|
<!-- Left node -->
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
|
||||||
|
<!-- Right node -->
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
|
||||||
|
<!-- Top node -->
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(4, 4)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(4, 4)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(4, 4)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Icon (72×72, scaled from 64 viewBox) -->
|
||||||
|
<g transform="translate(4, 4) scale(1)">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<text x="80" y="42"
|
||||||
|
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||||
|
font-weight="600"
|
||||||
|
font-size="30"
|
||||||
|
letter-spacing="-0.5">
|
||||||
|
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Subtitle -->
|
||||||
|
<text x="81" y="58"
|
||||||
|
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||||
|
font-weight="400"
|
||||||
|
font-size="11"
|
||||||
|
fill="#8b949e"
|
||||||
|
letter-spacing="0.5">HomeLab Visualizer</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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)_
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.8.2",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.8.2",
|
"version": "1.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.15.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
@@ -1645,9 +1646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.12",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -2011,6 +2012,415 @@
|
|||||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -2110,9 +2520,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2127,9 +2534,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2144,9 +2548,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2161,9 +2562,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2178,9 +2576,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2195,9 +2590,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2212,9 +2604,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2229,9 +2618,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2246,9 +2632,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2263,9 +2646,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2280,9 +2660,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2297,9 +2674,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2314,9 +2688,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2565,9 +2936,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2585,9 +2953,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2605,9 +2970,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2625,9 +2987,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3024,7 +3383,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -3776,12 +4135,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.14.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -5192,12 +5551,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.3.2",
|
"version": "8.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "10.1.0"
|
"ip-address": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -5283,9 +5642,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5447,9 +5806,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -5786,9 +6145,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.11",
|
"version": "4.12.18",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||||
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -5944,9 +6303,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -6525,9 +6884,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6549,9 +6905,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6573,9 +6926,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6597,9 +6947,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7468,9 +7815,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.3",
|
"version": "2.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.15.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
|
|||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
@@ -19,9 +19,12 @@ import { LoginPage } from '@/components/LoginPage'
|
|||||||
import { NodeModal } from '@/components/modals/NodeModal'
|
import { NodeModal } from '@/components/modals/NodeModal'
|
||||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -29,49 +32,62 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { canvasApi } from '@/api/client'
|
import { canvasApi } from '@/api/client'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
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 = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, 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 canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
||||||
const [highlightPendingId, setHighlightPendingId] = useState<string | 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 [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
|
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
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
|
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (STANDALONE) {
|
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()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
const edgesToSave = edges.map(serializeEdge)
|
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()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
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
|
// Keep a ref so the keydown handler always calls the latest version
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
@@ -83,8 +99,9 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
|
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
|
||||||
if (theme_id) setTheme(theme_id)
|
if (theme_id) setTheme(theme_id)
|
||||||
|
if (custom_style) setCustomStyle(custom_style)
|
||||||
loadCanvas(savedNodes, savedEdges)
|
loadCanvas(savedNodes, savedEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
@@ -99,23 +116,24 @@ export default function App() {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
if (apiNodes.length > 0) {
|
if (apiNodes.length > 0) {
|
||||||
// Build a map of proxmox container mode to know if children should be nested
|
// Build a map of container mode nodes to know if children should be nested
|
||||||
const proxmoxContainerMap = new Map<string, boolean>(
|
const proxmoxContainerMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
.map((n) => [n.id, true])
|
||||||
)
|
)
|
||||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(rfNodes, rfEdges)
|
loadCanvas(rfNodes, rfEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => 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
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
@@ -150,7 +168,7 @@ export default function App() {
|
|||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = generateUUID()
|
const id = generateUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isContainerNode = data.container_mode === true
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
// Children position is relative to parent; place near top-left with padding
|
// Children position is relative to parent; place near top-left with padding
|
||||||
const position = parentNode
|
const position = parentNode
|
||||||
@@ -163,7 +181,7 @@ export default function App() {
|
|||||||
position,
|
position,
|
||||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||||
...(isProxmox ? { width: 300, height: 200 } : {}),
|
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
@@ -225,6 +243,67 @@ export default function App() {
|
|||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
}, [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(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -241,13 +320,13 @@ export default function App() {
|
|||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||||
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
|
if (typeof data.container_mode === 'boolean') {
|
||||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||||
}
|
}
|
||||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||||
const nodeType = data.type ?? existingNode?.data.type
|
const nodeType = data.type ?? existingNode?.data.type
|
||||||
if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) {
|
if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) {
|
||||||
const oldParentId = existingNode?.data.parent_id ?? null
|
const oldParentId = existingNode?.data.parent_id ?? null
|
||||||
const newParentId = data.parent_id ?? null
|
const newParentId = data.parent_id ?? null
|
||||||
if (oldParentId !== newParentId) {
|
if (oldParentId !== newParentId) {
|
||||||
@@ -305,17 +384,60 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(() => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
try {
|
setExportModalOpen(true)
|
||||||
await exportToPng(el)
|
|
||||||
toast.success('Exported as PNG')
|
|
||||||
} catch {
|
|
||||||
toast.error('Export failed')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const 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) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -324,15 +446,19 @@ export default function App() {
|
|||||||
if (!pendingConnection) return
|
if (!pendingConnection) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
// When a virtual edge is drawn between a child node and a container node, sync parent_id
|
||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||||
const srcType = src?.data.type
|
const srcType = src?.data.type as NodeData['type']
|
||||||
const tgtType = tgt?.data.type
|
const tgtType = tgt?.data.type as NodeData['type']
|
||||||
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
|
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||||
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) {
|
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||||
|
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||||
|
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
|
||||||
|
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||||
|
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,6 +469,15 @@ export default function App() {
|
|||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -376,11 +511,12 @@ export default function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
|
onAddText={() => setAddTextOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
|
||||||
forceView={sidebarForceView}
|
forceView={sidebarForceView}
|
||||||
highlightPendingId={highlightPendingId}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -400,15 +536,9 @@ export default function App() {
|
|||||||
<CanvasContainer
|
<CanvasContainer
|
||||||
onConnect={handleEdgeConnect}
|
onConnect={handleEdgeConnect}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
@@ -417,11 +547,14 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NodeModal
|
<NodeModal
|
||||||
|
key={addNodeOpen ? 'add-open' : 'add-closed'}
|
||||||
open={addNodeOpen}
|
open={addNodeOpen}
|
||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
parentContainerNodes={nodes
|
||||||
|
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
||||||
|
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -432,7 +565,9 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
parentContainerNodes={nodes
|
||||||
|
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
||||||
|
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -470,6 +605,18 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZigbeeImportModal
|
||||||
|
open={zigbeeImportOpen}
|
||||||
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
@@ -505,6 +652,37 @@ export default function App() {
|
|||||||
title="Edit Zone"
|
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 */}
|
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||||
@@ -515,17 +693,23 @@ export default function App() {
|
|||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<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" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const canvasApi = {
|
|||||||
nodes: object[]
|
nodes: object[]
|
||||||
edges: object[]
|
edges: object[]
|
||||||
viewport: object
|
viewport: object
|
||||||
|
custom_style?: object | null
|
||||||
}) => api.post('/canvas/save', payload),
|
}) => api.post('/canvas/save', payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +58,27 @@ export const scanApi = {
|
|||||||
hidden: () => api.get('/scan/hidden'),
|
hidden: () => api.get('/scan/hidden'),
|
||||||
runs: () => api.get('/scan/runs'),
|
runs: () => api.get('/scan/runs'),
|
||||||
clearPending: () => api.delete('/scan/pending'),
|
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`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
|
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`),
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
@@ -69,3 +88,50 @@ export const settingsApi = {
|
|||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
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,16 +20,19 @@ import { THEMES } from '@/utils/themes'
|
|||||||
import { nodeTypes } from './nodes/nodeTypes'
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
|
import { AlignmentGuides } from './AlignmentGuides'
|
||||||
|
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
onOpenPending?: (deviceId: string) => void
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
@@ -68,6 +71,22 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick?.(edge)
|
onEdgeDoubleClick?.(edge)
|
||||||
}, [onEdgeDoubleClick])
|
}, [onEdgeDoubleClick])
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
|
onNodeDoubleClick?.(node)
|
||||||
|
}, [onNodeDoubleClick])
|
||||||
|
|
||||||
|
const handleBeforeDelete = useCallback(async () => {
|
||||||
|
snapshotHistory()
|
||||||
|
return true
|
||||||
|
}, [snapshotHistory])
|
||||||
|
|
||||||
|
const isValidConnection = useCallback(
|
||||||
|
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -79,22 +98,27 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
onBeforeDelete={handleBeforeDelete}
|
||||||
selectionOnDrag={lassoMode}
|
selectionOnDrag={lassoMode}
|
||||||
panOnDrag={lassoMode ? [1, 2] : true}
|
panOnDrag={lassoMode ? [1, 2] : true}
|
||||||
panActivationKeyCode="Space"
|
panActivationKeyCode="Space"
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
multiSelectionKeyCode={['Meta', 'Control']}
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2.5}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[8, 8]}
|
snapGrid={[8, 8]}
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
isValidConnection={(connection) => connection.source !== connection.target}
|
isValidConnection={isValidConnection}
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
variant={BackgroundVariant.Dots}
|
variant={BackgroundVariant.Dots}
|
||||||
@@ -103,6 +127,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<SearchBar onOpenPending={onOpenPending} />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
|
<AlignmentGuides guides={guides} />
|
||||||
<Controls>
|
<Controls>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onClick={() => setLassoMode((m) => !m)}
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
|
|
||||||
const pendingResults = q
|
const pendingResults = q
|
||||||
? pendingDevices.filter((d) =>
|
? pendingDevices.filter((d) =>
|
||||||
d.ip.toLowerCase().includes(q) ||
|
d.ip?.toLowerCase().includes(q) ||
|
||||||
d.hostname?.toLowerCase().includes(q) ||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.friendly_name?.toLowerCase().includes(q) ||
|
||||||
|
d.ieee_address?.toLowerCase().includes(q) ||
|
||||||
d.services.some((s) =>
|
d.services.some((s) =>
|
||||||
s.service_name?.toLowerCase().includes(q) ||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
s.category?.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: 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' }}>
|
<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>
|
||||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
{serviceName ?? d.ip}
|
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Server } from 'lucide-react'
|
||||||
|
import { BaseNode } from '../nodes/BaseNode'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
let mockZoom = 1
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
Handle: () => null,
|
||||||
|
Position: { Top: 'top', Bottom: 'bottom' },
|
||||||
|
NodeResizer: () => null,
|
||||||
|
useUpdateNodeInternals: () => vi.fn(),
|
||||||
|
useViewport: () => ({ zoom: mockZoom }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/themeStore', () => ({
|
||||||
|
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
|
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/themes', () => ({
|
||||||
|
THEMES: {
|
||||||
|
dark: {
|
||||||
|
colors: {
|
||||||
|
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
|
||||||
|
nodeSubtextColor: '#8b949e',
|
||||||
|
nodeLabelColor: '#e6edf3',
|
||||||
|
nodeIconBackground: '#21262d',
|
||||||
|
handleBackground: '#30363d',
|
||||||
|
handleBorder: '#30363d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeColors', () => ({
|
||||||
|
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/nodeIcons', () => ({
|
||||||
|
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||||
|
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', () => ({
|
||||||
|
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
|
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||||
|
bottomHandlePositions: () => [50],
|
||||||
|
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
|
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: data.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Test Node',
|
||||||
|
type: 'server',
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBaseNode(data: Partial<NodeData>) {
|
||||||
|
const node = makeNode(data)
|
||||||
|
return render(
|
||||||
|
<BaseNode
|
||||||
|
id={node.id}
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
icon={Server}
|
||||||
|
type="server"
|
||||||
|
dragging={false}
|
||||||
|
zIndex={0}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BaseNode — borderWidth zoom scaling', () => {
|
||||||
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
|
it('borderWidth is 1px at zoom=1', () => {
|
||||||
|
mockZoom = 1
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borderWidth scales to 2px at zoom=0.5', () => {
|
||||||
|
mockZoom = 0.5
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borderWidth is clamped to 1px at zoom=2', () => {
|
||||||
|
mockZoom = 2
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
||||||
|
mockZoom = 0.5
|
||||||
|
const node = makeNode({ status: 'online' })
|
||||||
|
const { container } = render(
|
||||||
|
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
|
||||||
|
type="server" dragging={false} zIndex={0} isConnectable={true}
|
||||||
|
positionAbsoluteX={0} positionAbsoluteY={0} />
|
||||||
|
)
|
||||||
|
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BaseNode — properties rendering', () => {
|
||||||
|
it('renders visible properties on the node', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: [
|
||||||
|
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||||
|
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||||
|
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||||
|
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render properties with visible=false', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: [
|
||||||
|
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Secret')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when properties array is empty', () => {
|
||||||
|
const { container } = renderBaseNode({ properties: [] })
|
||||||
|
// No properties section — only the main node card
|
||||||
|
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders label and ip regardless of properties', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
label: 'My Server',
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('My Server')).toBeDefined()
|
||||||
|
expect(screen.getByText('192.168.1.10')).toBeDefined()
|
||||||
|
expect(screen.getByText('OS')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BaseNode — 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({
|
||||||
|
properties: undefined,
|
||||||
|
show_hardware: true,
|
||||||
|
cpu_model: 'Intel Xeon E5-2680',
|
||||||
|
ram_gb: 32,
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render legacy hardware when properties array is present (even if empty)', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: [],
|
||||||
|
show_hardware: true,
|
||||||
|
cpu_model: 'Intel Xeon E5-2680',
|
||||||
|
})
|
||||||
|
// properties array exists → new system, legacy section skipped
|
||||||
|
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render legacy hardware when show_hardware is false', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
properties: undefined,
|
||||||
|
show_hardware: false,
|
||||||
|
cpu_model: 'Intel Xeon E5-2680',
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -104,6 +105,24 @@ describe('CanvasContainer', () => {
|
|||||||
}).not.toThrow()
|
}).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Node double-click ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
|
||||||
|
const onNodeDoubleClick = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
|
||||||
|
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||||
|
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw when onNodeDoubleClick is not provided', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(() => {
|
||||||
|
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Connection validation ─────────────────────────────────────────────────
|
// ── Connection validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
it('isValidConnection returns false for self-connections', () => {
|
it('isValidConnection returns false for self-connections', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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 { GroupNode } from '../nodes/GroupNode'
|
||||||
import * as canvasStore from '@/stores/canvasStore'
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
@@ -102,6 +102,19 @@ describe('GroupNode', () => {
|
|||||||
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
|
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', () => {
|
it('shows online/offline status summary from children', () => {
|
||||||
const storeNodes = [
|
const storeNodes = [
|
||||||
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
{ 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 { 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)', () => {
|
describe('buildWaypointPath — bezier (default)', () => {
|
||||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||||
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
|
|||||||
expect(idx).toBe(2)
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
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']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
|
|
||||||
@@ -161,9 +161,9 @@ function segmentMidpoints(
|
|||||||
const isSmooth = pathStyle === 'smooth'
|
const isSmooth = pathStyle === 'smooth'
|
||||||
|
|
||||||
return pts.slice(0, -1).map((a, i) => {
|
return pts.slice(0, -1).map((a, i) => {
|
||||||
const b = pts[i + 1]
|
const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
|
||||||
let mx = (a.x + b.x) / 2
|
let mx = base.x
|
||||||
const my = (a.y + b.y) / 2
|
const my = base.y
|
||||||
|
|
||||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||||
// the source handle axis so clicking it creates a perpendicular exit.
|
// the source handle axis so clicking it creates a perpendicular exit.
|
||||||
@@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
: autoPath
|
: autoPath
|
||||||
|
|
||||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
const labelPosition = hasWaypoints
|
||||||
const midY = (sourceY + targetY) / 2
|
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
|
: { x: labelX, y: (sourceY + targetY) / 2 }
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
const edgeColors = theme.colors.edgeColors
|
const edgeColors = theme.colors.edgeColors
|
||||||
@@ -233,9 +234,10 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const animMode: 'none' | 'snake' | 'flow' =
|
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
|
||||||
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
||||||
data?.animated === 'flow' ? 'flow' : 'none'
|
data?.animated === 'flow' ? 'flow' :
|
||||||
|
data?.animated === 'basic' ? 'basic' : 'none'
|
||||||
|
|
||||||
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||||
|
|
||||||
@@ -245,7 +247,22 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
|
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
|
||||||
|
|
||||||
|
{animMode === 'basic' && (
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={style.strokeWidth as number ?? 2}
|
||||||
|
strokeDasharray="5"
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
animation: 'homelable-basic-dash 0.5s linear infinite',
|
||||||
|
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{animMode === 'snake' && (
|
{animMode === 'snake' && (
|
||||||
<path
|
<path
|
||||||
@@ -284,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||||
style={{
|
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,
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: theme.colors.edgeLabelColor,
|
color: theme.colors.edgeLabelColor,
|
||||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
|
|||||||
@@ -72,6 +72,216 @@ export function buildWaypointPath(
|
|||||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
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 ──────────────────────────────────────────────────────────────
|
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { createElement, useEffect } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/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 type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
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 { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -23,6 +26,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
|
const { zoom } = useViewport()
|
||||||
|
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -31,24 +37,32 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
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
|
||||||
|
const showLegacyHardware = !data.properties && data.show_hardware &&
|
||||||
|
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
borderWidth: 1,
|
borderWidth,
|
||||||
boxShadow: isOnline && selected
|
boxShadow: isOnline && selected
|
||||||
? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
: isOnline
|
: isOnline
|
||||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
: selected
|
: selected
|
||||||
? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
// 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,
|
width: width ? '100%' : undefined,
|
||||||
height: height ? '100%' : undefined,
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -58,7 +72,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
minWidth={140}
|
minWidth={140}
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
@@ -68,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 }} />
|
<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 */}
|
{/* 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 */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
@@ -78,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
background: theme.colors.nodeIconBackground,
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Label + IP */}
|
{/* Label + IP */}
|
||||||
@@ -90,36 +113,122 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</div>
|
</div>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<div
|
<div
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[10px] truncate"
|
className="font-mono text-[10px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
title={data.ip}
|
title={ip}
|
||||||
>
|
>
|
||||||
{hideIp ? maskIp(data.ip) : data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hardware section */}
|
{/* Properties section (new system) */}
|
||||||
{showHardware && (
|
{visibleProperties && visibleProperties.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
|
{visibleProperties.map((prop) => {
|
||||||
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
|
return (
|
||||||
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
|
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
<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">
|
||||||
{/* Line 1: CPU */}
|
|
||||||
{(data.cpu_model || data.cpu_count != null) && (
|
{(data.cpu_model || data.cpu_count != null) && (
|
||||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
<Cpu size={9} className="shrink-0" />
|
<Cpu size={9} className="shrink-0" />
|
||||||
{data.cpu_model && (
|
{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 && (
|
{data.cpu_count != null && (
|
||||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Line 2: RAM + Disk */}
|
|
||||||
{(data.ram_gb != null || data.disk_gb != null) && (
|
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||||
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
{data.ram_gb != null && (
|
{data.ram_gb != null && (
|
||||||
@@ -140,16 +249,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status dot */}
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
<div
|
const sourceId = bottomHandleId(idx)
|
||||||
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
|
const targetId = `${sourceId}-t`
|
||||||
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`
|
|
||||||
return (
|
return (
|
||||||
<span key={sourceId}>
|
<span key={sourceId}>
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
|||||||
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
className="nodrag"
|
|
||||||
>
|
>
|
||||||
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
className="nodrag"
|
||||||
value={labelDraft}
|
value={labelDraft}
|
||||||
onChange={(e) => setLabelDraft(e.target.value)}
|
onChange={(e) => setLabelDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -97,11 +97,12 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
|||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
<button className="nodrag" 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={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
className="nodrag"
|
||||||
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
||||||
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
||||||
title="Rename group"
|
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 { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import type { NodeData, TextPosition } from '@/types'
|
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' },
|
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HANDLE_SIDES = [
|
||||||
|
{ id: 'zone-top', position: Position.Top },
|
||||||
|
{ id: 'zone-right', position: Position.Right },
|
||||||
|
{ id: 'zone-bottom', position: Position.Bottom },
|
||||||
|
{ id: 'zone-left', position: Position.Left },
|
||||||
|
] as const
|
||||||
|
|
||||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
const borderColor = rc.border ?? '#00d4ff'
|
||||||
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStyle: React.CSSProperties = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: borderColor,
|
||||||
|
border: '2px solid #0d1117',
|
||||||
|
borderRadius: '50%',
|
||||||
|
opacity: hovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
@@ -75,6 +94,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
}}
|
}}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{HANDLE_SIDES.map(({ id: hid, position }) => (
|
||||||
|
<span key={hid}>
|
||||||
|
<Handle type="source" id={hid} position={position} style={handleStyle} />
|
||||||
|
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -92,6 +119,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditingGroupRectId(id)
|
setEditingGroupRectId(id)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
import { resolveNodeIcon, 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 { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
@@ -10,6 +16,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
const { data, selected } = props
|
const { data, selected } = props
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
|
|
||||||
@@ -41,6 +48,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
const glow = colors.border
|
const glow = colors.border
|
||||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
||||||
|
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -49,7 +57,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
minHeight={160}
|
minHeight={160}
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Group border */}
|
{/* Group border */}
|
||||||
@@ -67,7 +75,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||||
@@ -80,7 +88,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
background: theme.colors.nodeIconBackground,
|
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>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
@@ -89,23 +99,45 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<span
|
<span
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[9px] truncate"
|
className="font-mono text-[9px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
>
|
>
|
||||||
{data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div
|
<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 }}
|
style={{ backgroundColor: statusColor }}
|
||||||
title={data.status}
|
title={data.status}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
|
||||||
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={prop.key}
|
||||||
|
className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden px-2.5 shrink-0"
|
||||||
|
style={{
|
||||||
|
color: theme.colors.nodeSubtextColor,
|
||||||
|
paddingTop: i === 0 ? 4 : 2,
|
||||||
|
paddingBottom: i === arr.length - 1 ? 4 : 2,
|
||||||
|
borderTop: i === 0 ? `1px solid ${glow}22` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
|
<span className="truncate max-w-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 */}
|
{/* Inner area — React Flow places children here */}
|
||||||
<div className="flex-1 relative" />
|
<div className="flex-1 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,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 { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
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 IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
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 SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
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 PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||||
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
|
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
// 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 { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
|
import { TextNode } from './TextNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
router: RouterNode,
|
router: RouterNode,
|
||||||
|
firewall: FirewallNode,
|
||||||
switch: SwitchNode,
|
switch: SwitchNode,
|
||||||
server: ServerNode,
|
server: ServerNode,
|
||||||
proxmox: ProxmoxGroupNode,
|
proxmox: ProxmoxGroupNode,
|
||||||
@@ -18,8 +20,13 @@ export const nodeTypes = {
|
|||||||
printer: PrinterNode,
|
printer: PrinterNode,
|
||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
docker: DockerNode,
|
docker_host: DockerHostNode,
|
||||||
|
docker_container: DockerContainerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
group: GroupNode,
|
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 { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -10,11 +11,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
|||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
||||||
|
|
||||||
type AnimMode = 'none' | 'snake' | 'flow'
|
type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
|
||||||
|
|
||||||
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
||||||
if (v === true || v === 'snake') return 'snake'
|
if (v === true || v === 'snake') return 'snake'
|
||||||
if (v === 'flow') return 'flow'
|
if (v === 'flow') return 'flow'
|
||||||
|
if (v === 'basic') return 'basic'
|
||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
||||||
<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']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
|
||||||
<SelectValue />
|
<SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{EDGE_TYPES.map(([value, label]) => (
|
{EDGE_TYPES.map(([value, label]) => (
|
||||||
@@ -88,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
value={vlanId}
|
value={vlanId}
|
||||||
onChange={(e) => setVlanId(e.target.value)}
|
onChange={(e) => setVlanId(e.target.value)}
|
||||||
placeholder="e.g. 20"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -99,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. 1G, trunk..."
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
<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) => (
|
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
|
||||||
<button
|
<button
|
||||||
key={style}
|
key={style}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPathStyle(style)}
|
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={{
|
style={{
|
||||||
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
||||||
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
||||||
@@ -126,20 +130,22 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
<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', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAnimation(mode)}
|
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={{
|
style={{
|
||||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||||
borderRight: i < 2 ? '1px solid #30363d' : undefined,
|
borderRight: i < 3 ? '1px solid #30363d' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
|
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -159,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label
|
<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' }}
|
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Edge color picker"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -188,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={handleDelete}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
<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">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
{onDelete ? 'Save' : 'Connect'}
|
{onDelete ? 'Save' : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import type { TextPosition } from '@/types'
|
import type { TextPosition } from '@/types'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
|
||||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
value={form.label}
|
value={form.label}
|
||||||
onChange={(e) => set('label', e.target.value)}
|
onChange={(e) => set('label', e.target.value)}
|
||||||
placeholder="Zone name…"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
<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']} ${modalStyles['modal-radius']}`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -161,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
title={value}
|
title={value}
|
||||||
onClick={() => set('text_position', 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']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -186,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('label_position', value)}
|
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']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -204,23 +206,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{colorFields.map(({ key, label }) => (
|
{colorFields.map(({ key, label }) => {
|
||||||
<div key={key} className="flex flex-col gap-1 items-center">
|
const { hex6, alpha } = hexToRgba(form[key])
|
||||||
<label
|
return (
|
||||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
style={{ borderColor: '#30363d' }}
|
<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
|
<input
|
||||||
type="color"
|
type="range"
|
||||||
value={form[key]}
|
min={0}
|
||||||
onChange={(e) => set(key, e.target.value)}
|
max={100}
|
||||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
value={alpha}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
|
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title={`Opacity: ${alpha}%`}
|
||||||
/>
|
/>
|
||||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
</label>
|
</div>
|
||||||
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('text_size', value)}
|
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']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -262,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
onClick={() => set('border_style', value)}
|
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']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -288,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('border_width', value)}
|
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']}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -306,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
<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)}>
|
<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']}`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -325,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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() }}
|
onClick={() => { onDelete(); onClose() }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 ml-auto">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
{title === 'Add Zone' ? 'Add' : 'Save'}
|
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 { RotateCcw, ChevronDown } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -7,16 +8,31 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, 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'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
|
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
|
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
|
|
||||||
|
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||||
|
none: 'None',
|
||||||
|
ping: 'Ping',
|
||||||
|
http: 'HTTP',
|
||||||
|
https: 'HTTPS',
|
||||||
|
tcp: 'TCP',
|
||||||
|
ssh: 'SSH',
|
||||||
|
prometheus: 'Prometheus',
|
||||||
|
health: 'Health',
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_DATA: Partial<NodeData> = {
|
const DEFAULT_DATA: Partial<NodeData> = {
|
||||||
type: 'server',
|
type: 'server',
|
||||||
@@ -26,7 +42,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
check_method: 'ping',
|
check_method: 'ping',
|
||||||
services: [],
|
services: [],
|
||||||
container_mode: true,
|
container_mode: false,
|
||||||
custom_colors: undefined,
|
custom_colors: undefined,
|
||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
@@ -37,20 +53,24 @@ interface NodeModalProps {
|
|||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
proxmoxNodes?: { id: string; label: string }[]
|
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough — no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
|
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||||
const [labelError, setLabelError] = useState(false)
|
const [labelError, setLabelError] = useState(false)
|
||||||
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
|
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||||
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
|
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) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
@@ -62,13 +82,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLabelError(false)
|
setLabelError(false)
|
||||||
onSubmit(form)
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
|
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||||
|
onSubmit({
|
||||||
|
...form,
|
||||||
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredParentNodes = form.type === 'docker_container'
|
||||||
|
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
||||||
|
: parentContainerNodes
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -79,14 +108,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
<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 />
|
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||||
<>
|
<Fragment key={group.label}>
|
||||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
|
||||||
<SelectGroup key={group.label}>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||||
{group.label}
|
{group.label}
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
@@ -96,7 +125,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -120,10 +149,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIconPickerOpen((o) => !o)}
|
onClick={() => setIconPickerOpen((o) => !o)}
|
||||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors 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">
|
<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)
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||||
@@ -136,14 +170,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline icon picker — full width, shown below the type+icon row */}
|
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
<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
|
<Input
|
||||||
value={iconSearch}
|
value={iconSearch}
|
||||||
onChange={(e) => setIconSearch(e.target.value)}
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
placeholder="Search icons…"
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||||
@@ -165,7 +230,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
type="button"
|
type="button"
|
||||||
title={entry.label}
|
title={entry.label}
|
||||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
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={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||||
@@ -183,6 +249,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -193,7 +261,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.label ?? ''}
|
value={form.label ?? ''}
|
||||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||||
placeholder="My Server"
|
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>}
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +273,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.hostname ?? ''}
|
value={form.hostname ?? ''}
|
||||||
onChange={(e) => set('hostname', e.target.value)}
|
onChange={(e) => set('hostname', e.target.value)}
|
||||||
placeholder="server.lan"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -215,21 +283,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Input
|
<Input
|
||||||
value={form.ip ?? ''}
|
value={form.ip ?? ''}
|
||||||
onChange={(e) => set('ip', e.target.value)}
|
onChange={(e) => set('ip', e.target.value)}
|
||||||
placeholder="192.168.1.x"
|
placeholder="192.168.1.x, 2001:db8::1"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check method */}
|
{/* Check method */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
||||||
<SelectValue />
|
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{CHECK_METHODS.map((m) => (
|
{CHECK_METHODS.map((m) => (
|
||||||
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
|
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -242,24 +311,28 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.check_target ?? ''}
|
value={form.check_target ?? ''}
|
||||||
onChange={(e) => set('check_target', e.target.value)}
|
onChange={(e) => set('check_target', e.target.value)}
|
||||||
placeholder="http://..."
|
placeholder="http://..."
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Parent Proxmox (VM / LXC only) */}
|
{/* Parent container */}
|
||||||
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
|
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.parent_id ?? 'none'}
|
value={form.parent_id ?? 'none'}
|
||||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
||||||
<SelectValue placeholder="None (standalone)" />
|
<SelectValue placeholder="None (standalone)">
|
||||||
|
{form.parent_id
|
||||||
|
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
||||||
|
: 'None (standalone)'}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
||||||
{proxmoxNodes.map((n) => (
|
{filteredParentNodes.map((n) => (
|
||||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -267,24 +340,57 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Container mode (proxmox only) */}
|
{/* Container mode */}
|
||||||
{form.type === 'proxmox' && (
|
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||||
<div className="flex items-center justify-between col-span-2 py-1">
|
<div className="flex items-center justify-between col-span-2 py-1">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||||
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
Allow other nodes to nest inside this node
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={!!form.container_mode}
|
aria-label="Show Services"
|
||||||
onClick={() => set('container_mode', !form.container_mode)}
|
aria-checked={showServicesEnabled}
|
||||||
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"
|
onClick={() => set('custom_colors', {
|
||||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
...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
|
<span
|
||||||
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
className="pointer-events-none absolute top-px 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)' }}
|
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,10 +400,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<div className="flex flex-col gap-2 col-span-2">
|
<div className="flex flex-col gap-2 col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
||||||
{form.custom_colors && (
|
{hasAppearanceOverrides && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw size={10} /> Reset to defaults
|
<RotateCcw size={10} /> Reset to defaults
|
||||||
@@ -312,9 +428,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1 items-center">
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
<label
|
<label
|
||||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden 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' }}
|
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
||||||
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Color picker for ${key}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -329,111 +447,34 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!form.custom_colors && (
|
<div className="min-h-3.5">
|
||||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
{!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>
|
|
||||||
|
|
||||||
{/* Hardware specs (hidden for groupRect) */}
|
|
||||||
{form.type !== 'groupRect' && (
|
|
||||||
<div className="flex flex-col gap-2 col-span-2">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setHardwareOpen((o) => !o)}
|
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span className="font-medium">Hardware</span>
|
|
||||||
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
|
||||||
</button>
|
|
||||||
{hardwareOpen && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={!!form.show_hardware}
|
|
||||||
onClick={() => set('show_hardware', !form.show_hardware)}
|
|
||||||
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
|
||||||
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
|
||||||
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hardwareOpen && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
|
||||||
<Input
|
|
||||||
value={form.cpu_model ?? ''}
|
|
||||||
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
|
||||||
placeholder="e.g. Intel Xeon E5-2680"
|
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.cpu_count ?? ''}
|
|
||||||
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
|
||||||
placeholder="e.g. 8"
|
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={0.5}
|
|
||||||
value={form.ram_gb ?? ''}
|
|
||||||
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
placeholder="e.g. 32"
|
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={form.disk_gb ?? ''}
|
|
||||||
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
placeholder="e.g. 500"
|
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Bottom connection points (not for group containers) */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Select
|
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||||
value={String(form.bottom_handles ?? 1)}
|
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
</div>
|
||||||
>
|
<input
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
type="range"
|
||||||
<SelectValue />
|
min={MIN_BOTTOM_HANDLES}
|
||||||
</SelectTrigger>
|
max={MAX_BOTTOM_HANDLES}
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
step={1}
|
||||||
<SelectItem value="1" className="text-sm">1 — center</SelectItem>
|
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||||
<SelectItem value="2" className="text-sm">2 — left / right</SelectItem>
|
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||||
<SelectItem value="3" className="text-sm">3 — left / center / right</SelectItem>
|
aria-label="Bottom connection points slider"
|
||||||
<SelectItem value="4" className="text-sm">4 — evenly spaced</SelectItem>
|
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -444,25 +485,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.notes ?? ''}
|
value={form.notes ?? ''}
|
||||||
onChange={(e) => set('notes', e.target.value)}
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
placeholder="Optional notes"
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
{/* Show delete button only for edit mode (not add) */}
|
||||||
Cancel
|
{title !== 'Add Node' ? (
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="submit"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
>
|
onClick={() => {
|
||||||
{title === 'Add Node' ? 'Add' : 'Save'}
|
if (window.confirm('Delete this node?')) {
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ interface Service {
|
|||||||
|
|
||||||
export interface PendingDevice {
|
export interface PendingDevice {
|
||||||
id: string
|
id: string
|
||||||
ip: string
|
ip: string | null
|
||||||
mac: string | null
|
mac: string | null
|
||||||
hostname: string | null
|
hostname: string | null
|
||||||
os: string | null
|
os: string | null
|
||||||
@@ -20,6 +20,12 @@ export interface PendingDevice {
|
|||||||
suggested_type: string | null
|
suggested_type: string | null
|
||||||
status: string
|
status: string
|
||||||
discovery_source: string | null
|
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
|
discovered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
if (!device) return null
|
if (!device) return null
|
||||||
|
|
||||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
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 handleApprove = () => { onApprove(device) }
|
||||||
const handleHide = () => { onHide(device); onClose() }
|
const handleHide = () => { onHide(device); onClose() }
|
||||||
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
<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>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
{/* Device info */}
|
{/* Device info */}
|
||||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
<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.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
{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 && (
|
{device.suggested_type && (
|
||||||
<InfoRow label="Type" value={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()} />
|
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||||
<div>
|
{!isZigbee && <div>
|
||||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||||
Services found ({device.services.length})
|
Services found ({device.services.length})
|
||||||
</p>
|
</p>
|
||||||
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
|
|||||||
@@ -0,0 +1,677 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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 nodeData = {
|
||||||
|
label: fallbackLabel,
|
||||||
|
type: (device.suggested_type ?? 'generic') as NodeType,
|
||||||
|
ip: device.ip ?? undefined,
|
||||||
|
hostname: device.hostname ?? undefined,
|
||||||
|
status: 'unknown',
|
||||||
|
services: (device.services ?? []) as 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 },
|
||||||
|
})
|
||||||
|
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
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||||
|
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||||
|
data: {
|
||||||
|
label: deviceLabel(d),
|
||||||
|
type: (d.suggested_type ?? 'generic') as NodeType,
|
||||||
|
ip: d.ip ?? undefined,
|
||||||
|
hostname: d.hostname ?? undefined,
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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)
|
).slice(0, 6)
|
||||||
|
|
||||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||||
d.ip.toLowerCase().includes(q) ||
|
d.ip?.toLowerCase().includes(q) ||
|
||||||
d.hostname?.toLowerCase().includes(q) ||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.friendly_name?.toLowerCase().includes(q) ||
|
||||||
|
d.ieee_address?.toLowerCase().includes(q) ||
|
||||||
d.services.some((s) =>
|
d.services.some((s) =>
|
||||||
s.service_name?.toLowerCase().includes(q) ||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
s.category?.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 { toast } from 'sonner'
|
||||||
import { Check } from 'lucide-react'
|
import { Check, Pencil } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { CustomStyleModal } from './CustomStyleModal'
|
||||||
|
|
||||||
// Node-type accent colors to display as preview swatches
|
// Node-type accent colors to display as preview swatches
|
||||||
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
||||||
@@ -14,74 +15,108 @@ interface ThemeCardProps {
|
|||||||
themeId: ThemeId
|
themeId: ThemeId
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onClick: () => void
|
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 preset = THEMES[themeId]
|
||||||
const c = preset.colors
|
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 (
|
return (
|
||||||
<button
|
<div className="relative w-full h-full">
|
||||||
type="button"
|
<button
|
||||||
onClick={onClick}
|
ref={buttonRef}
|
||||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
type="button"
|
||||||
style={{
|
onClick={onClick}
|
||||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
onKeyDown={onKeyDown}
|
||||||
background: c.canvasBackground,
|
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
style={{
|
||||||
}}
|
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||||
>
|
background: c.canvasBackground,
|
||||||
{/* Selected checkmark */}
|
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||||
{selected && (
|
}}
|
||||||
<span
|
|
||||||
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
|
||||||
style={{ background: c.nodeAccents.isp.border }}
|
|
||||||
>
|
|
||||||
<Check size={10} style={{ color: c.canvasBackground }} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mini canvas preview */}
|
|
||||||
<div
|
|
||||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
|
||||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
|
||||||
>
|
>
|
||||||
{/* Node accent dots */}
|
{/* Selected checkmark */}
|
||||||
<div className="flex gap-1 items-center flex-wrap">
|
{selected && (
|
||||||
{PREVIEW_TYPES.map((type) => (
|
<span
|
||||||
<span
|
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
||||||
key={type}
|
style={{ background: c.nodeAccents.isp.border }}
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
>
|
||||||
style={{ backgroundColor: c.nodeAccents[type].border }}
|
<Check size={10} style={{ color: c.canvasBackground }} />
|
||||||
/>
|
</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
{/* Edge line */}
|
{/* Mini canvas preview */}
|
||||||
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
|
||||||
{/* Wifi dashed line */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||||
height: 1,
|
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||||
width: '55%',
|
>
|
||||||
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
}}
|
{swatchColors.map((color, i) => (
|
||||||
/>
|
<span
|
||||||
</div>
|
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
|
||||||
<div
|
className="text-sm font-semibold leading-tight wrap-break-word"
|
||||||
className="text-xs font-semibold leading-tight"
|
style={{ color: c.nodeLabelColor }}
|
||||||
style={{ color: c.nodeLabelColor }}
|
>
|
||||||
>
|
{preset.label}
|
||||||
{preset.label}
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div
|
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
|
||||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
style={{ color: c.nodeSubtextColor }}
|
||||||
style={{ color: c.nodeSubtextColor }}
|
>
|
||||||
>
|
{preset.description}
|
||||||
{preset.description}
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</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) {
|
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
const { markUnsaved } = useCanvasStore()
|
const { markUnsaved } = useCanvasStore()
|
||||||
|
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||||
|
const [customStyleOpen, setCustomStyleOpen] = useState(false)
|
||||||
|
|
||||||
// Capture the theme that was active when the modal opened
|
// Capture the theme that was active when the modal opened
|
||||||
const [originalTheme] = useState<ThemeId>(activeTheme)
|
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||||
@@ -100,68 +137,96 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
|||||||
|
|
||||||
const handleSelect = (id: ThemeId) => {
|
const handleSelect = (id: ThemeId) => {
|
||||||
setSelected(id)
|
setSelected(id)
|
||||||
// Live-preview the selected theme on the canvas
|
|
||||||
setTheme(id)
|
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 = () => {
|
const handleApply = () => {
|
||||||
setTheme(selected)
|
setTheme(selected)
|
||||||
markUnsaved()
|
markUnsaved()
|
||||||
onClose()
|
onClose()
|
||||||
toast.info('Style applied — save your canvas to make it permanent', {
|
toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
// Revert to the original theme
|
|
||||||
setTheme(originalTheme)
|
setTheme(originalTheme)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
<>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||||
<DialogHeader>
|
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
|
||||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-3 py-1">
|
<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) => (
|
{THEME_ORDER.map((id, index) => (
|
||||||
<ThemeCard
|
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||||
key={id}
|
<ThemeCard
|
||||||
themeId={id}
|
themeId={id}
|
||||||
selected={selected === id}
|
selected={selected === id}
|
||||||
onClick={() => handleSelect(id)}
|
onClick={() => handleSelect(id)}
|
||||||
/>
|
onKeyDown={handleCardKeyDown(index)}
|
||||||
))}
|
buttonRef={(element) => { cardRefs.current[index] = element }}
|
||||||
</div>
|
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
style={
|
style={
|
||||||
selected !== 'default'
|
selected !== 'default'
|
||||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
>
|
>
|
||||||
Apply Style
|
Apply Style
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
|
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selecting Basic sends animated: "basic"', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Basic'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills animation from initial "basic" string', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||||
|
})
|
||||||
|
|
||||||
it('selecting None after Snake omits animated from payload', () => {
|
it('selecting None after Snake omits animated from payload', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
|
|||||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
expect(submitted.border_style).toBe('solid')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
|
|||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
|
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('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 ────────────────────────────────────────────────────────────
|
// ── Cancel ────────────────────────────────────────────────────────────
|
||||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalledOnce()
|
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 ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
@@ -121,7 +143,7 @@ describe('NodeModal', () => {
|
|||||||
it('submits updated hostname, IP and notes', () => {
|
it('submits updated hostname, IP and notes', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
|
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.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
@@ -130,6 +152,21 @@ describe('NodeModal', () => {
|
|||||||
expect(data.notes).toBe('rack A')
|
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', () => {
|
it('submits check_target', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
||||||
@@ -219,52 +256,103 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.queryByTitle('Router')).toBeNull()
|
expect(screen.queryByTitle('Router')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Container mode (proxmox only) ─────────────────────────────────────
|
// ── Container mode ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Container Mode toggle for proxmox type', () => {
|
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
|
||||||
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
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()
|
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides Container Mode for non-proxmox types', () => {
|
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: { ...BASE, type } })
|
||||||
expect(screen.queryByText('Container Mode')).toBeNull()
|
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles container_mode on click', () => {
|
it('toggles container_mode on click', () => {
|
||||||
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
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' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Show services toggle (modal-only) ───────────────────────────────
|
||||||
|
|
||||||
|
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('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 Proxmox (vm / lxc only) ───────────────────────────────────
|
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||||
|
|
||||||
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
|
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
||||||
|
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
||||||
|
|
||||||
|
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type: 'vm' },
|
initial: { ...BASE, type },
|
||||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
expect(screen.getByText('PVE-01')).toBeDefined()
|
expect(screen.getByText('Container 01')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
|
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('docker_container shows only docker_host parents', () => {
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type: 'lxc' },
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
parentContainerNodes: [
|
||||||
|
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
|
||||||
|
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
expect(screen.getByText('My Docker Host')).toBeDefined()
|
||||||
|
expect(screen.queryByText('My Proxmox')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides Parent Proxmox for server type', () => {
|
it('docker_container hides Parent Container when no docker_host is available', () => {
|
||||||
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
renderModal({
|
||||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
})
|
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
||||||
|
})
|
||||||
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
renderModal({ initial: { ...BASE, type: 'vm' } })
|
|
||||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Appearance ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
@@ -293,91 +381,6 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Hardware section ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
it('renders Hardware toggle button', () => {
|
|
||||||
renderModal()
|
|
||||||
expect(screen.getByText('Hardware')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hardware fields are hidden by default', () => {
|
|
||||||
renderModal()
|
|
||||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('expands hardware fields on toggle click', () => {
|
|
||||||
renderModal()
|
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
|
||||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
|
||||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
|
||||||
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
|
||||||
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-expands when initial has hardware data', () => {
|
|
||||||
renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
|
|
||||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pre-fills hardware fields from initial', () => {
|
|
||||||
renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
|
|
||||||
expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submits hardware fields when filled', () => {
|
|
||||||
const { onSubmit } = renderModal()
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
|
||||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
|
||||||
expect(data.cpu_model).toBe('Intel i7-12700K')
|
|
||||||
expect(data.cpu_count).toBe(12)
|
|
||||||
expect(data.ram_gb).toBe(64)
|
|
||||||
expect(data.disk_gb).toBe(2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides Hardware section for groupRect type', () => {
|
|
||||||
renderModal({ initial: { type: 'groupRect' } })
|
|
||||||
expect(screen.queryByText('Hardware')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('show_hardware toggle hidden when section is collapsed', () => {
|
|
||||||
renderModal()
|
|
||||||
expect(screen.queryByText('Show on node')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('show_hardware toggle appears when section is expanded', () => {
|
|
||||||
renderModal()
|
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
|
||||||
expect(screen.getByText('Show on node')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('show_hardware defaults to falsy', () => {
|
|
||||||
const { onSubmit } = renderModal()
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
|
||||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggling show_hardware sets it to true', () => {
|
|
||||||
const { onSubmit } = renderModal()
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
|
||||||
fireEvent.click(screen.getByText('Hardware'))
|
|
||||||
fireEvent.click(screen.getByRole('switch'))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
|
||||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pre-fills show_hardware from initial', () => {
|
|
||||||
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
|
||||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Bottom connection points ───────────────────────────────────────────
|
// ── Bottom connection points ───────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Bottom Connection Points for server type', () => {
|
it('shows Bottom Connection Points for server type', () => {
|
||||||
@@ -397,18 +400,37 @@ describe('NodeModal', () => {
|
|||||||
|
|
||||||
it('defaults bottom_handles to 1', () => {
|
it('defaults bottom_handles to 1', () => {
|
||||||
renderModal({ initial: BASE })
|
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', () => {
|
it('pre-fills bottom_handles from initial', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
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', () => {
|
it('submits updated bottom_handles', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
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' }))
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import { useState } from 'react'
|
import { createElement, useState } from 'react'
|
||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
import { primaryIp } from '@/utils/maskIp'
|
||||||
|
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
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 }
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
||||||
@@ -24,6 +30,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const [groupName, setGroupName] = useState('')
|
const [groupName, setGroupName] = useState('')
|
||||||
const [creatingGroup, setCreatingGroup] = useState(false)
|
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||||
|
|
||||||
|
// Properties state
|
||||||
|
const [addingProp, setAddingProp] = useState(false)
|
||||||
|
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||||||
|
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||||||
|
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||||
|
|
||||||
// Multi-select panel
|
// Multi-select panel
|
||||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||||
|
|
||||||
@@ -83,10 +95,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddService = () => {
|
const handleAddService = () => {
|
||||||
const port = parseInt(newSvc.port, 10)
|
const trimmedPort = newSvc.port.trim()
|
||||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
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()
|
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] })
|
updateNode(node.id, { services: [...services, svc] })
|
||||||
setNewSvc(EMPTY_FORM)
|
setNewSvc(EMPTY_FORM)
|
||||||
setAddingForNode(null)
|
setAddingForNode(null)
|
||||||
@@ -102,28 +122,85 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const handleStartEdit = (index: number) => {
|
const handleStartEdit = (index: number) => {
|
||||||
const svc = services[index]
|
const svc = services[index]
|
||||||
if (!svc) return
|
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 })
|
setEditingFor({ nodeId: node.id, index })
|
||||||
setAddingForNode(null)
|
setAddingForNode(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = () => {
|
||||||
if (editingIndex === null) return
|
if (editingIndex === null) return
|
||||||
const port = parseInt(editSvc.port, 10)
|
const trimmedPort = editSvc.port.trim()
|
||||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
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()
|
snapshotHistory()
|
||||||
|
const path = editSvc.path.trim()
|
||||||
const updated = services.map((svc, i) =>
|
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 })
|
updateNode(node.id, { services: updated })
|
||||||
setEditingFor(null)
|
setEditingFor(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Property handlers ---
|
||||||
|
const properties: NodeProperty[] = data.properties ?? []
|
||||||
|
|
||||||
|
const handleAddProp = () => {
|
||||||
|
if (!newProp.key.trim() || !newProp.value.trim()) return
|
||||||
|
snapshotHistory()
|
||||||
|
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
|
||||||
|
updateNode(node.id, { properties: [...properties, prop] })
|
||||||
|
setNewProp(EMPTY_PROP)
|
||||||
|
setAddingProp(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveProp = (index: number) => {
|
||||||
|
snapshotHistory()
|
||||||
|
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
|
||||||
|
if (editingPropIndex === index) setEditingPropIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePropVisible = (index: number) => {
|
||||||
|
snapshotHistory()
|
||||||
|
updateNode(node.id, {
|
||||||
|
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditProp = (index: number) => {
|
||||||
|
const p = properties[index]
|
||||||
|
if (!p) return
|
||||||
|
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
|
||||||
|
setEditingPropIndex(index)
|
||||||
|
setAddingProp(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEditProp = () => {
|
||||||
|
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
|
||||||
|
snapshotHistory()
|
||||||
|
updateNode(node.id, {
|
||||||
|
properties: properties.map((p, i) =>
|
||||||
|
i === editingPropIndex
|
||||||
|
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
|
||||||
|
: p
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setEditingPropIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||||
<button 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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,27 +223,73 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
{data.ip && (
|
||||||
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
||||||
|
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
||||||
|
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && <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>
|
</div>
|
||||||
|
|
||||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
{/* Properties section */}
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
<div className="flex items-center justify-between mb-2">
|
||||||
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||||
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
<button
|
||||||
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||||
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus size={10} /> Add
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{addingProp && (
|
||||||
|
<PropertyForm
|
||||||
|
form={newProp}
|
||||||
|
onChange={setNewProp}
|
||||||
|
onConfirm={handleAddProp}
|
||||||
|
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
|
||||||
|
confirmLabel="Add"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{properties.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{properties.map((prop, i) =>
|
||||||
|
editingPropIndex === i ? (
|
||||||
|
<PropertyForm
|
||||||
|
key={`edit-${i}`}
|
||||||
|
form={editProp}
|
||||||
|
onChange={setEditProp}
|
||||||
|
onConfirm={handleSaveEditProp}
|
||||||
|
onCancel={() => setEditingPropIndex(null)}
|
||||||
|
confirmLabel="Save"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PropertyBadge
|
||||||
|
key={`${prop.key}-${i}`}
|
||||||
|
prop={prop}
|
||||||
|
onToggleVisible={() => handleTogglePropVisible(i)}
|
||||||
|
onEdit={() => handleStartEditProp(i)}
|
||||||
|
onRemove={() => handleRemoveProp(i)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{properties.length === 0 && !addingProp && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
<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
|
<Plus size={10} /> Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +300,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
editingIndex === i ? (
|
editingIndex === i ? (
|
||||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
<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>
|
</div>
|
||||||
@@ -193,10 +316,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||||
<Button 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
|
<Edit size={14} /> Edit
|
||||||
</Button>
|
</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} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -365,11 +488,6 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
function formatStorage(gb: number): string {
|
|
||||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
|
||||||
return `${gb} GB`
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
@@ -382,23 +500,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
autoFocus?: boolean
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
<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()} />
|
<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">
|
<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">
|
<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="tcp">tcp</option>
|
||||||
<option value="udp">udp</option>
|
<option value="udp">udp</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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" 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>
|
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||||
@@ -407,6 +548,113 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Property components ---
|
||||||
|
|
||||||
|
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
||||||
|
form: PropForm
|
||||||
|
onChange: (f: PropForm) => void
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
confirmLabel: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||||
|
<Input
|
||||||
|
value={form.key}
|
||||||
|
onChange={(e) => onChange({ ...form, key: e.target.value })}
|
||||||
|
placeholder="Label (e.g. CPU Model)"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={form.value}
|
||||||
|
onChange={(e) => onChange({ ...form, value: e.target.value })}
|
||||||
|
placeholder="Value (e.g. i7-12700K)"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||||
|
/>
|
||||||
|
{/* Icon picker */}
|
||||||
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange({ ...form, icon: null })}
|
||||||
|
title="No icon"
|
||||||
|
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
|
||||||
|
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
{PROPERTY_ICON_NAMES.map((name) => {
|
||||||
|
const Icon = PROPERTY_ICONS[name]
|
||||||
|
const active = form.icon === name
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => onChange({ ...form, icon: name })}
|
||||||
|
title={name}
|
||||||
|
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
|
||||||
|
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{createElement(Icon, { size: 11 })}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Visible toggle */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.visible}
|
||||||
|
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
|
||||||
|
className="accent-[#00d4ff] w-3 h-3"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Show on node</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
||||||
|
prop: NodeProperty
|
||||||
|
onToggleVisible: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
|
return (
|
||||||
|
<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' }}>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||||||
|
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||||
|
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onToggleVisible}
|
||||||
|
title={prop.visible ? 'Hide on node' : 'Show on node'}
|
||||||
|
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
|
||||||
|
>
|
||||||
|
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||||
}
|
}
|
||||||
@@ -414,20 +662,85 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
const inner = (
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||||
<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">
|
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="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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
{svc.port != null && (
|
||||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
<span className="font-mono text-[#8b949e]">
|
||||||
<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>
|
{svc.port}/{svc.protocol}
|
||||||
<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>
|
</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>
|
||||||
</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 { 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 { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
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'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
|
type SidebarView = 'canvas' | 'history' | 'settings'
|
||||||
|
|
||||||
const ALL_VIEWS = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||||
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||||
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
|
|
||||||
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
|
|
||||||
]
|
]
|
||||||
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
|
|
||||||
|
|
||||||
interface ScanRun {
|
interface ScanRun {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
kind?: string
|
||||||
ranges: string[]
|
ranges: string[]
|
||||||
devices_found: number
|
devices_found: number
|
||||||
started_at: string
|
started_at: string
|
||||||
@@ -33,24 +37,32 @@ interface ScanRun {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
|
onAddText: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
|
||||||
forceView?: SidebarView
|
forceView?: SidebarView
|
||||||
highlightPendingId?: string
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [_collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
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
|
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||||
const collapsed = forceView ? false : _collapsed
|
if (forceView !== prevForceView) {
|
||||||
const activeView = forceView ?? _activeView
|
setPrevForceView(forceView)
|
||||||
|
if (forceView) {
|
||||||
|
setActiveView(forceView)
|
||||||
|
setCollapsed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
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 onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
@@ -78,23 +90,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
|
|
||||||
{/* Views */}
|
{/* Views */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2">
|
<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
|
<SidebarItem
|
||||||
key={id}
|
key={t.kind}
|
||||||
icon={Icon}
|
icon={t.icon}
|
||||||
label={label}
|
label={t.label}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === id}
|
onClick={() => onOpenPending(undefined, t.kind)}
|
||||||
onClick={() => setActiveView(id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={Clock}
|
||||||
|
label="Scan History"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'history'}
|
||||||
|
onClick={() => setActiveView('history')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* View content (only when expanded) */}
|
{/* View content (only when expanded) */}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<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 === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<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={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={hideIp ? EyeOff : Eye}
|
icon={hideIp ? EyeOff : Eye}
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
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')}
|
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={LogOut}
|
||||||
|
label="Logout"
|
||||||
|
collapsed={collapsed}
|
||||||
|
onClick={logout}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && <VersionBadge />}
|
{!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() {
|
function ScanHistoryPanel() {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
@@ -393,12 +206,19 @@ function ScanHistoryPanel() {
|
|||||||
const res = await scanApi.runs()
|
const res = await scanApi.runs()
|
||||||
const next: ScanRun[] = res.data
|
const next: ScanRun[] = res.data
|
||||||
|
|
||||||
// Toast when a run transitions from running → error
|
// Surface transitions and refresh dependent UI
|
||||||
for (const run of next) {
|
for (const run of next) {
|
||||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||||
if (prev?.status === 'running' && run.status === 'error') {
|
if (prev?.status === 'running' && run.status === 'error') {
|
||||||
toast.error(`Scan failed: ${run.error ?? 'unknown 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
|
prevRunsRef.current = next
|
||||||
setRuns(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="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>
|
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
{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>
|
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||||
{r.status === 'running' && (
|
{r.status === 'running' && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -486,7 +314,7 @@ function ScanHistoryPanel() {
|
|||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||||
)}
|
)}
|
||||||
{r.error && (
|
{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}
|
{r.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -499,6 +327,7 @@ function ScanHistoryPanel() {
|
|||||||
function SettingsPanel() {
|
function SettingsPanel() {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
@@ -506,6 +335,14 @@ function SettingsPanel() {
|
|||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
|
||||||
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
|
const next = { ...alignment, ...patch }
|
||||||
|
setAlignment(next)
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -530,7 +367,7 @@ function SettingsPanel() {
|
|||||||
min={10}
|
min={10}
|
||||||
max={3600}
|
max={3600}
|
||||||
value={interval}
|
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]"
|
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>
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
@@ -547,6 +384,41 @@ function SettingsPanel() {
|
|||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -567,7 +439,7 @@ function VersionBadge() {
|
|||||||
</a>
|
</a>
|
||||||
{hasUpdate && latest && (
|
{hasUpdate && latest && (
|
||||||
<a
|
<a
|
||||||
href={latest.url}
|
href={latest.url.startsWith('https://') ? latest.url : '#'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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 {
|
interface SidebarItemProps {
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
label: string
|
label: string
|
||||||
@@ -642,7 +465,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
|
|||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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
|
active
|
||||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
: accent
|
: accent
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
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}
|
onClick={onUndo}
|
||||||
disabled={past.length === 0}
|
disabled={past.length === 0}
|
||||||
title="Undo (Ctrl+Z)"
|
title="Undo (Ctrl+Z)"
|
||||||
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
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}
|
onClick={onRedo}
|
||||||
disabled={future.length === 0}
|
disabled={future.length === 0}
|
||||||
title="Redo (Ctrl+Y)"
|
title="Redo (Ctrl+Y)"
|
||||||
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Redo2 size={14} />
|
<Redo2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<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
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</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
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</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
|
<Upload size={14} /> Import
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
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
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
|
<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
|
<FileDown size={14} /> PNG
|
||||||
</Button>
|
</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
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</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} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
|
||||||
style={{
|
style={{
|
||||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
import type { ServiceInfo } from '@/types'
|
import type { ServiceInfo } from '@/types'
|
||||||
|
|
||||||
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
|
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
|
||||||
port,
|
...(port != null ? { port } : {}),
|
||||||
protocol,
|
protocol,
|
||||||
service_name,
|
service_name,
|
||||||
|
...(path ? { path } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getServiceUrl', () => {
|
describe('getServiceUrl', () => {
|
||||||
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
|
|||||||
it('uses host string directly (works with both IP and hostname)', () => {
|
it('uses host string directly (works with both IP and hostname)', () => {
|
||||||
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,58 +69,137 @@ describe('DetailPanel', () => {
|
|||||||
expect(container.firstChild).toBeNull()
|
expect(container.firstChild).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Hardware section', () => {
|
describe('Properties section', () => {
|
||||||
it('does not render hardware section when no hardware data', () => {
|
it('renders empty state when no properties', () => {
|
||||||
setupStore({ label: 'Server' })
|
setupStore({ properties: [] })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.queryByText('Hardware')).toBeNull()
|
expect(screen.getByText(/No properties/)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders hardware section when cpu_count is set', () => {
|
it('renders properties with key and value', () => {
|
||||||
setupStore({ cpu_count: 8 })
|
setupStore({
|
||||||
|
properties: [
|
||||||
|
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
|
||||||
|
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false },
|
||||||
|
],
|
||||||
|
})
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('Hardware')).toBeDefined()
|
expect(screen.getByText('CPU Model')).toBeDefined()
|
||||||
expect(screen.getByText('8')).toBeDefined()
|
// Value is rendered with a middle-dot prefix: "· 32 GB"
|
||||||
|
expect(screen.getByText(/32 GB/)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders cpu_model', () => {
|
it('shows Properties count when properties exist', () => {
|
||||||
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
setupStore({
|
||||||
|
properties: [
|
||||||
|
{ key: 'CPU Model', value: 'i7', icon: null, visible: true },
|
||||||
|
{ key: 'RAM', value: '16 GB', icon: null, visible: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
expect(screen.getByText('Properties (2)')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats ram_gb in GB', () => {
|
it('shows add form when Add is clicked', () => {
|
||||||
setupStore({ ram_gb: 32 })
|
setupStore({ properties: [] })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('32 GB')).toBeDefined()
|
// There are multiple "Add" buttons (services + properties) — find the one after "Properties"
|
||||||
|
const addButtons = screen.getAllByText('Add')
|
||||||
|
fireEvent.click(addButtons[0]) // first Add = properties (rendered above services)
|
||||||
|
expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats ram_gb >= 1024 as TB', () => {
|
it('calls updateNode with new property on Add confirm', () => {
|
||||||
setupStore({ ram_gb: 2048 })
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ properties: [] })],
|
||||||
|
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()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('2 TB')).toBeDefined()
|
const addButtons = screen.getAllByText('Add')
|
||||||
|
fireEvent.click(addButtons[0]) // first Add = properties
|
||||||
|
// Form is now open — fill key and value
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
|
||||||
|
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
|
||||||
|
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
|
||||||
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
|
const [, payload] = updateNode.mock.calls[0]
|
||||||
|
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats disk_gb in GB', () => {
|
it('calls updateNode with toggled visibility when eye button is clicked', () => {
|
||||||
setupStore({ disk_gb: 500 })
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })],
|
||||||
|
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()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('500 GB')).toBeDefined()
|
fireEvent.click(screen.getByTitle('Hide on node'))
|
||||||
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
|
const [, payload] = updateNode.mock.calls[0]
|
||||||
|
expect(payload.properties[0].visible).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats disk_gb >= 1024 as TB', () => {
|
it('calls updateNode without the property when remove button is clicked', () => {
|
||||||
setupStore({ disk_gb: 1536 })
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })],
|
||||||
|
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()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('1.5 TB')).toBeDefined()
|
fireEvent.click(screen.getByTitle('Remove property'))
|
||||||
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
|
const [, payload] = updateNode.mock.calls[0]
|
||||||
|
expect(payload.properties).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders all hardware fields together', () => {
|
it('does not submit add form when key is empty', () => {
|
||||||
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ properties: [] })],
|
||||||
|
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()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('Hardware')).toBeDefined()
|
const addButtons = screen.getAllByText('Add')
|
||||||
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
fireEvent.click(addButtons[0])
|
||||||
expect(screen.getByText('16')).toBeDefined()
|
// Only fill value, leave key empty
|
||||||
expect(screen.getByText('128 GB')).toBeDefined()
|
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
|
||||||
expect(screen.getByText('4 TB')).toBeDefined()
|
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||||
|
fireEvent.click(confirmButtons[confirmButtons.length - 1])
|
||||||
|
expect(updateNode).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,7 +268,9 @@ describe('DetailPanel', () => {
|
|||||||
it('shows add form when Add is clicked', () => {
|
it('shows add form when Add is clicked', () => {
|
||||||
setupStore({})
|
setupStore({})
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByText('Add'))
|
// Two "Add" buttons: first = properties, second = services
|
||||||
|
const addButtons = screen.getAllByText('Add')
|
||||||
|
fireEvent.click(addButtons[addButtons.length - 1])
|
||||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -198,20 +279,49 @@ describe('DetailPanel', () => {
|
|||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
nodes: [makeNode({})],
|
nodes: [makeNode({})],
|
||||||
selectedNodeId: 'n1',
|
selectedNodeId: 'n1',
|
||||||
|
selectedNodeIds: [],
|
||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode,
|
updateNode,
|
||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByText('Add'))
|
// Two "Add" header buttons: first = properties, second = services
|
||||||
|
const addHeaders = screen.getAllByText('Add')
|
||||||
|
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||||
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
|
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||||
const addButtons = screen.getAllByRole('button', { name: 'Add' })
|
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||||
fireEvent.click(addButtons[addButtons.length - 1])
|
|
||||||
expect(updateNode).toHaveBeenCalledOnce()
|
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', () => {
|
it('calls updateNode without the removed service when X is clicked', () => {
|
||||||
@@ -248,7 +358,7 @@ describe('DetailPanel', () => {
|
|||||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||||
|
|
||||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||||
setupStore({ services: [svc] })
|
setupStore({ services: [{ ...svc, path: '/admin' }] })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||||
const editBtn = screen.getByTitle('Edit service')
|
const editBtn = screen.getByTitle('Edit service')
|
||||||
@@ -257,6 +367,8 @@ describe('DetailPanel', () => {
|
|||||||
expect(nameInput.value).toBe('nginx')
|
expect(nameInput.value).toBe('nginx')
|
||||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||||
expect(portInput.value).toBe('80')
|
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', () => {
|
it('calls updateNode with updated values on Save', () => {
|
||||||
@@ -275,11 +387,13 @@ describe('DetailPanel', () => {
|
|||||||
|
|
||||||
const nameInput = screen.getByPlaceholderText('Service name')
|
const nameInput = screen.getByPlaceholderText('Service name')
|
||||||
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
expect(updateNode).toHaveBeenCalledOnce()
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
|
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].port).toBe(80)
|
||||||
|
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cancels edit without updating', () => {
|
it('cancels edit without updating', () => {
|
||||||
@@ -301,4 +415,99 @@ describe('DetailPanel', () => {
|
|||||||
expect(screen.getByText('nginx')).toBeDefined()
|
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.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
import { Sidebar } from '../Sidebar'
|
import { Sidebar } from '../Sidebar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
trigger: vi.fn().mockResolvedValue({}),
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
stop: vi.fn().mockResolvedValue({}),
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
@@ -37,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
|
|||||||
TooltipContent: () => null,
|
TooltipContent: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
|
||||||
PendingDeviceModal: () => null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
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 mockToggleHideIp = vi.fn()
|
||||||
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
@@ -64,12 +61,19 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
|||||||
} as 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 = {
|
const defaultProps = {
|
||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
|
onZigbeeImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onNodeApproved: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -77,6 +81,7 @@ const defaultProps = {
|
|||||||
describe('Sidebar', () => {
|
describe('Sidebar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStore()
|
mockStore()
|
||||||
|
mockAuth()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,26 +115,22 @@ describe('Sidebar', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total (excludes groupRect)
|
|
||||||
expect(screen.getByText('4')).toBeInTheDocument()
|
expect(screen.getByText('4')).toBeInTheDocument()
|
||||||
// Online
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument()
|
expect(screen.getByText('2')).toBeInTheDocument()
|
||||||
// Offline
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument()
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes groupRect nodes from stats', () => {
|
it('excludes groupRect nodes from stats', () => {
|
||||||
mockStore({
|
mockStore({
|
||||||
nodes: [
|
nodes: [
|
||||||
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
makeNode('n1', 'unknown'),
|
||||||
makeNode('zone', 'unknown', 'groupRect'),
|
makeNode('zone', 'unknown', 'groupRect'),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total row shows 1 (groupRect excluded), online/offline both 0
|
|
||||||
const totalRow = screen.getByText('Total').closest('div')!
|
const totalRow = screen.getByText('Total').closest('div')!
|
||||||
expect(totalRow).toHaveTextContent('1')
|
expect(totalRow).toHaveTextContent('1')
|
||||||
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
expect(screen.getAllByText('0')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Collapse ───────────────────────────────────────────────────────────────
|
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||||
@@ -206,7 +207,6 @@ describe('Sidebar', () => {
|
|||||||
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||||
mockStore({ hasUnsavedChanges: true })
|
mockStore({ hasUnsavedChanges: true })
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// The badge is a span sibling of the Save Canvas button icon
|
|
||||||
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||||
const badge = saveBtn.querySelector('span.rounded-full')
|
const badge = saveBtn.querySelector('span.rounded-full')
|
||||||
expect(badge).toBeInTheDocument()
|
expect(badge).toBeInTheDocument()
|
||||||
@@ -222,24 +222,24 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Scan action ────────────────────────────────────────────────────────────
|
// ── 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} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
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} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
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} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
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 () => {
|
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())
|
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 () => {
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Settings'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
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' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
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 * as React from "react"
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
@@ -63,13 +64,12 @@ function DialogContent({
|
|||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute top-2 right-2"
|
className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</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"
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
import { cn } from "@/lib/utils"
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
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} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
className,
|
className,
|
||||||
side = "top",
|
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "center",
|
|
||||||
alignOffset = 0,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: TooltipPrimitive.Popup.Props &
|
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||||
Pick<
|
|
||||||
TooltipPrimitive.Positioner.Props,
|
|
||||||
"align" | "alignOffset" | "side" | "sideOffset"
|
|
||||||
>) {
|
|
||||||
return (
|
return (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Positioner
|
<TooltipPrimitive.Content
|
||||||
align={align}
|
|
||||||
alignOffset={alignOffset}
|
|
||||||
side={side}
|
|
||||||
sideOffset={sideOffset}
|
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
|
{children}
|
||||||
data-slot="tooltip-content"
|
</TooltipPrimitive.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>
|
|
||||||
</TooltipPrimitive.Portal>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||