Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc3cb8364 | ||
|
|
9954fccf75 | ||
|
|
c263f839a8 | ||
|
|
e22152d2d3 | ||
|
|
57df3b9198 | ||
|
|
5617f0800f | ||
|
|
cd6402a680 | ||
|
|
4d2128c494 | ||
|
|
84360ffc50 | ||
|
|
56df462a8f | ||
|
|
06ccd354c5 | ||
|
|
c63bfd3859 | ||
|
|
2af6f728d7 | ||
|
|
b1186bd7dd | ||
|
|
a70fc248bf | ||
|
|
ff740f7466 | ||
|
|
3138cda378 | ||
|
|
0640704678 | ||
|
|
b129200929 | ||
|
|
e1f8850d1f | ||
|
|
9bf90cbbed | ||
|
|
7067bd76d4 | ||
|
|
8cafeee743 | ||
|
|
cfa296ef87 | ||
|
|
c6c7624084 | ||
|
|
e2da642554 | ||
|
|
a0d16e1454 | ||
|
|
33df37b691 | ||
|
|
f07f43686e | ||
|
|
3bde741fd2 | ||
|
|
e8ea6857bc | ||
|
|
42774fc138 | ||
|
|
8f273e0201 | ||
|
|
3c8e463dd9 | ||
|
|
c4017c7634 | ||
|
|
a88688170a | ||
|
|
aaccb50b00 | ||
|
|
34758c9850 | ||
|
|
4d2284dc37 | ||
|
|
9f974f2fcf | ||
|
|
37ad9c6933 | ||
|
|
99e7b9a343 | ||
|
|
f061db645b | ||
|
|
fd96b7d6ae | ||
|
|
0e95d99868 | ||
|
|
d9f3b4c18a | ||
|
|
80ebbca804 | ||
|
|
959cd4a3ae | ||
|
|
084a8e23e7 | ||
|
|
f5dd46a729 | ||
|
|
121873f41c | ||
|
|
16d6348cec | ||
|
|
28fc4d8a7f | ||
|
|
5726289b18 | ||
|
|
e85dab8cfc | ||
|
|
deccff15cf | ||
|
|
a1bf54c417 | ||
|
|
8c56921375 | ||
|
|
bf8551015c | ||
|
|
3f030959be |
+20
-2
@@ -1,4 +1,5 @@
|
||||
# Backend - server-side only (NEVER commit .env)
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
SQLITE_PATH=./data/homelab.db
|
||||
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
|
||||
@@ -7,13 +8,30 @@ SQLITE_PATH=./data/homelab.db
|
||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
# Auth — default credentials: admin / admin
|
||||
# Auth — local mode is the backward-compatible default.
|
||||
AUTH_MODE=local
|
||||
# Local credentials: admin / admin by default.
|
||||
# ⚠️ Change before exposing on a network.
|
||||
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
|
||||
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
||||
|
||||
# OpenID Connect (optional alternative to local auth).
|
||||
# Set AUTH_MODE=oidc and configure every value below. The callback must be
|
||||
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
|
||||
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
|
||||
# also requires SECRET_KEY to contain at least 32 bytes.
|
||||
# AUTH_MODE=oidc
|
||||
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=homelable
|
||||
# OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
# OIDC_COOKIE_SECURE=true
|
||||
# OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
|
||||
# Scanner — JSON array of CIDR ranges to scan
|
||||
SCANNER_RANGES=["192.168.1.0/24"]
|
||||
|
||||
|
||||
@@ -60,3 +60,20 @@ jobs:
|
||||
- run: ruff check .
|
||||
- run: mypy app/
|
||||
- run: pytest
|
||||
|
||||
# Also guards the NODE_TYPES / EDGE_TYPES enums in mcp/app/tools.py against
|
||||
# drift from frontend/src/types/index.ts (test_node_types_sync.py,
|
||||
# test_edge_types_sync.py) — those tests need the frontend sources, so this
|
||||
# job runs from a full checkout rather than the mcp Docker context.
|
||||
mcp:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest
|
||||
|
||||
@@ -22,6 +22,12 @@ jobs:
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
# Lob (a direct-mail API this project does not use) matches any
|
||||
# `test_`-prefixed identifier of ~35 chars and reports it verified,
|
||||
# because Lob test keys authenticate unconditionally. Every pytest
|
||||
# function named at that length trips it — the first was
|
||||
# `test_updates_an_existing_device_in_place`.
|
||||
extra_args: --exclude-detectors=Lob
|
||||
|
||||
dependency-audit:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,11 +37,12 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: NPM Audit
|
||||
run: cd frontend && npm audit --audit-level=high
|
||||
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
|
||||
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
|
||||
# bundled or served, so its advisories must not fail release CI.
|
||||
run: cd frontend && npm audit --omit=dev --audit-level=high
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Pip audit
|
||||
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
|
||||
# python-jose forces ecdsa as a dependency with no fixed version available.
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt
|
||||
|
||||
@@ -5,6 +5,68 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.2.0] - 2026-08-10
|
||||
|
||||
### Features
|
||||
|
||||
- Rack canvas: a third design type, next to network and electrical, with racks, mounted devices and port-to-port patching. (#315)
|
||||
- Declarative SVG faceplates picked from a visual catalog, including desktop NAS enclosures in 2, 4 and 5 bays. (#315)
|
||||
- Rack gear is filed in the Device Inventory, and a mount can be picked from the rackable entries. (#315)
|
||||
- Cables carry a type, colour, label and properties, printed on the canvas at the midpoint of the run. (#315)
|
||||
- Mounted devices follow the status check of the logical node they match, and links can be imported from the logical canvas. (#315)
|
||||
- Getting Started walkthrough covers the rack canvas. (#315)
|
||||
- Show the logical view of a mounted device, and link a mount to any Device Inventory entry. (#323)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Guard rack rows against cross-design saves and half-applied faceplates. (#315)
|
||||
- A rack height edit no longer orphans its mounts; height and colour commit on blur, and a blanked field no longer shrinks the rack. (#315)
|
||||
- Import links is idempotent, and stays usable after a run that matched nothing. (#315)
|
||||
- Stop the device modal losing cables, status and inventory rows. (#315)
|
||||
- Name rack inventory entries the way the Device Inventory does. (#315)
|
||||
- Stamp the linked node's last-seen with an offset. (#323)
|
||||
- Bump js-yaml to 4.3.1 for CVE-2026-59870. (#315)
|
||||
- CI: stop the secret scan reporting pytest names as Lob keys. (#315)
|
||||
|
||||
### Docs
|
||||
|
||||
- Document the rack canvas for users. (#315)
|
||||
|
||||
## [3.1.2] - 2026-08-01
|
||||
|
||||
### Features
|
||||
|
||||
- Generic OIDC authentication: backend sessions and browser login flow. Thanks @autogpt-elendir. (#306)
|
||||
- Brand icon picker for services, in a dedicated add/edit modal. (#313)
|
||||
- MCP: lightweight node lookup tools. Thanks @Nebesz. (#312)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sync the MCP node and edge type enums with the frontend, gated in CI against drift. Thanks @Nebesz. (#311)
|
||||
- Harden OIDC callback validation. (#306)
|
||||
- Use the official Homelable logo on the login page. (#306)
|
||||
- Clear the seven open dependabot alerts and refresh vulnerable frontend packages. (#317, #306)
|
||||
|
||||
### Docs
|
||||
|
||||
- Document OIDC setup and configuration. (#306)
|
||||
|
||||
## [3.1.1] - 2026-07-19
|
||||
|
||||
### Features
|
||||
|
||||
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
|
||||
- Getting Started walkthrough tour with first-run canvas detection. (#300)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Round-trip Show Port Numbers on YAML export/import. (#301)
|
||||
- Render error toasts on a red surface. (#300)
|
||||
|
||||
### Docs
|
||||
|
||||
- Rework README header with logo, badges, and nav links. (#300)
|
||||
|
||||
## [3.1.0] - 2026-07-18
|
||||
|
||||
### Features
|
||||
|
||||
+64
-26
@@ -16,19 +16,21 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
2. [Groups & Nesting](#2-groups--nesting)
|
||||
3. [Text Annotations](#3-text-annotations)
|
||||
4. [Multiple Canvases](#4-multiple-canvases)
|
||||
5. [Customize Style](#5-customize-style)
|
||||
6. [Floor Plan](#6-floor-plan-)
|
||||
7. [Network Scanner (IP import)](#7-network-scanner-ip-import-)
|
||||
8. [Zigbee Import](#8-zigbee-import-)
|
||||
9. [Z-Wave Import](#9-z-wave-import-)
|
||||
10. [Proxmox VE Import](#10-proxmox-ve-import-)
|
||||
11. [Device Inventory](#11-device-inventory-)
|
||||
12. [Live Status Monitoring](#12-live-status-monitoring-)
|
||||
13. [Export (PNG / SVG / YAML / Markdown)](#13-export)
|
||||
14. [Live View (read-only public canvas)](#14-live-view-)
|
||||
15. [Gethomepage Widget](#15-gethomepage-widget-)
|
||||
16. [MCP Server (AI integration)](#16-mcp-server-)
|
||||
17. [Settings & Shortcuts](#17-settings--shortcuts)
|
||||
5. [Rack Canvas](#5-rack-canvas)
|
||||
6. [Customize Style](#6-customize-style)
|
||||
7. [Floor Plan](#7-floor-plan-)
|
||||
8. [Network Scanner (IP import)](#8-network-scanner-ip-import-)
|
||||
9. [Zigbee Import](#9-zigbee-import-)
|
||||
10. [Z-Wave Import](#10-z-wave-import-)
|
||||
11. [Proxmox VE Import](#11-proxmox-ve-import-)
|
||||
12. [Device Inventory](#12-device-inventory-)
|
||||
13. [Live Status Monitoring](#13-live-status-monitoring-)
|
||||
14. [Export (PNG / SVG / YAML / Markdown)](#14-export)
|
||||
15. [Live View (read-only public canvas)](#15-live-view-)
|
||||
16. [Gethomepage Widget](#16-gethomepage-widget-)
|
||||
17. [MCP Server (AI integration)](#17-mcp-server-)
|
||||
18. [Settings & Shortcuts](#18-settings--shortcuts)
|
||||
19. [Authentication (Local / OpenID Connect)](#19-authentication-local--openid-connect-)
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +75,26 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
|
||||
---
|
||||
|
||||
## 5. Customize Style
|
||||
## 5. Rack Canvas
|
||||
|
||||
**What:** The physical side of the lab, as its own kind of canvas: racks, the gear mounted in them, and the patch cables between their ports. Where a diagram says what talks to what, a rack says what sits in which U, and which switch port the uplink lands on.
|
||||
|
||||
**Use:**
|
||||
- **Canvas switcher** → **New Canvas** → Kind **Rack**. A canvas cannot change kind afterwards.
|
||||
- **Add Rack** in the header drops a rack; double-click its frame for U height, 19"/10" width, numbering direction, colours and the enclosed/open look.
|
||||
- **+ Device** in the sidebar mounts something: an entry from your **Device Inventory** 🔒, a **new device** (which joins the inventory tagged *Rack devices*), or a rack-only **accessory** (blank, shelf, cable manager). Double-click a plate to edit it, and **Unmount** to take it out — the device itself stays in the inventory.
|
||||
- Gear occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U. A drop snaps to the nearest free slot, an impossible one previews red, and growing a device relocates it to the nearest slot that fits.
|
||||
- The **Faceplate** field opens a visual catalog — servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks — drawn as vector artwork that scales with the rack and follows your theme.
|
||||
- **Patch**: drag from one port to another (or click both in turn) to cable them, across racks if you need to. Click a cable to select it, then **Delete** or **Unplug**. A header select controls whether cables show on hover, always, or not at all.
|
||||
- **Status**: pin a mount's colour, or set **Check device** 🔒 so it follows the status check already configured on the matching diagram node.
|
||||
- **Import links** 🔒 derives patches from the physical links already drawn on your diagrams. Run it again after racking more gear — pairs already cabled are left alone.
|
||||
- Saving is explicit — **Save Rack** — like any other canvas.
|
||||
|
||||
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
|
||||
|
||||
---
|
||||
|
||||
## 6. Customize Style
|
||||
|
||||
**What:** Repaint the whole thing with a preset theme, or roll your own node and edge colors.
|
||||
|
||||
@@ -84,7 +105,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
|
||||
---
|
||||
|
||||
## 6. Floor Plan 🔒
|
||||
## 7. Floor Plan 🔒
|
||||
|
||||
**What:** Put a background image (a house plan, an office layout, a rack diagram) behind a canvas and lay your devices out on top of it.
|
||||
|
||||
@@ -95,7 +116,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
|
||||
---
|
||||
|
||||
## 7. Network Scanner (IP import) 🔒
|
||||
## 8. Network Scanner (IP import) 🔒
|
||||
|
||||
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
|
||||
|
||||
@@ -115,7 +136,7 @@ SCANNER_HTTP_VERIFY_TLS=false
|
||||
|
||||
---
|
||||
|
||||
## 8. Zigbee Import 🔒
|
||||
## 9. Zigbee Import 🔒
|
||||
|
||||
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
|
||||
|
||||
@@ -128,7 +149,7 @@ Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. Th
|
||||
|
||||
---
|
||||
|
||||
## 9. Z-Wave Import 🔒
|
||||
## 10. Z-Wave Import 🔒
|
||||
|
||||
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
|
||||
|
||||
@@ -141,7 +162,7 @@ Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy c
|
||||
|
||||
---
|
||||
|
||||
## 10. Proxmox VE Import 🔒
|
||||
## 11. Proxmox VE Import 🔒
|
||||
|
||||
**What:** Pull your **Proxmox VE** inventory (hosts, VMs, LXC) in over the Proxmox REST API — typed, named nodes with run state and hardware specs. Optional scheduled **auto-sync**; guest IPs already found by a scan are merged, not duplicated.
|
||||
|
||||
@@ -155,7 +176,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 11. Device Inventory 🔒
|
||||
## 12. Device Inventory 🔒
|
||||
|
||||
**What:** The holding pen for everything found by a scan or import that isn't on the canvas yet, plus a separate **Hidden Devices** list.
|
||||
|
||||
@@ -166,7 +187,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 12. Live Status Monitoring 🔒
|
||||
## 13. Live Status Monitoring 🔒
|
||||
|
||||
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
|
||||
|
||||
@@ -187,7 +208,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 13. Export
|
||||
## 14. Export
|
||||
|
||||
**What:** Get your canvas out as a picture or as structured data.
|
||||
|
||||
@@ -199,7 +220,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 14. Live View 🔒
|
||||
## 15. Live View 🔒
|
||||
|
||||
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
|
||||
|
||||
@@ -211,7 +232,7 @@ Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab
|
||||
|
||||
---
|
||||
|
||||
## 15. Gethomepage Widget 🔒
|
||||
## 16. Gethomepage Widget 🔒
|
||||
|
||||
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
|
||||
|
||||
@@ -223,7 +244,7 @@ Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-st
|
||||
|
||||
---
|
||||
|
||||
## 16. MCP Server 🔒
|
||||
## 17. MCP Server 🔒
|
||||
|
||||
**What:** A [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude Code, Claude Desktop, Open WebUI…) can read and change your topology. Optional, runs as its own service.
|
||||
|
||||
@@ -241,7 +262,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
|
||||
|
||||
---
|
||||
|
||||
## 17. Settings & Shortcuts
|
||||
## 18. Settings & Shortcuts
|
||||
|
||||
**What:** App config and keyboard shortcuts.
|
||||
|
||||
@@ -252,4 +273,21 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
|
||||
|
||||
---
|
||||
|
||||
## 19. Authentication (Local / OpenID Connect) 🔒
|
||||
|
||||
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
|
||||
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
|
||||
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
|
||||
|
||||
**Use (OIDC):**
|
||||
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
|
||||
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
|
||||
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
|
||||
|
||||
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
|
||||
|
||||
> OIDC is Full-mode only — the no-backend standalone build has no login.
|
||||
|
||||
---
|
||||
|
||||
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
|
||||
|
||||
+26
-2
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
|
||||
>
|
||||
Generate a new hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
|
||||
```
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
|
||||
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
|
||||
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
|
||||
```
|
||||
|
||||
## Quick Start — Frontend only
|
||||
@@ -69,6 +69,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
|
||||
All configuration is done via `.env` (copied from `.env.example`):
|
||||
|
||||
```env
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
|
||||
# Auth — default: admin / admin
|
||||
@@ -82,6 +83,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
```
|
||||
|
||||
### OpenID Connect (optional)
|
||||
|
||||
OIDC is an exclusive alternative to the local password. Existing installs stay
|
||||
in `AUTH_MODE=local` unless explicitly changed.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
```
|
||||
|
||||
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
|
||||
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
|
||||
backend uses Authorization Code with PKCE and keeps provider tokens out of the
|
||||
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
|
||||
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
|
||||
|
||||
All settings are also editable in-app via the **Scan Network** button.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
# Homelable
|
||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&label=build" alt="Build status" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#screenshots">Screenshots</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#installation">Installation</a> ·
|
||||
<a href="#rack-canvas">Rack Canvas</a> ·
|
||||
<a href="#network-scanner">Network Scanner</a> ·
|
||||
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
|
||||
<a href="#proxmox-ve-import">Proxmox</a> ·
|
||||
<a href="#live-view-read-only-public-canvas">Live View</a> ·
|
||||
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
|
||||
</p>
|
||||
|
||||
## About
|
||||
|
||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
||||
|
||||
@@ -10,7 +41,6 @@ If you just like the design, you can only run the frontend and export your desig
|
||||
|
||||
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||
|
||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="250" height="55"/></a>
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -27,7 +57,7 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
|
||||
|
||||
## Features
|
||||
|
||||
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
|
||||
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, **rack canvases** with port-to-port patching, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
|
||||
|
||||
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
|
||||
|
||||
@@ -39,6 +69,24 @@ Docker, Proxmox LXC, build from source, configuration, and development setup are
|
||||
|
||||
---
|
||||
|
||||
## Rack Canvas
|
||||
|
||||
Next to the network diagram, Homelable draws the **physical** side of your lab: racks, the gear mounted in them, and the patch cables between their ports. It is a canvas kind of its own — create it from the canvas switcher, **New Canvas → Kind → Rack**.
|
||||
|
||||
### Usage
|
||||
|
||||
1. **Add Rack** in the header drops a rack; double-click its frame to set U height, 19"/10" width, numbering direction and colours
|
||||
2. **+ Device** in the sidebar mounts something — an entry from your Device Inventory, a new device (which joins the inventory), or an accessory (blank, shelf, cable manager)
|
||||
3. Pick a **faceplate** from the visual catalog: servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks
|
||||
4. Click **Patch**, then drag from one port to another to cable them — across racks if you need to. Click a cable and press Delete to unplug it
|
||||
5. **Save Rack** when you're happy; nothing is written behind your back
|
||||
|
||||
Gear sits in a U range and part of a 12-column width grid, so half- and third-width machines share a U; a drop snaps to the nearest free slot. A mount can follow the status check of its matching diagram node, and **Import links** derives patches from the links already drawn on your diagrams.
|
||||
|
||||
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
|
||||
|
||||
---
|
||||
|
||||
## Network Scanner
|
||||
|
||||
The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates a **Pending Devices** queue. From the sidebar you can then approve (adds a node to the canvas), hide, or ignore each discovered device.
|
||||
|
||||
+45
-10
@@ -1,31 +1,66 @@
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
from app.core.security import decode_token, get_oidc_session
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
x_mcp_service_key: str | None = Header(default=None),
|
||||
) -> str:
|
||||
) -> AuthContext:
|
||||
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||
if x_mcp_service_key is not None:
|
||||
if not settings.mcp_service_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||
return "__mcp_service__"
|
||||
return AuthContext(
|
||||
subject="__mcp_service__",
|
||||
display_name="MCP service",
|
||||
auth_method="mcp",
|
||||
)
|
||||
|
||||
# 2. Standard JWT bearer token
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return username
|
||||
if credentials is not None:
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return AuthContext(
|
||||
subject=username,
|
||||
display_name=username,
|
||||
auth_method="local",
|
||||
)
|
||||
|
||||
# 3. OIDC session cookie (only active in exclusive OIDC mode)
|
||||
if settings.auth_mode == "oidc":
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is not None:
|
||||
return AuthContext(
|
||||
subject=oidc_session.subject,
|
||||
display_name=oidc_session.display_name,
|
||||
auth_method="oidc",
|
||||
issuer=oidc_session.issuer,
|
||||
csrf_token=oidc_session.csrf_token,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
|
||||
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
|
||||
return context.subject
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.api.deps import AuthContext, get_auth_context
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.core.security import (
|
||||
clear_oidc_session_cookie,
|
||||
create_access_token,
|
||||
create_oidc_session_token,
|
||||
get_oidc_client,
|
||||
set_oidc_session_cookie,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
mode: str
|
||||
oidc_login_url: str | None = None
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def _require_oidc_mode() -> None:
|
||||
if settings.auth_mode != "oidc":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
|
||||
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config() -> AuthConfigResponse:
|
||||
return AuthConfigResponse(
|
||||
mode=settings.auth_mode,
|
||||
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest) -> TokenResponse:
|
||||
if settings.auth_mode != "local":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
|
||||
# Always run both checks to prevent timing-based username enumeration.
|
||||
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
||||
username_ok = hmac.compare_digest(body.username, settings.auth_username)
|
||||
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(body.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except (OAuthError, JoseError) as exc:
|
||||
logger.warning("OIDC callback rejected: %s", exc.error)
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not isinstance(userinfo, Mapping):
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
|
||||
try:
|
||||
session_token = create_oidc_session_token(userinfo)
|
||||
except ValueError:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
request.session.clear()
|
||||
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
set_oidc_session_cookie(response, session_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
|
||||
return CurrentUserResponse(
|
||||
subject=context.subject,
|
||||
display_name=context.display_name,
|
||||
auth_method=context.auth_method,
|
||||
issuer=context.issuer,
|
||||
csrf_token=context.csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
clear_oidc_session_cookie(response)
|
||||
return response
|
||||
|
||||
@@ -37,6 +37,9 @@ async def load_canvas(
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
# A CanvasState row exists only after a save (or explicit design create),
|
||||
# so its presence marks an intentional canvas vs. a never-touched one.
|
||||
initialized=state is not None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node, Rack, RackCable, RackDevice
|
||||
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
||||
|
||||
router = APIRouter()
|
||||
@@ -118,6 +118,56 @@ async def copy_design(
|
||||
)
|
||||
)
|
||||
|
||||
# Copy the rack canvas, if the source has one. Racks, mounted gear and patches
|
||||
# all get new ids; the inventory / node links are carried over untouched, since
|
||||
# a copy documents the same hardware.
|
||||
src_racks = (await db.execute(select(Rack).where(Rack.design_id == source_id))).scalars().all()
|
||||
rack_id_map = {r.id: str(uuid.uuid4()) for r in src_racks}
|
||||
rack_skip = {"id", "design_id", "created_at", "updated_at"}
|
||||
for r in src_racks:
|
||||
cols = {c.name: getattr(r, c.name) for c in Rack.__table__.columns if c.name not in rack_skip}
|
||||
db.add(Rack(id=rack_id_map[r.id], design_id=new_design.id, **cols))
|
||||
|
||||
src_rack_devices = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == source_id))
|
||||
).scalars().all()
|
||||
rack_device_id_map = {d.id: str(uuid.uuid4()) for d in src_rack_devices}
|
||||
rack_device_skip = {"id", "design_id", "rack_id", "created_at", "updated_at"}
|
||||
for d in src_rack_devices:
|
||||
if d.rack_id not in rack_id_map:
|
||||
continue
|
||||
cols = {
|
||||
c.name: getattr(d, c.name)
|
||||
for c in RackDevice.__table__.columns
|
||||
if c.name not in rack_device_skip
|
||||
}
|
||||
db.add(
|
||||
RackDevice(
|
||||
id=rack_device_id_map[d.id],
|
||||
design_id=new_design.id,
|
||||
rack_id=rack_id_map[d.rack_id],
|
||||
**cols,
|
||||
)
|
||||
)
|
||||
|
||||
src_cables = (await db.execute(select(RackCable).where(RackCable.design_id == source_id))).scalars().all()
|
||||
cable_skip = {"id", "design_id", "from_device_id", "to_device_id", "created_at"}
|
||||
for c_row in src_cables:
|
||||
if c_row.from_device_id not in rack_device_id_map or c_row.to_device_id not in rack_device_id_map:
|
||||
continue
|
||||
cols = {
|
||||
c.name: getattr(c_row, c.name) for c in RackCable.__table__.columns if c.name not in cable_skip
|
||||
}
|
||||
db.add(
|
||||
RackCable(
|
||||
id=str(uuid.uuid4()),
|
||||
design_id=new_design.id,
|
||||
from_device_id=rack_device_id_map[c_row.from_device_id],
|
||||
to_device_id=rack_device_id_map[c_row.to_device_id],
|
||||
**cols,
|
||||
)
|
||||
)
|
||||
|
||||
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
|
||||
src_state = await db.get(CanvasState, source_id)
|
||||
db.add(
|
||||
@@ -165,10 +215,20 @@ async def delete_design(
|
||||
count = (await db.execute(select(Design))).scalars().all()
|
||||
if len(count) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the only design")
|
||||
# Delete associated canvas state, edges, nodes
|
||||
# Delete associated canvas state, rack rows, edges, nodes. SQLite does not
|
||||
# always enforce the ON DELETE CASCADE, so unwind by hand — cables before
|
||||
# devices before racks.
|
||||
cs = await db.get(CanvasState, design_id)
|
||||
if cs:
|
||||
await db.delete(cs)
|
||||
for cable in (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all():
|
||||
await db.delete(cable)
|
||||
for rack_device in (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
|
||||
).scalars().all():
|
||||
await db.delete(rack_device)
|
||||
for rack in (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all():
|
||||
await db.delete(rack)
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for e in edges:
|
||||
await db.delete(e)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -55,9 +55,15 @@ async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[
|
||||
|
||||
|
||||
@router.get("", response_model=list[NodeResponse])
|
||||
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
|
||||
result = await db.execute(select(Node))
|
||||
return list(result.scalars().all())
|
||||
async def list_nodes(
|
||||
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[Node]:
|
||||
query = select(Node)
|
||||
if label:
|
||||
query = query.where(Node.label.ilike(f"%{label}%"))
|
||||
return list((await db.execute(query)).scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.proxmox import (
|
||||
ProxmoxConfig,
|
||||
ProxmoxConnectionRequest,
|
||||
@@ -245,7 +245,7 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ProxmoxImportPendingResponse:
|
||||
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
|
||||
"""Upsert Proxmox nodes/edges into device_inventory + device_inventory_links.
|
||||
|
||||
Two-tier identity (order matters):
|
||||
1. Match an existing canvas Node or pending row by **IP** (merge into a
|
||||
@@ -328,14 +328,14 @@ async def _persist_pending_import(
|
||||
|
||||
async def _find_pending(
|
||||
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
|
||||
) -> PendingDevice | None:
|
||||
filters = [PendingDevice.ieee_address == ieee]
|
||||
) -> InventoryDevice | None:
|
||||
filters = [InventoryDevice.ieee_address == ieee]
|
||||
if ip:
|
||||
filters.append(PendingDevice.ip == ip)
|
||||
filters.append(InventoryDevice.ip == ip)
|
||||
if mac:
|
||||
filters.append(PendingDevice.mac == mac)
|
||||
filters.append(InventoryDevice.mac == mac)
|
||||
return (
|
||||
await db.execute(select(PendingDevice).where(or_(*filters)))
|
||||
await db.execute(select(InventoryDevice).where(or_(*filters)))
|
||||
).scalars().first()
|
||||
|
||||
|
||||
@@ -346,8 +346,8 @@ def _new_pending(
|
||||
n: dict[str, Any],
|
||||
props: list[dict[str, Any]],
|
||||
status: str,
|
||||
) -> PendingDevice:
|
||||
return PendingDevice(
|
||||
) -> InventoryDevice:
|
||||
return InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
@@ -363,7 +363,7 @@ def _new_pending(
|
||||
)
|
||||
|
||||
|
||||
def _sources_after_merge(row: PendingDevice) -> list[str]:
|
||||
def _sources_after_merge(row: InventoryDevice) -> list[str]:
|
||||
"""Discovery sources for an inventory row after a Proxmox import merges in.
|
||||
|
||||
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
|
||||
@@ -381,7 +381,7 @@ def _sources_after_merge(row: PendingDevice) -> list[str]:
|
||||
|
||||
|
||||
def _refresh_pending(
|
||||
pending: PendingDevice,
|
||||
pending: InventoryDevice,
|
||||
ieee: str,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
@@ -441,8 +441,8 @@ async def _replace_links(
|
||||
(``proxmox_cluster`` → 'cluster' edges).
|
||||
"""
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(
|
||||
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
||||
sa_delete(InventoryDeviceLink).where(
|
||||
InventoryDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
||||
)
|
||||
)
|
||||
recorded = 0
|
||||
@@ -453,7 +453,7 @@ async def _replace_links(
|
||||
if not src or not tgt or (src, tgt) in seen:
|
||||
return
|
||||
seen.add((src, tgt))
|
||||
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
||||
db.add(InventoryDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
||||
recorded += 1
|
||||
|
||||
for e in edges_raw:
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, InventoryDevice, Node, Rack, RackCable, RackDevice
|
||||
from app.schemas.racks import (
|
||||
RackCableResponse,
|
||||
RackDeviceResponse,
|
||||
RackInventoryItem,
|
||||
RackInventoryResponse,
|
||||
RackResponse,
|
||||
RackSaveRequest,
|
||||
RackServiceInfo,
|
||||
RackStateResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Device kinds that cannot be racked. An exclusion list rather than an allow list:
|
||||
# new hardware types should show up in the tray by default, and only the obviously
|
||||
# virtual / mesh / annotation kinds need to opt out.
|
||||
_UNRACKABLE_TYPES = {
|
||||
"vm",
|
||||
"lxc",
|
||||
"docker_container",
|
||||
"mobile",
|
||||
"laptop",
|
||||
"light",
|
||||
"socket",
|
||||
"load",
|
||||
"groupRect",
|
||||
"group",
|
||||
"text",
|
||||
"zigbee_coordinator",
|
||||
"zigbee_router",
|
||||
"zigbee_enddevice",
|
||||
"zwave_coordinator",
|
||||
"zwave_router",
|
||||
"zwave_enddevice",
|
||||
}
|
||||
|
||||
|
||||
_COMMON_PORTS = {22, 80, 443}
|
||||
|
||||
|
||||
def _service_name(device: InventoryDevice) -> str | None:
|
||||
"""Name a device after the app it runs, the way the Device Inventory does.
|
||||
|
||||
Ports everyone exposes (ssh, http, https) say nothing, and the generic web
|
||||
category loses to a real match, so "jellyfin" wins over "http".
|
||||
"""
|
||||
candidates = [
|
||||
s
|
||||
for s in (device.services or [])
|
||||
if isinstance(s, dict)
|
||||
and s.get("category")
|
||||
and s.get("service_name")
|
||||
# A service with no port is not a fingerprint the inventory names a
|
||||
# device after either — and `None not in _COMMON_PORTS` is True.
|
||||
and s.get("port") is not None
|
||||
and s.get("port") not in _COMMON_PORTS
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
named = next((s for s in candidates if str(s["category"]).lower() != "web"), candidates[0])
|
||||
name = named.get("service_name")
|
||||
return str(name) if name else None
|
||||
|
||||
|
||||
def _device_label(device: InventoryDevice) -> str:
|
||||
"""Inventory naming, mirrored: friendly name, host, app, IP, IEEE, id.
|
||||
|
||||
Must stay in step with `deviceLabel` in `InventoryDevicesModal.tsx` — a device
|
||||
the user picked out of the inventory by one name should not turn up in the
|
||||
rack picker under another.
|
||||
"""
|
||||
return (
|
||||
device.friendly_name
|
||||
or device.hostname
|
||||
or _service_name(device)
|
||||
or device.ip
|
||||
or device.ieee_address
|
||||
or device.id
|
||||
)
|
||||
|
||||
|
||||
# A mount prints its services as a short list, not a port scan dump.
|
||||
_MAX_SERVICES = 12
|
||||
|
||||
|
||||
def _services(raw: Any) -> list[RackServiceInfo]:
|
||||
"""Fingerprinted services, deduped and trimmed for the rack's info panel."""
|
||||
out: list[RackServiceInfo] = []
|
||||
seen: set[tuple[int | None, str | None]] = set()
|
||||
for entry in raw or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_port = entry.get("port")
|
||||
port = raw_port if isinstance(raw_port, int) else None
|
||||
raw_name = entry.get("service_name") or entry.get("name")
|
||||
name = str(raw_name) if raw_name else None
|
||||
if port is None and name is None:
|
||||
continue
|
||||
if (port, name) in seen:
|
||||
continue
|
||||
seen.add((port, name))
|
||||
out.append(RackServiceInfo(port=port, name=name))
|
||||
if len(out) == _MAX_SERVICES:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split a device ``ip`` field into individual addresses.
|
||||
|
||||
Mirrors the scan routes: the canvas stores multiple addresses in one
|
||||
comma-separated string, so matching must compare per-address.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
_Row = TypeVar("_Row", Rack, RackDevice, RackCable)
|
||||
|
||||
|
||||
async def _owned(
|
||||
db: AsyncSession, model: type[_Row], row_id: str, design_id: str
|
||||
) -> _Row | None:
|
||||
"""Fetch a rack row by id, refusing one that belongs to another design.
|
||||
|
||||
The upsert below sets `design_id` from the payload, so without this a save
|
||||
carrying an id from design A would quietly move A's rack — devices and
|
||||
cables included — into design B, and A would lose it with no error. Ids come
|
||||
from the client, so a copied design or a stale tab is enough to collide.
|
||||
"""
|
||||
row = await db.get(model, row_id)
|
||||
if row is not None and row.design_id != design_id:
|
||||
raise HTTPException(409, f"{model.__name__} {row_id} belongs to another design")
|
||||
return row
|
||||
|
||||
|
||||
async def _require_design(db: AsyncSession, design_id: str) -> Design:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
return design
|
||||
|
||||
|
||||
@router.get("", response_model=RackStateResponse)
|
||||
async def load_racks(
|
||||
design_id: str = Query(..., description="Rack design to load"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> RackStateResponse:
|
||||
await _require_design(db, design_id)
|
||||
|
||||
racks = (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all()
|
||||
devices = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
|
||||
).scalars().all()
|
||||
cables = (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
|
||||
return RackStateResponse(
|
||||
racks=[RackResponse.model_validate(r) for r in racks],
|
||||
devices=[RackDeviceResponse.model_validate(d) for d in devices],
|
||||
cables=[RackCableResponse.model_validate(c) for c in cables],
|
||||
viewport=state.viewport if state else {"x": 0, "y": 0, "zoom": 1},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def save_racks(
|
||||
body: RackSaveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, bool]:
|
||||
"""Persist the full rack state of one design: upsert what is sent, prune the rest."""
|
||||
await _require_design(db, body.design_id)
|
||||
|
||||
rack_ids = {r.id for r in body.racks}
|
||||
device_ids = {d.id for d in body.devices}
|
||||
|
||||
# A device must land in a rack that is part of the same payload, and a cable
|
||||
# must join two devices in it — otherwise the prune below would orphan them.
|
||||
for device in body.devices:
|
||||
if device.rack_id not in rack_ids:
|
||||
raise HTTPException(400, f"Device {device.id} references unknown rack {device.rack_id}")
|
||||
for cable in body.cables:
|
||||
if cable.from_device_id not in device_ids or cable.to_device_id not in device_ids:
|
||||
raise HTTPException(400, f"Cable {cable.id} references a device outside this payload")
|
||||
|
||||
# Prune first (cables, then devices, then racks) so freed rows never collide
|
||||
# with an upsert that reuses their geometry.
|
||||
for existing_cable in (
|
||||
await db.execute(select(RackCable).where(RackCable.design_id == body.design_id))
|
||||
).scalars().all():
|
||||
if existing_cable.id not in {c.id for c in body.cables}:
|
||||
await db.delete(existing_cable)
|
||||
for existing_device in (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == body.design_id))
|
||||
).scalars().all():
|
||||
if existing_device.id not in device_ids:
|
||||
await db.delete(existing_device)
|
||||
for existing_rack in (
|
||||
await db.execute(select(Rack).where(Rack.design_id == body.design_id))
|
||||
).scalars().all():
|
||||
if existing_rack.id not in rack_ids:
|
||||
await db.delete(existing_rack)
|
||||
await db.flush()
|
||||
|
||||
for rack_data in body.racks:
|
||||
payload: dict[str, Any] = rack_data.model_dump()
|
||||
payload["design_id"] = body.design_id
|
||||
db_rack = await _owned(db, Rack, rack_data.id, body.design_id)
|
||||
if db_rack:
|
||||
for field, value in payload.items():
|
||||
setattr(db_rack, field, value)
|
||||
else:
|
||||
db.add(Rack(**payload))
|
||||
await db.flush() # racks must exist before devices point at them
|
||||
|
||||
for device_data in body.devices:
|
||||
payload = device_data.model_dump()
|
||||
payload["design_id"] = body.design_id
|
||||
db_device = await _owned(db, RackDevice, device_data.id, body.design_id)
|
||||
if db_device:
|
||||
for field, value in payload.items():
|
||||
setattr(db_device, field, value)
|
||||
else:
|
||||
db.add(RackDevice(**payload))
|
||||
await db.flush() # devices must exist before cables point at them
|
||||
|
||||
for cable_data in body.cables:
|
||||
payload = cable_data.model_dump()
|
||||
payload["design_id"] = body.design_id
|
||||
db_cable = await _owned(db, RackCable, cable_data.id, body.design_id)
|
||||
if db_cable:
|
||||
for field, value in payload.items():
|
||||
setattr(db_cable, field, value)
|
||||
else:
|
||||
db.add(RackCable(**payload))
|
||||
|
||||
# Viewport lives on the design's shared CanvasState row, like the logical canvas.
|
||||
state = await db.get(CanvasState, body.design_id)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(design_id=body.design_id, viewport=body.viewport))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
@router.get("/inventory", response_model=RackInventoryResponse)
|
||||
async def rack_inventory(
|
||||
design_id: str = Query(..., description="Rack design the tray is for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> RackInventoryResponse:
|
||||
"""Device Inventory entries that can be racked, flagged with what is already mounted.
|
||||
|
||||
Reads `device_inventory` — the inventory survives approval and node deletion,
|
||||
so unracking or deleting a canvas node never removes the entry here.
|
||||
"""
|
||||
await _require_design(db, design_id)
|
||||
|
||||
devices = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
).scalars().all()
|
||||
mounts = (
|
||||
await db.execute(
|
||||
select(RackDevice.device_id, RackDevice.node_id).where(
|
||||
RackDevice.design_id == design_id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
mounted = {device_id for device_id, _ in mounts if device_id}
|
||||
# A mount can name its canvas node itself, when the user linked one by hand
|
||||
# in the rack. That beats the IEEE/IP guess below: the guess is what failed
|
||||
# to find anything in the first place.
|
||||
pinned = {device_id: node_id for device_id, node_id in mounts if device_id and node_id}
|
||||
|
||||
# Correlate against canvas nodes for live status: IEEE first (stable), then IP.
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
by_id = {n.id: n for n in nodes}
|
||||
by_ieee = {n.ieee_address: n for n in nodes if n.ieee_address}
|
||||
by_ip: dict[str, Node] = {}
|
||||
for node in nodes:
|
||||
for token in _ip_tokens(node.ip):
|
||||
by_ip.setdefault(token, node)
|
||||
# Which canvas the matched node lives on — the rack prints it so the user
|
||||
# knows where the logical twin of a mount is drawn.
|
||||
design_names = {
|
||||
d.id: d.name for d in (await db.execute(select(Design))).scalars().all()
|
||||
}
|
||||
|
||||
items = []
|
||||
for device in devices:
|
||||
if device.suggested_type in _UNRACKABLE_TYPES:
|
||||
continue
|
||||
# A node the user linked by hand wins; a deleted one falls back to the
|
||||
# guess rather than reporting a node that no longer exists.
|
||||
linked: Node | None = by_id.get(pinned.get(device.id) or "")
|
||||
if linked is None and device.ieee_address:
|
||||
linked = by_ieee.get(device.ieee_address)
|
||||
if linked is None:
|
||||
linked = next((by_ip[t] for t in _ip_tokens(device.ip) if t in by_ip), None)
|
||||
# The node is the richer record once a device has been approved onto a
|
||||
# canvas; the inventory row is what discovery last saw. Report both and
|
||||
# let the panel prefer the node.
|
||||
services = _services(device.services) or (
|
||||
_services(linked.services) if linked else []
|
||||
)
|
||||
items.append(
|
||||
RackInventoryItem(
|
||||
id=device.id,
|
||||
label=_device_label(device),
|
||||
suggested_type=device.suggested_type,
|
||||
ip=device.ip,
|
||||
status=device.status,
|
||||
discovery_source=device.discovery_source,
|
||||
mac=device.mac or device.ieee_address,
|
||||
hostname=device.hostname,
|
||||
os=device.os,
|
||||
services=services,
|
||||
node_id=linked.id if linked else None,
|
||||
node_status=linked.status if linked else None,
|
||||
node_label=linked.label if linked else None,
|
||||
node_type=linked.type if linked else None,
|
||||
node_ip=linked.ip if linked else None,
|
||||
node_mac=(linked.mac or linked.ieee_address) if linked else None,
|
||||
node_hostname=linked.hostname if linked else None,
|
||||
node_os=linked.os if linked else None,
|
||||
node_check_method=linked.check_method if linked else None,
|
||||
node_design_id=linked.design_id if linked else None,
|
||||
node_design_name=(
|
||||
design_names.get(linked.design_id) if linked and linked.design_id else None
|
||||
),
|
||||
node_last_seen=linked.last_seen if linked else None,
|
||||
racked=device.id in mounted,
|
||||
)
|
||||
)
|
||||
return RackInventoryResponse(items=items)
|
||||
+127
-33
@@ -12,9 +12,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import (
|
||||
Design,
|
||||
Edge,
|
||||
InventoryDevice,
|
||||
InventoryDeviceLink,
|
||||
Node,
|
||||
RackDevice,
|
||||
ScanRun,
|
||||
)
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.schemas.scan import InventoryDeviceCreate, InventoryDeviceResponse, ScanRunResponse
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.zigbee_service import (
|
||||
@@ -38,6 +47,15 @@ def _ip_tokens(ip: str | None) -> list[str]:
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
def _is_rack_only(device: InventoryDevice) -> bool:
|
||||
"""True for inventory entries created from a rack canvas.
|
||||
|
||||
They describe a mount — a patch panel, a shelf, a chassis — not a host to
|
||||
document on a logical canvas, so they are never approved onto one.
|
||||
"""
|
||||
return device.discovery_source == "rack" or "rack" in (device.discovery_sources or [])
|
||||
|
||||
|
||||
def _is_wireless(node_type: str | None) -> bool:
|
||||
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
|
||||
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
|
||||
@@ -224,7 +242,7 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
||||
|
||||
|
||||
async def _canvas_correlation(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
db: AsyncSession, devices: list[InventoryDevice]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
|
||||
or ``ip``.
|
||||
@@ -290,8 +308,8 @@ async def _canvas_correlation(
|
||||
|
||||
|
||||
async def _with_canvas_counts(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
) -> list[PendingDevice]:
|
||||
db: AsyncSession, devices: list[InventoryDevice]
|
||||
) -> list[InventoryDevice]:
|
||||
"""Attach transient canvas count + linked-node timestamps for the response."""
|
||||
info = await _canvas_correlation(db, devices)
|
||||
for d in devices:
|
||||
@@ -304,28 +322,91 @@ async def _with_canvas_counts(
|
||||
return devices
|
||||
|
||||
|
||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
@router.get("/pending", response_model=list[InventoryDeviceResponse])
|
||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
|
||||
# Inventory: every scanned device except the user-hidden ones. Approved devices
|
||||
# stay listed so they keep showing with a canvas-presence badge.
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
|
||||
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending", response_model=InventoryDeviceResponse, status_code=201)
|
||||
async def create_pending(
|
||||
body: InventoryDeviceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> InventoryDevice:
|
||||
"""Add an inventory entry by hand, for hardware no scan can discover.
|
||||
|
||||
Lands as ``status="pending"`` like a discovery would, so the existing approve
|
||||
/ hide / restore flows apply unchanged.
|
||||
"""
|
||||
device = InventoryDevice(
|
||||
hostname=body.hostname,
|
||||
ip=body.ip,
|
||||
# Canonical form, like every other write path: dedup compares MACs by
|
||||
# equality, so a hand-typed "AA-BB-CC-11-22-33" would never match the
|
||||
# scanned "aa:bb:cc:11:22:33" and approve would build a duplicate node.
|
||||
mac=normalize_mac(body.mac),
|
||||
suggested_type=body.suggested_type,
|
||||
model=body.model,
|
||||
vendor=body.vendor,
|
||||
properties=body.properties,
|
||||
status="pending",
|
||||
discovery_source=body.discovery_source,
|
||||
discovery_sources=[body.discovery_source],
|
||||
)
|
||||
db.add(device)
|
||||
await db.commit()
|
||||
await db.refresh(device)
|
||||
return (await _with_canvas_counts(db, [device]))[0]
|
||||
|
||||
|
||||
@router.delete("/pending", response_model=dict)
|
||||
async def clear_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
result = await db.execute(sa_delete(InventoryDevice).where(InventoryDevice.status == "pending"))
|
||||
await db.commit()
|
||||
return {"deleted": result.rowcount}
|
||||
|
||||
|
||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||
@router.delete("/pending/{device_id}", response_model=dict)
|
||||
async def delete_pending(
|
||||
device_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, bool]:
|
||||
"""Remove one inventory entry.
|
||||
|
||||
Used by the rack canvas to drop the placeholder it created for a plate once
|
||||
that plate is pointed at a real inventory row. Refuses while a rack still
|
||||
mounts the device: SQLite runs with foreign keys off here, so the mount's
|
||||
``device_id`` would be left naming a row that no longer exists rather than
|
||||
being cleared by the ``ON DELETE SET NULL`` the schema declares.
|
||||
"""
|
||||
device = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id == device_id))
|
||||
).scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
mounted = (
|
||||
await db.execute(select(RackDevice.id).where(RackDevice.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if mounted:
|
||||
raise HTTPException(status_code=409, detail="Device is mounted in a rack")
|
||||
|
||||
await db.delete(device)
|
||||
await db.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.get("/hidden", response_model=list[InventoryDeviceResponse])
|
||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
|
||||
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status == "hidden"))
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@@ -350,9 +431,9 @@ async def bulk_approve_devices(
|
||||
# node was later deleted) must still be placeable on THIS design. Duplicates
|
||||
# are guarded per-design below, not by the global status flag.
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status != "hidden",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
@@ -378,9 +459,17 @@ async def bulk_approve_devices(
|
||||
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
|
||||
|
||||
created_nodes: list[Node] = []
|
||||
approved_devices: list[PendingDevice] = []
|
||||
approved_devices: list[InventoryDevice] = []
|
||||
skipped_devices: list[dict[str, Any]] = []
|
||||
for device in devices:
|
||||
# Rack-only gear belongs to a rack canvas, never to a logical one.
|
||||
if _is_rack_only(device):
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or "device",
|
||||
"match": "rack", "value": "rack device", "_ref": None,
|
||||
})
|
||||
continue
|
||||
# Record which identifier collided so the caller can explain each skip
|
||||
# (and, for existing on-canvas nodes, link to the node already there).
|
||||
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
|
||||
@@ -477,9 +566,9 @@ async def bulk_hide_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
@@ -495,7 +584,7 @@ async def restore_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if device.status != "hidden":
|
||||
@@ -512,9 +601,9 @@ async def bulk_restore_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "hidden",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status == "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
@@ -537,7 +626,7 @@ async def approve_device(
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
|
||||
@@ -549,6 +638,11 @@ async def approve_device(
|
||||
# device is off-limits here.
|
||||
if device.status == "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is hidden")
|
||||
if _is_rack_only(device):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Rack devices cannot be placed on a logical canvas",
|
||||
)
|
||||
wireless = _is_wireless(node_data.type)
|
||||
|
||||
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
|
||||
@@ -621,10 +715,10 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
|
||||
return False
|
||||
found = (
|
||||
await db.execute(
|
||||
select(PendingDeviceLink.id)
|
||||
select(InventoryDeviceLink.id)
|
||||
.where(
|
||||
PendingDeviceLink.discovery_source == "proxmox_cluster",
|
||||
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
|
||||
InventoryDeviceLink.discovery_source == "proxmox_cluster",
|
||||
(InventoryDeviceLink.source_ieee == ieee) | (InventoryDeviceLink.target_ieee == ieee),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
@@ -635,7 +729,7 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
|
||||
async def _resolve_pending_links_for_ieee(
|
||||
db: AsyncSession, ieee: str | None, design_id: str | None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Materialize edges for any pending_device_links involving ``ieee`` on the
|
||||
"""Materialize edges for any device_inventory_links involving ``ieee`` on the
|
||||
canvas identified by ``design_id``.
|
||||
|
||||
For each link where the other endpoint already exists as a Node *on this
|
||||
@@ -649,9 +743,9 @@ async def _resolve_pending_links_for_ieee(
|
||||
return []
|
||||
|
||||
links_q = await db.execute(
|
||||
select(PendingDeviceLink).where(
|
||||
(PendingDeviceLink.source_ieee == ieee)
|
||||
| (PendingDeviceLink.target_ieee == ieee)
|
||||
select(InventoryDeviceLink).where(
|
||||
(InventoryDeviceLink.source_ieee == ieee)
|
||||
| (InventoryDeviceLink.target_ieee == ieee)
|
||||
)
|
||||
)
|
||||
links = list(links_q.scalars().all())
|
||||
@@ -751,7 +845,7 @@ async def _resolve_pending_links_for_ieee(
|
||||
async def hide_device(
|
||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
device.status = "hidden"
|
||||
@@ -763,7 +857,7 @@ async def hide_device(
|
||||
async def ignore_device(
|
||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
await db.delete(device)
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -38,8 +38,8 @@ async def summary(
|
||||
pending = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PendingDevice)
|
||||
.where(PendingDevice.status == "pending")
|
||||
.select_from(InventoryDevice)
|
||||
.where(InventoryDevice.status == "pending")
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@@ -58,6 +58,8 @@ async def summary(
|
||||
"online": counts.get("online", 0),
|
||||
"offline": counts.get("offline", 0),
|
||||
"unknown": counts.get("unknown", 0),
|
||||
# Wire key kept from before the table was renamed to `device_inventory`:
|
||||
# the stats payload is a published contract, and the MCP server reads it.
|
||||
"pending_devices": pending,
|
||||
"zigbee_devices": zigbee,
|
||||
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
||||
|
||||
@@ -3,7 +3,13 @@ import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
decode_oidc_session_token,
|
||||
decode_token,
|
||||
oidc_session_cookie_name,
|
||||
origin_is_allowed,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,19 +27,29 @@ def _drop(websocket: WebSocket) -> None:
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
await websocket.accept()
|
||||
try:
|
||||
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
if not origin_is_allowed(websocket.headers.get("origin")):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
|
||||
if settings.auth_mode == "oidc":
|
||||
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
|
||||
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
else:
|
||||
# Local mode keeps the existing first-message bearer protocol.
|
||||
try:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
_connections.append(websocket)
|
||||
try:
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeConfig,
|
||||
@@ -185,7 +185,7 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZigbeeImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
"""Upsert nodes/edges into device_inventory + device_inventory_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.
|
||||
@@ -230,12 +230,12 @@ async def _persist_pending_import(
|
||||
)
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if inv is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -263,12 +263,12 @@ async def _persist_pending_import(
|
||||
continue
|
||||
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -303,7 +303,7 @@ async def _persist_pending_import(
|
||||
|
||||
# Replace all zigbee-source links with the freshly discovered set.
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
|
||||
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zigbee")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
@@ -315,7 +315,7 @@ async def _persist_pending_import(
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zigbee",
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zwave import (
|
||||
ZwaveConfig,
|
||||
@@ -182,7 +182,7 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZwaveImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
"""Upsert nodes/edges into device_inventory + device_inventory_links.
|
||||
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
|
||||
identity. All zwave-source links are wiped and re-inserted from the new map.
|
||||
@@ -225,12 +225,12 @@ async def _persist_pending_import(
|
||||
)
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if inv is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -257,12 +257,12 @@ async def _persist_pending_import(
|
||||
continue
|
||||
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -292,7 +292,7 @@ async def _persist_pending_import(
|
||||
|
||||
# Replace all zwave-source links with the freshly discovered set.
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
|
||||
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zwave")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
@@ -304,7 +304,7 @@ async def _persist_pending_import(
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zwave",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,11 +36,22 @@ class Settings(BaseSettings):
|
||||
access_token_expire_minutes: int = 1440 # 24h
|
||||
|
||||
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
|
||||
auth_mode: Literal["local", "oidc"] = "local"
|
||||
auth_username: str = "admin"
|
||||
auth_password_hash: str = ""
|
||||
|
||||
# OpenID Connect — required only when AUTH_MODE=oidc.
|
||||
oidc_discovery_url: str = ""
|
||||
oidc_client_id: str = ""
|
||||
oidc_client_secret: str = ""
|
||||
oidc_redirect_uri: str = ""
|
||||
oidc_scopes: str = "openid profile email"
|
||||
oidc_cookie_secure: bool = True
|
||||
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
|
||||
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_password_hash(self) -> "Settings":
|
||||
def validate_auth_settings(self) -> "Settings":
|
||||
h = self.auth_password_hash
|
||||
if h and not h.startswith("$2"):
|
||||
logger.error(
|
||||
@@ -47,6 +59,35 @@ class Settings(BaseSettings):
|
||||
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
||||
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
||||
)
|
||||
if self.auth_mode == "oidc":
|
||||
required = {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_CLIENT_ID": self.oidc_client_id,
|
||||
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}
|
||||
missing = [name for name, value in required.items() if not value]
|
||||
if missing:
|
||||
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
|
||||
if len(self.secret_key.encode("utf-8")) < 32:
|
||||
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
|
||||
if "openid" not in self.oidc_scopes.split():
|
||||
raise ValueError("OIDC_SCOPES must contain 'openid'")
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith(("https://", "http://")):
|
||||
raise ValueError(f"{name} must be an HTTP(S) URL")
|
||||
if self.oidc_cookie_secure:
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith("https://"):
|
||||
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
|
||||
if "*" in self.cors_origins:
|
||||
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import hmac
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
|
||||
from fastapi import Request, Response, status
|
||||
from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OIDCSession:
|
||||
subject: str
|
||||
issuer: str
|
||||
display_name: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not plain or not hashed:
|
||||
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
return str(sub) if sub is not None else None
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
|
||||
subject = userinfo.get("sub")
|
||||
issuer = userinfo.get("iss")
|
||||
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
|
||||
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
|
||||
|
||||
display_name = next(
|
||||
(
|
||||
value
|
||||
for value in (
|
||||
userinfo.get("preferred_username"),
|
||||
userinfo.get("name"),
|
||||
userinfo.get("email"),
|
||||
subject,
|
||||
)
|
||||
if isinstance(value, str) and value
|
||||
),
|
||||
subject,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"oidc_issuer": issuer,
|
||||
"name": display_name,
|
||||
"csrf": secrets.token_urlsafe(32),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
|
||||
"token_use": OIDC_SESSION_TOKEN_USE,
|
||||
}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_oidc_session_token(token: str) -> OIDCSession | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
|
||||
return None
|
||||
subject = payload.get("sub")
|
||||
issuer = payload.get("oidc_issuer")
|
||||
display_name = payload.get("name")
|
||||
csrf_token = payload.get("csrf")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
return None
|
||||
if not isinstance(issuer, str) or not issuer:
|
||||
return None
|
||||
if not isinstance(display_name, str) or not display_name:
|
||||
return None
|
||||
if not isinstance(csrf_token, str) or not csrf_token:
|
||||
return None
|
||||
return OIDCSession(
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
display_name=display_name,
|
||||
csrf_token=csrf_token,
|
||||
)
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def oidc_session_cookie_name() -> str:
|
||||
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
|
||||
|
||||
|
||||
def set_oidc_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
value=token,
|
||||
max_age=settings.oidc_session_expire_minutes * 60,
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_oidc_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def get_oidc_session(request: Request) -> OIDCSession | None:
|
||||
token = request.cookies.get(oidc_session_cookie_name())
|
||||
return decode_oidc_session_token(token) if token else None
|
||||
|
||||
|
||||
def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
# Explicit non-cookie credentials take precedence in get_current_user,
|
||||
# so their requests keep the existing CSRF-free API behavior.
|
||||
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
|
||||
return await call_next(request)
|
||||
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is None:
|
||||
return await call_next(request)
|
||||
|
||||
csrf_token = request.headers.get("X-Homelable-CSRF", "")
|
||||
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
|
||||
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "CSRF validation failed"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _build_oidc_client(
|
||||
discovery_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: str,
|
||||
) -> StarletteOAuth2App:
|
||||
oauth = OAuth()
|
||||
client = oauth.register(
|
||||
name="oidc",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": scopes,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
if client is None:
|
||||
raise RuntimeError("Failed to register OIDC client")
|
||||
return client
|
||||
|
||||
|
||||
def get_oidc_client() -> StarletteOAuth2App:
|
||||
return _build_oidc_client(
|
||||
settings.oidc_discovery_url,
|
||||
settings.oidc_client_id,
|
||||
settings.oidc_client_secret,
|
||||
settings.oidc_scopes,
|
||||
)
|
||||
|
||||
+102
-28
@@ -60,9 +60,67 @@ def _backup_db() -> None:
|
||||
logger.warning("Could not create DB backup at %s", backup_path)
|
||||
|
||||
|
||||
# Tables renamed in 3.2.0. "Pending devices" was the scanner's word for a queue
|
||||
# of finds awaiting approval; the same rows outlive approval, are edited by hand
|
||||
# and are what a rack mounts, so the product calls them the Device Inventory.
|
||||
# Only the names moved — every column, every row and every route is unchanged.
|
||||
_RENAMED_TABLES: list[tuple[str, str]] = [
|
||||
("pending_devices", "device_inventory"),
|
||||
("pending_device_links", "device_inventory_links"),
|
||||
]
|
||||
|
||||
|
||||
async def _rename_legacy_tables(conn: AsyncConnection) -> None:
|
||||
"""Rename the pre-3.2.0 inventory tables. Must run before `create_all`.
|
||||
|
||||
Order matters: `create_all` would otherwise create an empty
|
||||
`device_inventory` beside the populated `pending_devices`, and every device
|
||||
the user ever scanned would look lost.
|
||||
|
||||
Foreign keys are switched on for the rename so SQLite rewrites the
|
||||
``REFERENCES pending_devices`` clause in `rack_devices` too; enforcement is
|
||||
off at runtime, so a missed rewrite costs nothing today, but it would leave
|
||||
the schema naming a table that no longer exists.
|
||||
"""
|
||||
for old, new in _RENAMED_TABLES:
|
||||
rows = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?)",
|
||||
(old, new),
|
||||
)
|
||||
).fetchall()
|
||||
names = {r[0] for r in rows}
|
||||
if old not in names:
|
||||
continue # Fresh install, or already migrated.
|
||||
if new in names:
|
||||
# A start that ran `create_all` before this migration existed left an
|
||||
# empty new table beside the populated old one, and the app read the
|
||||
# empty one. Clear it out of the way rather than stranding the data;
|
||||
# a new table with rows in it means the rename already happened and
|
||||
# something re-created the old one, which is not ours to resolve.
|
||||
count = (await conn.exec_driver_sql(f"SELECT COUNT(*) FROM {new}")).scalar()
|
||||
if count:
|
||||
logger.warning(
|
||||
"Both %s and %s hold rows; leaving them alone. Merge them by hand.", old, new
|
||||
)
|
||||
continue
|
||||
logger.info("Dropping the empty %s left by an earlier start", new)
|
||||
await _try_migrate(conn, f"DROP TABLE {new}", label=f"{new}.drop_empty")
|
||||
logger.info("Migrating table %s -> %s", old, new)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
await _try_migrate(conn, f"ALTER TABLE {old} RENAME TO {new}", label=f"{new}.rename")
|
||||
# The index rides the rename under its old name; the model asks for the new
|
||||
# one, so drop the stale duplicate rather than carrying both.
|
||||
await _try_migrate(
|
||||
conn, "DROP INDEX IF EXISTS ix_pending_devices_ieee_address", label="device_inventory.index",
|
||||
)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
_backup_db()
|
||||
async with engine.begin() as conn:
|
||||
await _rename_legacy_tables(conn)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns introduced after initial schema (idempotent)
|
||||
with suppress(OperationalError):
|
||||
@@ -114,11 +172,11 @@ async def init_db() -> None:
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_source TEXT")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN properties JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
|
||||
await conn.exec_driver_sql("UPDATE device_inventory SET properties = '[]' WHERE properties IS NULL")
|
||||
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) ---
|
||||
@@ -128,32 +186,32 @@ async def init_db() -> None:
|
||||
"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"),
|
||||
("device_inventory.ieee_address", "ALTER TABLE device_inventory 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)",
|
||||
"device_inventory.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(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"),
|
||||
("device_inventory.friendly_name", "ALTER TABLE device_inventory ADD COLUMN friendly_name TEXT"),
|
||||
("device_inventory.device_subtype", "ALTER TABLE device_inventory ADD COLUMN device_subtype TEXT"),
|
||||
("device_inventory.model", "ALTER TABLE device_inventory ADD COLUMN model TEXT"),
|
||||
("device_inventory.vendor", "ALTER TABLE device_inventory ADD COLUMN vendor TEXT"),
|
||||
("device_inventory.lqi", "ALTER TABLE device_inventory 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).
|
||||
# Drop NOT NULL on device_inventory.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)")
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(device_inventory)")
|
||||
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")
|
||||
logger.info("Migrating device_inventory: 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 ("
|
||||
"CREATE TABLE device_inventory_new ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"ip VARCHAR,"
|
||||
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
||||
@@ -171,25 +229,25 @@ async def init_db() -> None:
|
||||
")"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO pending_devices_new "
|
||||
"INSERT INTO device_inventory_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"
|
||||
"model, vendor, lqi, discovered_at FROM device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE pending_devices")
|
||||
await conn.exec_driver_sql("DROP TABLE device_inventory")
|
||||
await conn.exec_driver_sql(
|
||||
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
|
||||
"ALTER TABLE device_inventory_new RENAME TO device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||
"ON pending_devices(ieee_address)"
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(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)
|
||||
logger.warning("device_inventory ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
# --- Electrical designs schema migrations -----------------------------
|
||||
# Create designs table (idempotent)
|
||||
@@ -286,6 +344,22 @@ async def init_db() -> None:
|
||||
logger.warning("canvas_state migration failed: %s", exc)
|
||||
# --- end Electrical designs schema migrations --------------------------
|
||||
|
||||
# Rack cables gained editable annotations: a label the user can print on
|
||||
# the canvas, plus the same free-form property records nodes carry.
|
||||
rack_cable_migrations: list[tuple[str, str]] = [
|
||||
(
|
||||
"rack_cables.label_visible",
|
||||
"ALTER TABLE rack_cables ADD COLUMN label_visible BOOLEAN NOT NULL DEFAULT 0",
|
||||
),
|
||||
("rack_cables.properties", "ALTER TABLE rack_cables ADD COLUMN properties JSON"),
|
||||
]
|
||||
for label, sql in rack_cable_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE rack_cables SET properties = '[]' WHERE properties IS NULL"
|
||||
)
|
||||
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
@@ -325,28 +399,28 @@ async def init_db() -> None:
|
||||
# Proxmox import carries every source. Backfill from the legacy single
|
||||
# discovery_source so existing rows show under their filter.
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_sources JSON")
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_sources JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
|
||||
"UPDATE device_inventory SET discovery_sources = json_array(discovery_source) "
|
||||
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
|
||||
)
|
||||
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
|
||||
# but a real IP — treat them as an ARP scan so they keep the IP tag.
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE pending_devices SET discovery_sources = json_array('arp') "
|
||||
"UPDATE device_inventory SET discovery_sources = json_array('arp') "
|
||||
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
|
||||
"UPDATE device_inventory SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
|
||||
)
|
||||
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
|
||||
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
||||
"UPDATE device_inventory SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
|
||||
+103
-5
@@ -105,8 +105,106 @@ class CanvasState(Base):
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class PendingDevice(Base):
|
||||
__tablename__ = "pending_devices"
|
||||
class Rack(Base):
|
||||
"""A physical rack on a `design_type='rack'` canvas.
|
||||
|
||||
Racks live per design, like nodes and edges. Geometry is expressed in rack
|
||||
units: `u_height` is the number of mountable U, and a device's `u_start` is
|
||||
always counted from the bottom rail — `numbering` only changes the printed
|
||||
labels.
|
||||
"""
|
||||
|
||||
__tablename__ = "racks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
u_height: Mapped[int] = mapped_column(Integer, default=42)
|
||||
# "19" or "10" (inches). Drives the drawn inner width.
|
||||
width_standard: Mapped[str] = mapped_column(String, default="19")
|
||||
# "bottom-up" or "top-down" — printed U labels only, never storage order.
|
||||
numbering: Mapped[str] = mapped_column(String, default="bottom-up")
|
||||
location: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Frame/rail/interior colours + showNumbers/enclosed flags.
|
||||
style: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class RackDevice(Base):
|
||||
"""A piece of gear mounted in a rack.
|
||||
|
||||
Two independent, both-optional links back to the rest of the app:
|
||||
|
||||
* `device_id` — the Device Inventory entry (`device_inventory`). This is the
|
||||
primary link: inventory rows survive approval *and* node deletion, so
|
||||
unracking or deleting a canvas node never removes the inventory entry.
|
||||
* `node_id` — the logical-canvas node, when one exists. Only used to resolve
|
||||
live status and to seed cables from the network design's edges.
|
||||
|
||||
Both are ``SET NULL`` on delete and `label` is denormalized, so a rack keeps
|
||||
rendering after an inventory purge.
|
||||
"""
|
||||
|
||||
__tablename__ = "rack_devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
rack_id: Mapped[str] = mapped_column(String, ForeignKey("racks.id", ondelete="CASCADE"), nullable=False)
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
node_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 1-based, counted from the bottom rail.
|
||||
u_start: Mapped[int] = mapped_column(Integer, default=1)
|
||||
u_height: Mapped[int] = mapped_column(Integer, default=1)
|
||||
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
|
||||
col_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||
col_span: Mapped[int] = mapped_column(Integer, default=12)
|
||||
faceplate_id: Mapped[str] = mapped_column(String, nullable=False, default="blank-1u")
|
||||
color: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, default="unknown")
|
||||
# [{id, label, type, x, y}] — positions are unit coordinates on the plate.
|
||||
ports: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class RackCable(Base):
|
||||
"""A patch between two rack device ports.
|
||||
|
||||
Not an `Edge`: rack cables are port-to-port and may cross racks, so they are
|
||||
their own relation rather than a canvas edge.
|
||||
"""
|
||||
|
||||
__tablename__ = "rack_cables"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
from_device_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
from_port_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
to_device_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
to_port_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||
color: Mapped[str] = mapped_column(String, default="#39d353")
|
||||
label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Print the label next to the run on the canvas.
|
||||
label_visible: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# [{key, value, icon, visible}] — same records nodes carry; the visible ones
|
||||
# are drawn beside the cable.
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class InventoryDevice(Base):
|
||||
__tablename__ = "device_inventory"
|
||||
# Permit the plain (non-Mapped[]) annotations on the transient request-only
|
||||
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
|
||||
__allow_unmapped__ = True
|
||||
@@ -149,16 +247,16 @@ class PendingDevice(Base):
|
||||
node_last_seen: datetime | None = None
|
||||
|
||||
|
||||
class PendingDeviceLink(Base):
|
||||
class InventoryDeviceLink(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
|
||||
a InventoryDevice. On approval, the matching Edge is auto-created when both
|
||||
endpoints exist as canvas Nodes.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_device_links"
|
||||
__tablename__ = "device_inventory_links"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
|
||||
+14
-1
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.routes import (
|
||||
auth,
|
||||
@@ -16,6 +17,7 @@ from app.api.routes import (
|
||||
media,
|
||||
nodes,
|
||||
proxmox,
|
||||
racks,
|
||||
scan,
|
||||
stats,
|
||||
status,
|
||||
@@ -25,6 +27,7 @@ from app.api.routes import (
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.core.security import OIDCCSRFMiddleware
|
||||
from app.db.database import init_db
|
||||
|
||||
|
||||
@@ -53,12 +56,21 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(OIDCCSRFMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
|
||||
max_age=settings.oidc_transaction_expire_seconds,
|
||||
same_site="lax",
|
||||
https_only=settings.oidc_cookie_secure,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
@@ -66,6 +78,7 @@ app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
||||
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
|
||||
app.include_router(racks.router, prefix="/api/v1/racks", tags=["racks"])
|
||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
|
||||
@@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel):
|
||||
edges: list[EdgeResponse]
|
||||
viewport: dict[str, Any]
|
||||
custom_style: dict[str, Any] | None = None
|
||||
# True once this design's canvas has ever been persisted (a CanvasState row
|
||||
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
|
||||
# who intentionally cleared their canvas (keep it empty). False also for a
|
||||
# missing/uninitialized design.
|
||||
initialized: bool = False
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
# Kept in sync with DesignType in frontend/src/types.
|
||||
# "network" and "electrical" render the same React Flow canvas and differ only by
|
||||
# palette and node set — the icon drives their presentation. "rack" is a genuinely
|
||||
# different renderer (racks, mounted gear, port-to-port cables), so the frontend
|
||||
# does branch on this field.
|
||||
DESIGN_TYPES = {"network", "electrical", "rack"}
|
||||
|
||||
|
||||
class DesignCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = "dashboard"
|
||||
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
|
||||
# the chosen icon now drives presentation. Defaults to a generic canvas.
|
||||
design_type: str = "network"
|
||||
|
||||
@field_validator("design_type")
|
||||
@classmethod
|
||||
def _known_type(cls, v: str) -> str:
|
||||
if v not in DESIGN_TYPES:
|
||||
raise ValueError(f"design_type must be one of {sorted(DESIGN_TYPES)}")
|
||||
return v
|
||||
|
||||
|
||||
class DesignUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
# Kept in sync with the frontend rack model (frontend/src/types).
|
||||
WIDTH_STANDARDS = {"19", "10"}
|
||||
NUMBERINGS = {"bottom-up", "top-down"}
|
||||
CABLE_TYPES = {"ethernet", "fiber"}
|
||||
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
|
||||
RACK_COLUMNS = 12
|
||||
|
||||
|
||||
class RackSave(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
u_height: int = 42
|
||||
width_standard: str = "19"
|
||||
numbering: str = "bottom-up"
|
||||
location: str | None = None
|
||||
style: dict[str, Any] = {}
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
@field_validator("u_height")
|
||||
@classmethod
|
||||
def _positive_height(cls, v: int) -> int:
|
||||
if not 1 <= v <= 100:
|
||||
raise ValueError("u_height must be between 1 and 100")
|
||||
return v
|
||||
|
||||
@field_validator("width_standard")
|
||||
@classmethod
|
||||
def _known_width(cls, v: str) -> str:
|
||||
if v not in WIDTH_STANDARDS:
|
||||
raise ValueError(f"width_standard must be one of {sorted(WIDTH_STANDARDS)}")
|
||||
return v
|
||||
|
||||
@field_validator("numbering")
|
||||
@classmethod
|
||||
def _known_numbering(cls, v: str) -> str:
|
||||
if v not in NUMBERINGS:
|
||||
raise ValueError(f"numbering must be one of {sorted(NUMBERINGS)}")
|
||||
return v
|
||||
|
||||
|
||||
class RackDeviceSave(BaseModel):
|
||||
id: str
|
||||
rack_id: str
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
label: str
|
||||
u_start: int = 1
|
||||
u_height: int = 1
|
||||
col_start: int = 0
|
||||
col_span: int = RACK_COLUMNS
|
||||
faceplate_id: str = "blank-1u"
|
||||
color: str | None = None
|
||||
status: str = "unknown"
|
||||
ports: list[Any] = []
|
||||
|
||||
@field_validator("u_start", "u_height")
|
||||
@classmethod
|
||||
def _positive(cls, v: int) -> int:
|
||||
if v < 1:
|
||||
raise ValueError("u_start and u_height are 1-based and must be >= 1")
|
||||
return v
|
||||
|
||||
@field_validator("col_start")
|
||||
@classmethod
|
||||
def _column_in_grid(cls, v: int) -> int:
|
||||
if not 0 <= v < RACK_COLUMNS:
|
||||
raise ValueError(f"col_start must be within the {RACK_COLUMNS}-column grid")
|
||||
return v
|
||||
|
||||
@field_validator("col_span")
|
||||
@classmethod
|
||||
def _span_in_grid(cls, v: int) -> int:
|
||||
if not 1 <= v <= RACK_COLUMNS:
|
||||
raise ValueError(f"col_span must be between 1 and {RACK_COLUMNS}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _fits_the_column_grid(self) -> "RackDeviceSave":
|
||||
"""`col_start` and `col_span` are legal apart and still illegal together.
|
||||
|
||||
11 + 12 spans to column 23 of a 12-column grid — each field passes its
|
||||
own check, and the frontend serializer clamps them independently, so
|
||||
nothing caught it before this.
|
||||
"""
|
||||
if self.col_start + self.col_span > RACK_COLUMNS:
|
||||
raise ValueError(
|
||||
f"col_start + col_span must not exceed the {RACK_COLUMNS}-column grid"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class RackCableSave(BaseModel):
|
||||
id: str
|
||||
from_device_id: str
|
||||
from_port_id: str
|
||||
to_device_id: str
|
||||
to_port_id: str
|
||||
type: str = "ethernet"
|
||||
color: str = "#39d353"
|
||||
label: str | None = None
|
||||
label_visible: bool = False
|
||||
# [{key, value, icon, visible}] — free-form, same shape as node properties.
|
||||
properties: list[dict[str, Any]] = []
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def _known_type(cls, v: str) -> str:
|
||||
if v not in CABLE_TYPES:
|
||||
raise ValueError(f"type must be one of {sorted(CABLE_TYPES)}")
|
||||
return v
|
||||
|
||||
@field_validator("properties", mode="before")
|
||||
@classmethod
|
||||
def _clean_properties(cls, v: Any) -> list[dict[str, Any]]:
|
||||
"""Keep only usable records — a property with no key draws an empty badge."""
|
||||
if not isinstance(v, list):
|
||||
return []
|
||||
return [p for p in v if isinstance(p, dict) and str(p.get("key", "")).strip()]
|
||||
|
||||
|
||||
class RackSaveRequest(BaseModel):
|
||||
"""Full rack state for one design — upserted, with anything missing pruned.
|
||||
|
||||
Same contract as ``POST /api/v1/canvas/save``: the client owns the state and
|
||||
sends all of it on an explicit Save.
|
||||
"""
|
||||
|
||||
design_id: str
|
||||
racks: list[RackSave] = []
|
||||
devices: list[RackDeviceSave] = []
|
||||
cables: list[RackCableSave] = []
|
||||
# Pan/zoom, stored on the design's shared CanvasState row like the logical canvas.
|
||||
viewport: dict[str, Any] = {}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _devices_fit_their_rack(self) -> "RackSaveRequest":
|
||||
"""A mount must fit inside the rack it names.
|
||||
|
||||
`RackDeviceSave` validates each field in isolation and never sees the
|
||||
rack, so `u_start: 40` in a 12U rack passed. A mount above the top rail
|
||||
draws outside the chassis and nothing in the UI can drag it back, so the
|
||||
server refuses to store it. Rack membership itself is checked in the
|
||||
route, which knows what is already persisted.
|
||||
"""
|
||||
heights = {r.id: r.u_height for r in self.racks}
|
||||
for device in self.devices:
|
||||
u_height = heights.get(device.rack_id)
|
||||
if u_height is None:
|
||||
continue # unknown rack — the route reports that with a 400
|
||||
if device.u_start + device.u_height - 1 > u_height:
|
||||
raise ValueError(
|
||||
f"Device {device.id} does not fit in rack {device.rack_id}: "
|
||||
f"U {device.u_start}–{device.u_start + device.u_height - 1} "
|
||||
f"of {u_height}U"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class RackResponse(BaseModel):
|
||||
id: str
|
||||
design_id: str
|
||||
name: str
|
||||
u_height: int
|
||||
width_standard: str
|
||||
numbering: str
|
||||
location: str | None = None
|
||||
style: dict[str, Any] = {}
|
||||
pos_x: float
|
||||
pos_y: float
|
||||
|
||||
@field_validator("style", mode="before")
|
||||
@classmethod
|
||||
def _coerce_style(cls, v: Any) -> dict[str, Any]:
|
||||
return v if isinstance(v, dict) else {}
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RackDeviceResponse(BaseModel):
|
||||
id: str
|
||||
design_id: str
|
||||
rack_id: str
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
label: str
|
||||
u_start: int
|
||||
u_height: int
|
||||
col_start: int
|
||||
col_span: int
|
||||
faceplate_id: str
|
||||
color: str | None = None
|
||||
status: str
|
||||
ports: list[Any] = []
|
||||
|
||||
@field_validator("ports", mode="before")
|
||||
@classmethod
|
||||
def _coerce_ports(cls, v: Any) -> list[Any]:
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RackCableResponse(BaseModel):
|
||||
id: str
|
||||
design_id: str
|
||||
from_device_id: str
|
||||
from_port_id: str
|
||||
to_device_id: str
|
||||
to_port_id: str
|
||||
type: str
|
||||
color: str
|
||||
label: str | None = None
|
||||
label_visible: bool = False
|
||||
properties: list[dict[str, Any]] = []
|
||||
|
||||
@field_validator("properties", mode="before")
|
||||
@classmethod
|
||||
def _list_properties(cls, v: Any) -> list[dict[str, Any]]:
|
||||
"""Rows written before the column existed read back as NULL."""
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
@field_validator("label_visible", mode="before")
|
||||
@classmethod
|
||||
def _bool_label_visible(cls, v: Any) -> bool:
|
||||
return bool(v)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RackStateResponse(BaseModel):
|
||||
racks: list[RackResponse]
|
||||
devices: list[RackDeviceResponse]
|
||||
cables: list[RackCableResponse]
|
||||
viewport: dict[str, Any] = {}
|
||||
|
||||
|
||||
class RackServiceInfo(BaseModel):
|
||||
"""One service fingerprinted on a device, as the rack view prints it."""
|
||||
|
||||
port: int | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class RackInventoryItem(BaseModel):
|
||||
"""A Device Inventory entry offered to the rack tray.
|
||||
|
||||
Carries the technical facts the logical canvas already knows about the same
|
||||
hardware (`node_*`), so a mount can print them without the user leaving the
|
||||
rack. Everything node-side is read-only here: the logical canvas owns it.
|
||||
"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
suggested_type: str | None = None
|
||||
ip: str | None = None
|
||||
status: str
|
||||
discovery_source: str | None = None
|
||||
# Inventory-side technical detail, from discovery.
|
||||
mac: str | None = None
|
||||
hostname: str | None = None
|
||||
os: str | None = None
|
||||
services: list[RackServiceInfo] = []
|
||||
# Live status of the matching canvas node, when the device is on one.
|
||||
node_id: str | None = None
|
||||
node_status: str | None = None
|
||||
# What that node holds — the logical view of the same box.
|
||||
node_label: str | None = None
|
||||
node_type: str | None = None
|
||||
node_ip: str | None = None
|
||||
node_mac: str | None = None
|
||||
node_hostname: str | None = None
|
||||
node_os: str | None = None
|
||||
node_check_method: str | None = None
|
||||
node_design_id: str | None = None
|
||||
node_design_name: str | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
# True when this device is already mounted somewhere in this design.
|
||||
racked: bool = False
|
||||
|
||||
@field_validator("node_last_seen")
|
||||
@classmethod
|
||||
def _utc(cls, v: datetime | None) -> datetime | None:
|
||||
"""Stamp UTC on a naive timestamp before it goes on the wire.
|
||||
|
||||
SQLite has no timezone type: an aware datetime written to `last_seen`
|
||||
reads back naive, and Pydantic then serializes it without an offset.
|
||||
`new Date("2026-08-08T10:00:00")` in the browser is *local* time, so the
|
||||
rack printed a last-seen shifted by the viewer's offset.
|
||||
"""
|
||||
if v is not None and v.tzinfo is None:
|
||||
return v.replace(tzinfo=timezone.utc)
|
||||
return v
|
||||
|
||||
|
||||
class RackInventoryResponse(BaseModel):
|
||||
items: list[RackInventoryItem]
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class PendingDeviceResponse(BaseModel):
|
||||
class InventoryDeviceResponse(BaseModel):
|
||||
id: str
|
||||
ip: str | None
|
||||
mac: str | None
|
||||
@@ -47,6 +47,41 @@ class PendingDeviceResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
"""Sources a hand-made inventory entry may claim.
|
||||
|
||||
``rack`` marks gear created from a rack canvas. Those rows live in the Device
|
||||
Inventory like any other, but they describe a physical mount rather than a host
|
||||
on the network, so they are never placed on a logical canvas.
|
||||
"""
|
||||
MANUAL_SOURCES = {"manual", "rack"}
|
||||
|
||||
|
||||
class InventoryDeviceCreate(BaseModel):
|
||||
"""Manually add an entry to the Device Inventory.
|
||||
|
||||
Used when the user documents hardware no scan can find — a dumb switch, a
|
||||
patch panel, a machine that is powered off. Lands with
|
||||
`discovery_source="manual"` (or `"rack"`) so the inventory filters can tell
|
||||
it apart.
|
||||
"""
|
||||
|
||||
hostname: str
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
suggested_type: str | None = None
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
properties: list[Any] = []
|
||||
discovery_source: str = "manual"
|
||||
|
||||
@field_validator("discovery_source")
|
||||
@classmethod
|
||||
def _known_source(cls, v: str) -> str:
|
||||
if v not in MANUAL_SOURCES:
|
||||
raise ValueError(f"discovery_source must be one of {sorted(MANUAL_SOURCES)}")
|
||||
return v
|
||||
|
||||
|
||||
class ScanRunResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Helpers for the multi-valued ``PendingDevice.discovery_sources`` set.
|
||||
"""Helpers for the multi-valued ``InventoryDevice.discovery_sources`` set.
|
||||
|
||||
A device discovered by more than one path (e.g. an IP scan *and* a Proxmox
|
||||
import) accumulates every source that has seen it, so it surfaces under each
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||
from app.services.http_probe import probe_open_ports
|
||||
@@ -476,12 +476,12 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||
otherwise the oldest row, and deletes the rest. Returns the number deleted.
|
||||
"""
|
||||
rows = (await db.execute(
|
||||
select(PendingDevice)
|
||||
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
|
||||
.order_by(PendingDevice.discovered_at)
|
||||
select(InventoryDevice)
|
||||
.where(InventoryDevice.status != "hidden", InventoryDevice.ip.isnot(None))
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
by_ip: dict[str, list[PendingDevice]] = {}
|
||||
by_ip: dict[str, list[InventoryDevice]] = {}
|
||||
for row in rows:
|
||||
if row.ip is None: # guarded by the query, but keeps the type checker happy
|
||||
continue
|
||||
@@ -507,7 +507,7 @@ async def run_scan(
|
||||
run_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
) -> None:
|
||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||
"""Execute scan for given CIDR ranges and populate device_inventory."""
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
|
||||
deep_scan = deep_scan or DeepScanOptions()
|
||||
@@ -527,7 +527,7 @@ async def run_scan(
|
||||
# Devices already on a canvas are intentionally NOT suppressed: they stay
|
||||
# in the inventory and are badged "In N canvas" via per-request correlation.
|
||||
hidden_ips_result = await db.execute(
|
||||
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
||||
select(InventoryDevice.ip).where(InventoryDevice.status == "hidden")
|
||||
)
|
||||
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
||||
|
||||
@@ -570,13 +570,13 @@ async def run_scan(
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [PendingDevice.ip == ip]
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(PendingDevice.mac == norm_mac)
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(PendingDevice)
|
||||
.where(or_(*match_cond), PendingDevice.status != "hidden")
|
||||
.order_by(PendingDevice.discovered_at)
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
if existing_rows:
|
||||
@@ -600,7 +600,7 @@ async def run_scan(
|
||||
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
|
||||
# status preserved — an approved device stays approved.
|
||||
else:
|
||||
db.add(PendingDevice(
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
mac=norm_mac,
|
||||
hostname=host.get("hostname"),
|
||||
|
||||
@@ -6,8 +6,10 @@ aiosqlite==0.20.0
|
||||
alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
PyJWT==2.13.0
|
||||
bcrypt==4.2.1
|
||||
authlib==1.7.2
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.31
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
|
||||
@@ -3,13 +3,13 @@ import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pending_device(db_session):
|
||||
import uuid
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.100",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
@@ -39,10 +39,10 @@ async def mem_db():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def two_pending_devices(db_session):
|
||||
async def two_device_inventory(db_session):
|
||||
devices = []
|
||||
for i in range(2):
|
||||
d = PendingDevice(
|
||||
d = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=f"192.168.1.{10 + i}",
|
||||
mac=None,
|
||||
@@ -62,7 +62,7 @@ async def two_pending_devices(db_session):
|
||||
|
||||
@pytest.fixture
|
||||
async def zigbee_pending_device(db_session):
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=None,
|
||||
mac=None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
|
||||
import uuid
|
||||
|
||||
from app.db.models import Design, Node, PendingDevice
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
|
||||
async def _add_design(db_session, name: str) -> str:
|
||||
@@ -21,7 +21,7 @@ def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
|
||||
|
||||
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, PendingDeviceLink
|
||||
from app.db.models import InventoryDeviceLink, Node
|
||||
|
||||
coord = Node(
|
||||
label="Coordinator",
|
||||
@@ -31,7 +31,7 @@ async def _seed_zigbee_pending_pair(db_session):
|
||||
)
|
||||
db_session.add(coord)
|
||||
|
||||
pending = PendingDevice(
|
||||
pending = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
@@ -42,7 +42,7 @@ async def _seed_zigbee_pending_pair(db_session):
|
||||
db_session.add(pending)
|
||||
|
||||
db_session.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee="0xCOORD",
|
||||
target_ieee="0xR1",
|
||||
discovery_source="zigbee",
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, Node, PendingDevice
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from tests.scan.helpers import _add_design, _node
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
||||
services=[], design_id=design.id,
|
||||
)
|
||||
db_session.add(existing)
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
@@ -103,7 +103,7 @@ async def test_approve_device_force_creates_duplicate_ieee(
|
||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
||||
services=[], design_id=design.id,
|
||||
))
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
@@ -274,7 +274,7 @@ async def test_approve_device_places_already_approved_on_another_design(
|
||||
target = await _add_design(db_session, "Network Topology")
|
||||
# Device is on `other` already (its global status is "approved").
|
||||
db_session.add(_node(other, ieee="0x00158d0005292b83"))
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
|
||||
suggested_type="zigbee_enddevice", status="approved",
|
||||
discovery_source="zigbee",
|
||||
@@ -399,8 +399,8 @@ async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
||||
|
||||
|
||||
@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]
|
||||
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
@@ -418,7 +418,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
client: AsyncClient, headers, db_session, two_pending_devices
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""Regression: a device already approved (status='approved', e.g. placed on
|
||||
another canvas) must still get a node on the design being approved onto.
|
||||
@@ -426,7 +426,7 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
Previously bulk-approve filtered status=='pending', so selecting an
|
||||
already-approved device created no node — the user saw fewer nodes than
|
||||
they selected."""
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design_a = await _add_design(db_session, "Canvas A")
|
||||
design_b = await _add_design(db_session, "Canvas B")
|
||||
|
||||
@@ -458,10 +458,10 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
client: AsyncClient, headers, db_session, two_pending_devices
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""A device already on the target canvas (same ip) is not placed twice."""
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
# First device already sits on the canvas (matched by ip).
|
||||
db_session.add(_node(design, ip="192.168.1.10"))
|
||||
@@ -487,11 +487,11 @@ async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
||||
client: AsyncClient, headers, db_session, two_pending_devices
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
|
||||
as the plain IPv4 is still recognised as already placed (issue #258)."""
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
|
||||
await db_session.commit()
|
||||
@@ -509,11 +509,11 @@ async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_reports_skipped_devices(
|
||||
client: AsyncClient, headers, db_session, two_pending_devices
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
|
||||
(with the existing node id) instead of silently dropping it."""
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
existing = _node(design, ip="192.168.1.10")
|
||||
db_session.add(existing)
|
||||
@@ -568,7 +568,7 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.55",
|
||||
mac="11:22:33:44:55:66",
|
||||
@@ -597,12 +597,12 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_device_inventory, 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]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
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()
|
||||
@@ -628,8 +628,8 @@ async def test_approve_device_sets_default_check_method(client: AsyncClient, hea
|
||||
|
||||
|
||||
@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]
|
||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
# Approve first device individually first
|
||||
await client.post(
|
||||
f"/api/v1/scan/pending/{ids[0]}/approve",
|
||||
@@ -645,15 +645,15 @@ async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@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]
|
||||
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
@@ -665,8 +665,8 @@ async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending
|
||||
|
||||
|
||||
@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]
|
||||
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
# 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)
|
||||
@@ -678,8 +678,8 @@ async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pen
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -690,7 +690,7 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
|
||||
first design — otherwise approved devices land on the wrong canvas."""
|
||||
first = await _add_design(db_session, "Default") # first design (fallback)
|
||||
active = await _add_design(db_session, "zwave") # the design the user is on
|
||||
dev = PendingDevice(
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ieee_address="zwave-H-2",
|
||||
friendly_name="Living Room Plug",
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
|
||||
"""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
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
@@ -193,7 +193,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
|
||||
# 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(
|
||||
new_pending = InventoryDevice(
|
||||
ieee_address="0xR1B",
|
||||
friendly_name="r1b",
|
||||
suggested_type="zigbee_router",
|
||||
@@ -202,7 +202,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
|
||||
)
|
||||
db_session.add(new_pending)
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
InventoryDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
res = await client.post(
|
||||
@@ -220,16 +220,16 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
a = InventoryDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
b = InventoryDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
@@ -238,7 +238,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -258,7 +258,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert edges == []
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # preserved for later resolution
|
||||
|
||||
|
||||
@@ -271,16 +271,16 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
be re-approved onto another canvas — it's topology, wiped only on reimport."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
a = InventoryDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
b = InventoryDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
@@ -289,7 +289,7 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -300,14 +300,14 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # retained for re-approval onto other canvases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
|
||||
active = await _add_design(db_session, "zwave")
|
||||
dev = PendingDevice(
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ieee_address="zwave-H-9",
|
||||
friendly_name="Door Sensor",
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
from tests.scan.helpers import _add_design, _node
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
|
||||
suggested_type="zigbee_enddevice", services=[], status="pending",
|
||||
)
|
||||
@@ -260,3 +260,111 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_normalizes_the_mac(client: AsyncClient, headers):
|
||||
# Dedup compares MACs by equality, so a hand-typed entry in any other
|
||||
# notation would never match the scanned row and approve would build a
|
||||
# duplicate node.
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={
|
||||
"hostname": "printer",
|
||||
"mac": "AA-BB-CC-11-22-33",
|
||||
"discovery_source": "manual",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["mac"] == "aa:bb:cc:11:22:33"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_keeps_a_missing_mac_null(client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "no-mac", "discovery_source": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["mac"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_removes_one_entry(client: AsyncClient, headers, pending_device):
|
||||
# The rack canvas drops the placeholder it created for a plate once that
|
||||
# plate is pointed at a real inventory row.
|
||||
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json() == {"deleted": True}
|
||||
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert listed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_unknown_is_404(client: AsyncClient, headers):
|
||||
res = await client.delete(f"/api/v1/scan/pending/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_requires_auth(client: AsyncClient, pending_device):
|
||||
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_refuses_a_mounted_device(client: AsyncClient, headers, pending_device):
|
||||
# `rack_devices.device_id` is ON DELETE SET NULL: without this guard the
|
||||
# delete would strip the mount of its device instead of erroring.
|
||||
design = (
|
||||
await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Rack Room", "icon": "server", "design_type": "rack"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
saved = await client.post(
|
||||
"/api/v1/racks/save",
|
||||
json={
|
||||
"design_id": design,
|
||||
"racks": [
|
||||
{
|
||||
"id": "rack-1",
|
||||
"name": "Main",
|
||||
"u_height": 12,
|
||||
"width_standard": "19",
|
||||
"numbering": "bottom-up",
|
||||
"style": {},
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev-1",
|
||||
"rack_id": "rack-1",
|
||||
"device_id": pending_device.id,
|
||||
"label": "my-server",
|
||||
"u_start": 1,
|
||||
"u_height": 1,
|
||||
"col_start": 0,
|
||||
"col_span": 12,
|
||||
"faceplate_id": "server-1u",
|
||||
"status": "unknown",
|
||||
"ports": [],
|
||||
}
|
||||
],
|
||||
"cables": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert saved.status_code == 200, saved.text
|
||||
|
||||
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
|
||||
assert res.status_code == 409
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [pending_device.id]
|
||||
|
||||
@@ -7,7 +7,7 @@ from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
@@ -141,7 +141,7 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
stale = PendingDevice(
|
||||
stale = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
@@ -167,7 +167,7 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
@@ -201,7 +201,7 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
@@ -212,7 +212,7 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
|
||||
"""Re-scanning an already-approved device updates its row in place instead of
|
||||
spawning a fresh pending duplicate, and keeps it approved."""
|
||||
approved = PendingDevice(
|
||||
approved = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
|
||||
os=None, services=[], suggested_type="server", status="approved",
|
||||
)
|
||||
@@ -228,7 +228,7 @@ async def test_run_scan_refreshes_approved_device_without_duplicating(db_session
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "approved"
|
||||
@@ -240,7 +240,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
|
||||
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
|
||||
row at scan start, even if the device is not re-discovered."""
|
||||
for status in ("approved", "pending", "pending"):
|
||||
db_session.add(PendingDevice(
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
|
||||
os=None, services=[], suggested_type="server", status=status,
|
||||
))
|
||||
@@ -255,7 +255,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.77")
|
||||
)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "approved" # approved row is the one kept
|
||||
@@ -264,7 +264,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
||||
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
|
||||
hidden = PendingDevice(
|
||||
hidden = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
@@ -289,9 +289,9 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == "192.168.1.50",
|
||||
PendingDevice.status == "pending",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.ip == "192.168.1.50",
|
||||
InventoryDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
@@ -395,7 +395,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
|
||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||
# Pre-existing pending device with no services
|
||||
existing = PendingDevice(
|
||||
existing = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
@@ -421,7 +421,7 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
|
||||
# Should still be only one device
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
assert len(devices) == 1
|
||||
|
||||
+288
-3
@@ -1,5 +1,56 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import ExpiredTokenError, JoseError
|
||||
from fastapi import WebSocketDisconnect
|
||||
from httpx import AsyncClient
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
class FakeOIDCClient:
|
||||
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
|
||||
self.token = token or {}
|
||||
self.error = error
|
||||
self.redirect_uri: str | None = None
|
||||
|
||||
async def authorize_redirect(self, _request, redirect_uri: str):
|
||||
self.redirect_uri = redirect_uri
|
||||
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
|
||||
|
||||
async def authorize_access_token(self, _request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_settings():
|
||||
from app.core.config import settings
|
||||
from app.core.security import _build_oidc_client
|
||||
|
||||
values = {
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "test-client-secret",
|
||||
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
|
||||
"oidc_scopes": "openid profile email",
|
||||
"oidc_cookie_secure": False,
|
||||
"oidc_session_expire_minutes": 60,
|
||||
"cors_origins": ["http://test"],
|
||||
}
|
||||
original = {name: getattr(settings, name) for name in values}
|
||||
for name, value in values.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
yield settings
|
||||
for name, value in original.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
|
||||
|
||||
async def test_login_success(client: AsyncClient):
|
||||
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_auth_config_defaults_to_local(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"mode": "local", "oidc_login_url": None}
|
||||
|
||||
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
@@ -76,7 +133,7 @@ async def test_expired_token_rejected(client: AsyncClient):
|
||||
"""A JWT whose `exp` is in the past must be refused."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
@@ -97,14 +154,14 @@ async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||
"""A token signed with a different key must not be accepted."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"sub": "admin",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
||||
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
|
||||
assert verify_password("", h) is False
|
||||
assert verify_password("hunter2", "") is False
|
||||
assert verify_password("", "") is False
|
||||
|
||||
|
||||
# --- OpenID Connect ---
|
||||
|
||||
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"mode": "oidc",
|
||||
"oidc_login_url": "/api/v1/auth/oidc/login",
|
||||
}
|
||||
|
||||
|
||||
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient()
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"].startswith("https://idp.example/")
|
||||
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
|
||||
|
||||
|
||||
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
|
||||
from app.core.security import get_oidc_client
|
||||
|
||||
oidc_client = get_oidc_client()
|
||||
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
|
||||
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
|
||||
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
|
||||
|
||||
query = parse_qs(urlparse(authorization["url"]).query)
|
||||
assert query["response_type"] == ["code"]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert "code_challenge" in query
|
||||
assert "code_verifier" not in query
|
||||
assert query["nonce"] == [authorization["nonce"]]
|
||||
assert authorization["code_verifier"]
|
||||
|
||||
|
||||
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
callback = await client.get("/api/v1/auth/oidc/callback")
|
||||
|
||||
assert callback.status_code == 303
|
||||
assert callback.headers["location"] == "/"
|
||||
cookie = callback.headers["set-cookie"]
|
||||
assert "homelable-session=" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=lax" in cookie
|
||||
|
||||
me = await client.get("/api/v1/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["subject"] == "user-123"
|
||||
assert me.json()["display_name"] == "alice"
|
||||
assert me.json()["auth_method"] == "oidc"
|
||||
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
|
||||
assert me.json()["csrf_token"]
|
||||
|
||||
protected = await client.get("/api/v1/nodes")
|
||||
assert protected.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
|
||||
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
|
||||
token = {} if userinfo is None else {"userinfo": userinfo}
|
||||
fake_client = FakeOIDCClient(token=token)
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "sensitive detail" not in res.text
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=ExpiredTokenError())
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "expired" not in res.text.lower()
|
||||
|
||||
|
||||
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
|
||||
from app.core.security import create_oidc_session_token
|
||||
|
||||
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
await client.get("/api/v1/auth/oidc/callback")
|
||||
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
|
||||
|
||||
missing = await client.post("/api/v1/auth/logout")
|
||||
assert missing.status_code == 403
|
||||
wrong_origin = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
valid = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert valid.status_code == 204
|
||||
assert "Max-Age=0" in valid.headers["set-cookie"]
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
|
||||
# --- WebSocket authentication ---
|
||||
|
||||
def _wait_for_connection_count(expected: int) -> None:
|
||||
from app.api.routes import status as status_routes
|
||||
|
||||
deadline = time.monotonic() + 1
|
||||
while len(status_routes._connections) != expected and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(status_routes._connections) == expected
|
||||
|
||||
|
||||
def test_local_websocket_keeps_first_message_bearer_flow():
|
||||
from app.core.security import create_access_token
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://localhost:3000"},
|
||||
) as websocket:
|
||||
websocket.send_json({"token": create_access_token("admin")})
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_websocket_rejects_untrusted_origin_before_auth():
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "https://evil.example"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
|
||||
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
|
||||
from app.main import app
|
||||
|
||||
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
test_client = TestClient(app)
|
||||
test_client.cookies.set(oidc_session_cookie_name(), session_token)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
):
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
@@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
|
||||
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||
|
||||
|
||||
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
|
||||
# A never-saved canvas has no CanvasState row → initialized False. The frontend
|
||||
# uses this to show the demo canvas only to genuinely new users.
|
||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert data["initialized"] is False
|
||||
|
||||
|
||||
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
|
||||
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
|
||||
# loaded empty canvas is reported initialized — the user cleared it on purpose
|
||||
# and must not get the demo re-seeded.
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert data["nodes"] == []
|
||||
assert data["initialized"] is True
|
||||
|
||||
|
||||
async def test_load_canvas_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/canvas")
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""The pending_devices -> device_inventory rename, run at startup.
|
||||
|
||||
It is the one migration that must happen *before* `create_all`, so it gets its
|
||||
own suite: get the order wrong and every device a user ever scanned reads as
|
||||
gone, because the app looks at a freshly created empty table.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db.database import _rename_legacy_tables
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _legacy_ddl(table: str) -> str:
|
||||
return f"CREATE TABLE {table} (id VARCHAR PRIMARY KEY, hostname VARCHAR)"
|
||||
|
||||
|
||||
async def _engine(tmp_path, *, statements: list[str]):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
|
||||
async with engine.begin() as conn:
|
||||
for sql in statements:
|
||||
await conn.exec_driver_sql(sql)
|
||||
return engine
|
||||
|
||||
|
||||
async def _tables(engine) -> set[str]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
).fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
async def _rename(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await _rename_legacy_tables(conn)
|
||||
|
||||
|
||||
async def test_renames_the_legacy_tables_keeping_their_rows(tmp_path):
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
_legacy_ddl("pending_device_links"),
|
||||
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
assert await _tables(engine) == {"device_inventory", "device_inventory_links"}
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.exec_driver_sql("SELECT hostname FROM device_inventory")).fetchall()
|
||||
assert [r[0] for r in rows] == ["nas"]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_rewrites_the_reference_from_rack_devices(tmp_path):
|
||||
# `rack_devices.device_id` names the inventory table. Left pointing at the
|
||||
# old name, the schema references a table that no longer exists.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
"CREATE TABLE rack_devices (id VARCHAR PRIMARY KEY, device_id VARCHAR "
|
||||
"REFERENCES pending_devices(id) ON DELETE SET NULL)",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
ddl = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT sql FROM sqlite_master WHERE name='rack_devices'"
|
||||
)
|
||||
).scalar()
|
||||
assert "device_inventory" in ddl
|
||||
assert "pending_devices" not in ddl
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_replaces_an_empty_table_left_by_an_earlier_start(tmp_path):
|
||||
# A start that ran `create_all` before this migration existed created the new
|
||||
# table empty, and the app read that one instead of the user's devices.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
_legacy_ddl("device_inventory"),
|
||||
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
assert await _tables(engine) == {"device_inventory"}
|
||||
async with engine.begin() as conn:
|
||||
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM device_inventory")).scalar()
|
||||
assert count == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_leaves_two_populated_tables_alone(tmp_path):
|
||||
# Rows on both sides is not a state this migration created, and dropping
|
||||
# either one would lose data.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
_legacy_ddl("device_inventory"),
|
||||
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'old')",
|
||||
"INSERT INTO device_inventory (id, hostname) VALUES ('d2', 'new')",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
assert await _tables(engine) == {"pending_devices", "device_inventory"}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_is_a_no_op_on_a_fresh_database(tmp_path):
|
||||
engine = await _engine(tmp_path, statements=[_legacy_ddl("device_inventory")])
|
||||
|
||||
await _rename(engine)
|
||||
await _rename(engine) # And still a no-op the second time round.
|
||||
|
||||
assert await _tables(engine) == {"device_inventory"}
|
||||
await engine.dispose()
|
||||
@@ -363,3 +363,43 @@ async def test_create_node_explicit_zero_position_preserved(client: AsyncClient,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_exact(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=Proxmox", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_substring(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve1-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve2-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=pve", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert sorted(n["label"] for n in res.json()) == ["pve1-node", "pve2-node"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_case_insensitive(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=PROXMOX", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_no_match(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=nonexistent", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, headers: dict):
|
||||
"""Omitting label is fully backward compatible with the old unfiltered behavior."""
|
||||
for label in ("A", "B", "C"):
|
||||
await client.post("/api/v1/nodes", json={"type": "generic", "label": label, "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 3
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.api.routes.proxmox import (
|
||||
)
|
||||
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
|
||||
from app.core.config import settings
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
|
||||
from app.db.models import Design, Edge, InventoryDevice, InventoryDeviceLink, Node
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -212,7 +212,7 @@ async def test_persist_creates_pending(db_session) -> None:
|
||||
result = await _persist_pending_import(db_session, nodes, edges)
|
||||
assert result.pending_created == 2
|
||||
assert result.links_recorded == 1
|
||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
|
||||
# Specs carried as properties.
|
||||
vm = next(r for r in rows if r.suggested_type == "vm")
|
||||
@@ -239,14 +239,14 @@ async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
|
||||
assert merged.cpu_count == 2
|
||||
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
|
||||
# Inventory row exists as approved (already on canvas).
|
||||
inv = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
inv = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
||||
# Device previously found by an IP scan: arp source, MAC known, no ieee.
|
||||
db_session.add(PendingDevice(
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="10.0.0.5", mac="bc:24:11:aa:bb:cc",
|
||||
suggested_type="generic", status="pending",
|
||||
discovery_source="arp", discovery_sources=["arp"],
|
||||
@@ -257,7 +257,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
||||
# different casing. Must merge, not duplicate.
|
||||
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
|
||||
|
||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row.ieee_address == "pve-pve1-101" # adopted proxmox identity
|
||||
@@ -269,7 +269,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
||||
async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -> None:
|
||||
# Legacy inventory row from an old IP scan, before discovery_source(s) were
|
||||
# recorded: scalar NULL, sources empty — but it has an IP + MAC.
|
||||
db_session.add(PendingDevice(
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
suggested_type="lxc", status="pending",
|
||||
discovery_source=None, discovery_sources=[],
|
||||
@@ -280,7 +280,7 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
|
||||
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
|
||||
)
|
||||
|
||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
# The IP tag must survive the proxmox merge even with no recorded origin.
|
||||
assert set(rows[0].discovery_sources) == {"arp", "proxmox"}
|
||||
@@ -295,7 +295,7 @@ async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -
|
||||
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
status="online", pos_x=0, pos_y=0,
|
||||
)
|
||||
inv = PendingDevice(
|
||||
inv = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
suggested_type="lxc", status="approved",
|
||||
discovery_source=None, discovery_sources=[],
|
||||
@@ -308,7 +308,7 @@ async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -
|
||||
)
|
||||
|
||||
inv_row = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.mac == "bc:24:11:6c:96:52")
|
||||
select(InventoryDevice).where(InventoryDevice.mac == "bc:24:11:6c:96:52")
|
||||
)).scalar_one()
|
||||
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
|
||||
|
||||
@@ -320,7 +320,7 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], []) # re-sync
|
||||
row = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101")
|
||||
)).scalar_one()
|
||||
assert set(row.discovery_sources) == {"proxmox"}
|
||||
|
||||
@@ -351,27 +351,27 @@ async def test_persist_resync_updates_in_place(db_session) -> None:
|
||||
await _persist_pending_import(db_session, nodes, [])
|
||||
# Second sync: same device, new IP. Should update, not duplicate.
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
|
||||
rows = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
|
||||
rows = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].ip == "10.0.0.9"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||
db_session.add(PendingDevice(
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
|
||||
suggested_type="vm", status="hidden", discovery_source="proxmox",
|
||||
))
|
||||
await db_session.commit()
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
row = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
row = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
assert row.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
|
||||
# Legacy row: properties column NULL (added by migration on older DBs).
|
||||
dev = PendingDevice(
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
|
||||
status="pending", discovery_source="arp",
|
||||
)
|
||||
@@ -396,7 +396,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
await _persist_pending_import(db_session, nodes, edges)
|
||||
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
|
||||
assert len(cluster) == 1
|
||||
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
|
||||
@@ -410,7 +410,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
||||
async def test_single_host_records_no_cluster_link(db_session) -> None:
|
||||
await _persist_pending_import(db_session, [_host_node()], [])
|
||||
links = (await db_session.execute(
|
||||
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
|
||||
select(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "proxmox_cluster")
|
||||
)).scalars().all()
|
||||
assert links == []
|
||||
|
||||
@@ -427,7 +427,7 @@ async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
|
||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
||||
left_handles=1, right_handles=1)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(PendingDeviceLink(
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
|
||||
discovery_source="proxmox_cluster",
|
||||
))
|
||||
@@ -459,7 +459,7 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
|
||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
||||
])
|
||||
db_session.add(PendingDeviceLink(
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
|
||||
discovery_source="zigbee",
|
||||
))
|
||||
@@ -476,7 +476,7 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
|
||||
assert {e.design_id for e in edges} == {da.id, db_.id}
|
||||
assert all(e.type == "iot" for e in edges)
|
||||
# The link row is still present for any further design.
|
||||
assert (await db_session.execute(select(PendingDeviceLink))).scalars().one()
|
||||
assert (await db_session.execute(select(InventoryDeviceLink))).scalars().one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -484,6 +484,6 @@ async def test_persist_never_deletes(db_session) -> None:
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
# A later sync that no longer includes vm101 must not remove it.
|
||||
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
|
||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
ieees = {r.ieee_address for r in rows}
|
||||
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""The rackable exclusion list is duplicated in the frontend — keep them equal.
|
||||
|
||||
`GET /api/v1/racks/inventory` filters on `_UNRACKABLE_TYPES`, while the Device
|
||||
Inventory modal's "Rackable" filter (used as the rack picker's default) filters
|
||||
on `UNRACKABLE_TYPES` in `frontend/src/utils/rackable.ts`. If the two drift, the
|
||||
picker offers devices the rack side then refuses.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.api.routes.racks import _UNRACKABLE_TYPES
|
||||
|
||||
FRONTEND_FILE = (
|
||||
Path(__file__).resolve().parents[2] / "frontend" / "src" / "utils" / "rackable.ts"
|
||||
)
|
||||
|
||||
|
||||
def _frontend_unrackable() -> set[str]:
|
||||
src = FRONTEND_FILE.read_text()
|
||||
match = re.search(r"export const UNRACKABLE_TYPES = new Set\(\[(.*?)\]\)", src, re.S)
|
||||
assert match, (
|
||||
"Could not locate UNRACKABLE_TYPES in frontend/src/utils/rackable.ts — "
|
||||
"update this parser if the file's shape changed."
|
||||
)
|
||||
return set(re.findall(r"'([^']+)'", match.group(1)))
|
||||
|
||||
|
||||
def test_unrackable_types_in_sync_with_frontend():
|
||||
frontend = _frontend_unrackable()
|
||||
assert frontend == _UNRACKABLE_TYPES, (
|
||||
"racks.py _UNRACKABLE_TYPES is out of sync with frontend rackable.ts.\n"
|
||||
f"Missing from the backend: {sorted(frontend - _UNRACKABLE_TYPES)}\n"
|
||||
f"Missing from the frontend: {sorted(_UNRACKABLE_TYPES - frontend)}"
|
||||
)
|
||||
@@ -0,0 +1,798 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _design(client: AsyncClient, headers: dict, *, name="Rack Room", design_type="rack") -> str:
|
||||
res = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": name, "icon": "server", "design_type": design_type},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()["id"]
|
||||
|
||||
|
||||
def _state(design_id: str, **overrides) -> dict:
|
||||
"""A minimal but complete rack payload: one rack, two devices, one patch."""
|
||||
payload = {
|
||||
"design_id": design_id,
|
||||
"racks": [
|
||||
{
|
||||
"id": "rack-1",
|
||||
"name": "Main",
|
||||
"u_height": 12,
|
||||
"width_standard": "19",
|
||||
"numbering": "bottom-up",
|
||||
"location": "garage",
|
||||
"style": {"frame": "#1c2129", "showNumbers": True},
|
||||
"pos_x": 40,
|
||||
"pos_y": 60,
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev-sw",
|
||||
"rack_id": "rack-1",
|
||||
"label": "sw-24",
|
||||
"u_start": 10,
|
||||
"u_height": 1,
|
||||
"col_start": 0,
|
||||
"col_span": 12,
|
||||
"faceplate_id": "switch-24",
|
||||
"status": "online",
|
||||
"ports": [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}],
|
||||
},
|
||||
{
|
||||
"id": "dev-nas",
|
||||
"rack_id": "rack-1",
|
||||
"label": "nas",
|
||||
"u_start": 4,
|
||||
"u_height": 2,
|
||||
"col_start": 0,
|
||||
"col_span": 12,
|
||||
"faceplate_id": "nas-2u",
|
||||
"status": "unknown",
|
||||
"ports": [{"id": "p2", "label": "eth0", "type": "rj45", "x": 0.7, "y": 0.5}],
|
||||
},
|
||||
],
|
||||
"cables": [
|
||||
{
|
||||
"id": "cbl-1",
|
||||
"from_device_id": "dev-sw",
|
||||
"from_port_id": "p1",
|
||||
"to_device_id": "dev-nas",
|
||||
"to_port_id": "p2",
|
||||
"type": "ethernet",
|
||||
"color": "#39d353",
|
||||
}
|
||||
],
|
||||
"viewport": {"x": 10, "y": 20, "zoom": 1.5},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class TestDesignType:
|
||||
async def test_creates_a_rack_design(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
res = await client.get("/api/v1/designs", headers=headers)
|
||||
design = next(d for d in res.json() if d["id"] == design_id)
|
||||
assert design["design_type"] == "rack"
|
||||
|
||||
async def test_rejects_an_unknown_design_type(self, client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Nope", "design_type": "spaceship"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
class TestSaveAndLoad:
|
||||
async def test_round_trips_the_full_state(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
res = await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
assert [r["name"] for r in loaded["racks"]] == ["Main"]
|
||||
assert loaded["racks"][0]["u_height"] == 12
|
||||
assert loaded["racks"][0]["style"]["showNumbers"] is True
|
||||
assert {d["id"] for d in loaded["devices"]} == {"dev-sw", "dev-nas"}
|
||||
assert loaded["devices"][0]["ports"][0]["type"] == "rj45"
|
||||
assert loaded["cables"][0]["from_port_id"] == "p1"
|
||||
assert loaded["viewport"] == {"x": 10, "y": 20, "zoom": 1.5}
|
||||
|
||||
async def test_keeps_a_mount_that_follows_its_node_check(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
# "Check device" is stored as `auto` on the mount: the rack has no
|
||||
# checker, it reads the linked node's live status at render time. The
|
||||
# status column is free-form, so this only guards it staying that way.
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id)
|
||||
state["devices"][0]["status"] = "auto"
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
mount = next(d for d in loaded["devices"] if d["id"] == "dev-sw")
|
||||
assert mount["status"] == "auto"
|
||||
|
||||
async def test_prunes_what_the_client_dropped(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
trimmed = _state(design_id)
|
||||
trimmed["devices"] = [d for d in trimmed["devices"] if d["id"] == "dev-sw"]
|
||||
trimmed["cables"] = []
|
||||
await client.post("/api/v1/racks/save", json=trimmed, headers=headers)
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
assert {d["id"] for d in loaded["devices"]} == {"dev-sw"}
|
||||
assert loaded["cables"] == []
|
||||
|
||||
async def test_updates_an_existing_device_in_place(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
moved = _state(design_id)
|
||||
moved["devices"][1]["u_start"] = 1
|
||||
moved["devices"][1]["label"] = "nas-renamed"
|
||||
await client.post("/api/v1/racks/save", json=moved, headers=headers)
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
nas = next(d for d in loaded["devices"] if d["id"] == "dev-nas")
|
||||
assert (nas["u_start"], nas["label"]) == (1, "nas-renamed")
|
||||
|
||||
async def test_keeps_designs_isolated(self, client: AsyncClient, headers):
|
||||
first = await _design(client, headers, name="Rack A")
|
||||
second = await _design(client, headers, name="Rack B")
|
||||
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={second}", headers=headers)).json()
|
||||
assert loaded["racks"] == []
|
||||
assert loaded["devices"] == []
|
||||
|
||||
async def test_refuses_to_steal_a_row_from_another_design(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
"""Ids come from the client, so a stale tab or a copy can collide.
|
||||
|
||||
The upsert sets `design_id` from the payload: without a guard, saving
|
||||
design B with an id owned by design A moved A's rack — devices and
|
||||
cables with it — into B, and A silently lost it.
|
||||
"""
|
||||
first = await _design(client, headers, name="Rack A")
|
||||
second = await _design(client, headers, name="Rack B")
|
||||
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
|
||||
|
||||
res = await client.post("/api/v1/racks/save", json=_state(second), headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
kept = (await client.get(f"/api/v1/racks?design_id={first}", headers=headers)).json()
|
||||
assert [r["id"] for r in kept["racks"]] == ["rack-1"]
|
||||
assert len(kept["devices"]) == 2
|
||||
|
||||
async def test_rejects_an_unknown_design(self, client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/racks/save", json=_state("nope"), headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
async def test_rejects_a_device_pointing_outside_the_payload(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
broken = _state(design_id)
|
||||
broken["devices"][0]["rack_id"] = "rack-ghost"
|
||||
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
async def test_rejects_a_cable_pointing_outside_the_payload(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
broken = _state(design_id)
|
||||
broken["cables"][0]["to_device_id"] = "dev-ghost"
|
||||
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
{"u_height": 0},
|
||||
{"width_standard": "23"},
|
||||
{"numbering": "sideways"},
|
||||
],
|
||||
)
|
||||
async def test_rejects_invalid_rack_geometry(self, client: AsyncClient, headers, patch):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["racks"][0].update(patch)
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
{"u_start": 0},
|
||||
{"col_start": 12},
|
||||
{"col_span": 13},
|
||||
],
|
||||
)
|
||||
async def test_rejects_invalid_device_geometry(self, client: AsyncClient, headers, patch):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update(patch)
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_rejects_a_device_taller_than_its_rack(self, client: AsyncClient, headers):
|
||||
# Each field is legal on its own; only the rack says otherwise. A mount
|
||||
# above the top rail draws outside the chassis with no way to drag it back.
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["u_start"] = 40 # rack is 12U
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_rejects_a_device_whose_height_overruns_the_rack(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"u_start": 11, "u_height": 4}) # 11..14 of 12U
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_accepts_a_device_that_ends_on_the_top_rail(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"u_start": 11, "u_height": 2}) # 11..12 of 12U
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
async def test_rejects_a_span_that_overruns_the_column_grid(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"col_start": 11, "col_span": 12})
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_accepts_a_half_width_pair_sharing_one_u(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"col_start": 0, "col_span": 6})
|
||||
payload["devices"][1].update({"col_start": 6, "col_span": 6})
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
async def test_rejects_an_unknown_cable_type(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0]["type"] = "power"
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_round_trips_cable_annotations(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0].update(
|
||||
{
|
||||
"label": "Uplink to core",
|
||||
"label_visible": True,
|
||||
"properties": [
|
||||
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
|
||||
{"key": "VLAN", "value": "20", "icon": None, "visible": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
).status_code == 200
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert cable["label"] == "Uplink to core"
|
||||
assert cable["label_visible"] is True
|
||||
assert [p["key"] for p in cable["properties"]] == ["Length", "VLAN"]
|
||||
assert cable["properties"][1]["visible"] is False
|
||||
|
||||
async def test_defaults_cable_annotations_when_absent(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
# A client that predates the feature sends neither key; the response must
|
||||
# still carry usable values rather than nulls.
|
||||
design_id = await _design(client, headers)
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
).status_code == 200
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert cable["label_visible"] is False
|
||||
assert cable["properties"] == []
|
||||
|
||||
async def test_drops_cable_properties_with_no_key(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0]["properties"] = [
|
||||
{"key": " ", "value": "orphan", "icon": None, "visible": True},
|
||||
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
|
||||
]
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
).status_code == 200
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert [p["key"] for p in cable["properties"]] == ["Length"]
|
||||
|
||||
async def test_updates_cable_annotations_on_a_second_save(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0]["properties"] = [
|
||||
{"key": "Length", "value": "2 m", "icon": None, "visible": True}
|
||||
]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
payload["cables"][0]["properties"] = []
|
||||
payload["cables"][0]["label_visible"] = True
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert cable["properties"] == []
|
||||
assert cable["label_visible"] is True
|
||||
|
||||
async def test_requires_auth(self, client: AsyncClient):
|
||||
assert (await client.get("/api/v1/racks?design_id=any")).status_code == 401
|
||||
|
||||
|
||||
class TestInventory:
|
||||
async def test_lists_rackable_devices_only(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pve-01", "suggested_type": "proxmox"},
|
||||
headers=headers,
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "phone", "suggested_type": "mobile"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
labels = [i["label"] for i in res.json()["items"]]
|
||||
assert "pve-01" in labels
|
||||
assert "phone" not in labels
|
||||
|
||||
async def test_names_a_device_the_way_the_inventory_does(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""A scan find with no hostname is named after the app it runs, not its IP.
|
||||
|
||||
The rack picker used to show "192.168.1.63 · 192.168.1.63" where the
|
||||
Device Inventory showed "jellyfin".
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-services",
|
||||
ip="192.168.1.63",
|
||||
services=[
|
||||
{"port": 443, "category": "web", "service_name": "https"},
|
||||
{"port": 8096, "category": "media", "service_name": "jellyfin"},
|
||||
],
|
||||
suggested_type="server",
|
||||
)
|
||||
)
|
||||
db_session.add(InventoryDevice(id="pd-bare", ip="192.168.1.64", suggested_type="server"))
|
||||
# A portless service is no fingerprint: the inventory skips it, so must
|
||||
# this — `None not in _COMMON_PORTS` is True and used to let it through.
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-portless",
|
||||
ip="192.168.1.65",
|
||||
services=[{"port": None, "category": "media", "service_name": "jellyfin"}],
|
||||
suggested_type="server",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
assert next(i for i in items if i["id"] == "pd-services")["label"] == "jellyfin"
|
||||
# Nothing better to say: the IP stays the label, and the UI stops doubling it.
|
||||
assert next(i for i in items if i["id"] == "pd-bare")["label"] == "192.168.1.64"
|
||||
assert next(i for i in items if i["id"] == "pd-portless")["label"] == "192.168.1.65"
|
||||
|
||||
async def test_flags_devices_already_mounted(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "sw-24", "suggested_type": "switch"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = created["id"]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
assert next(i for i in items if i["id"] == created["id"])["racked"] is True
|
||||
|
||||
async def test_resolves_the_canvas_node_for_live_status(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
node = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "nas", "ip": "192.168.1.9", "status": "online"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "nas", "ip": "192.168.1.9", "suggested_type": "nas"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["label"] == "nas")
|
||||
assert entry["node_id"] == node["id"]
|
||||
assert entry["node_status"] == "online"
|
||||
|
||||
async def test_reports_what_the_canvas_node_knows(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The rack prints the logical view's facts, so the endpoint must ship them."""
|
||||
rack_design = await _design(client, headers)
|
||||
network = await _design(client, headers, name="Network", design_type="network")
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "nas",
|
||||
"label": "nas-truenas",
|
||||
"design_id": network,
|
||||
"ip": "192.168.1.9",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"hostname": "nas.lan",
|
||||
"os": "TrueNAS SCALE",
|
||||
"status": "online",
|
||||
"check_method": "http",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
# Straight to the table: the manual-create schema carries no os/services,
|
||||
# and a scan find does.
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-nas",
|
||||
hostname="nas",
|
||||
ip="192.168.1.9",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
suggested_type="nas",
|
||||
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["ip"] == "192.168.1.9")
|
||||
assert entry["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
assert entry["os"] == "TrueNAS"
|
||||
assert entry["services"] == [{"port": 445, "name": "smb"}]
|
||||
assert entry["node_label"] == "nas-truenas"
|
||||
assert entry["node_type"] == "nas"
|
||||
assert entry["node_hostname"] == "nas.lan"
|
||||
assert entry["node_os"] == "TrueNAS SCALE"
|
||||
assert entry["node_check_method"] == "http"
|
||||
assert entry["node_design_id"] == network
|
||||
assert entry["node_design_name"] == "Network"
|
||||
|
||||
async def test_stamps_last_seen_with_an_offset(self, client: AsyncClient, headers, db_session):
|
||||
"""SQLite reads `last_seen` back naive, and a naive ISO string is *local*
|
||||
time to `new Date()` — the panel printed it shifted by the viewer's offset.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
node = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "seen", "ip": "192.168.1.77"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
db_session.add(InventoryDevice(id="pd-seen", ip="192.168.1.77", suggested_type="server"))
|
||||
await db_session.execute(
|
||||
text("UPDATE nodes SET last_seen = :ts WHERE id = :id"),
|
||||
{"ts": "2026-08-08 10:00:00.000000", "id": node["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == "pd-seen")
|
||||
assert entry["node_last_seen"].endswith(("Z", "+00:00"))
|
||||
|
||||
async def test_prefers_the_node_the_mount_names(self, client: AsyncClient, headers):
|
||||
"""A link the user picked in the rack beats the IEEE/IP guess.
|
||||
|
||||
The guess is what failed on gear with no MAC and no address, and it is
|
||||
wrong — not merely absent — when two hosts have swapped IPs.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
picked = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "pdu", "label": "pdu-main", "status": "online"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
guessed = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "someone-else", "ip": "192.168.1.90"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
device = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pdu", "ip": "192.168.1.90", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = device["id"]
|
||||
payload["devices"][0]["node_id"] = picked["id"]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == device["id"])
|
||||
assert entry["node_id"] == picked["id"] != guessed["id"]
|
||||
assert entry["node_label"] == "pdu-main"
|
||||
assert entry["node_status"] == "online"
|
||||
|
||||
async def test_falls_back_when_the_named_node_is_gone(self, client: AsyncClient, headers):
|
||||
"""A mount keeps its `node_id` in the payload the client last sent, so a
|
||||
node deleted meanwhile must not report a node that no longer exists.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
guessed = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "nas", "ip": "192.168.1.91"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
device = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "nas", "ip": "192.168.1.91", "suggested_type": "nas"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = device["id"]
|
||||
payload["devices"][0]["node_id"] = "node-that-was-deleted"
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == device["id"])
|
||||
assert entry["node_id"] == guessed["id"]
|
||||
|
||||
async def test_leaves_the_node_fields_empty_without_a_match(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pdu-main", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["label"] == "pdu-main")
|
||||
assert entry["node_id"] is None
|
||||
assert entry["node_label"] is None
|
||||
assert entry["node_design_name"] is None
|
||||
assert entry["services"] == []
|
||||
|
||||
async def test_falls_back_to_the_node_services(self, client: AsyncClient, headers, db_session):
|
||||
"""An approved device keeps its fingerprint on the node, not the inventory row."""
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "server",
|
||||
"label": "media",
|
||||
"ip": "192.168.1.63",
|
||||
"services": [{"port": 8096, "service_name": "jellyfin", "category": "media"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
db_session.add(
|
||||
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == "pd-noservices")
|
||||
assert entry["services"] == [{"port": 8096, "name": "jellyfin"}]
|
||||
|
||||
async def test_manual_entry_is_tagged_as_such(self, client: AsyncClient, headers):
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "patch-panel", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
assert created["discovery_source"] == "manual"
|
||||
assert created["status"] == "pending"
|
||||
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert any(d["id"] == created["id"] for d in listed)
|
||||
|
||||
|
||||
class TestRackSourcedInventory:
|
||||
"""Gear created from a rack canvas shares the Device Inventory, but never a
|
||||
logical canvas: it documents a mount, not a host."""
|
||||
|
||||
async def _rack_device(self, client: AsyncClient, headers, hostname: str = "patch-house"):
|
||||
return (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": hostname, "discovery_source": "rack"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
async def test_records_the_rack_source(self, client: AsyncClient, headers):
|
||||
created = await self._rack_device(client, headers)
|
||||
assert created["discovery_source"] == "rack"
|
||||
assert created["discovery_sources"] == ["rack"]
|
||||
|
||||
async def test_rejects_an_unknown_source(self, client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "spoof", "discovery_source": "zigbee"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_approve_refuses_rack_gear(self, client: AsyncClient, headers):
|
||||
created = await self._rack_device(client, headers)
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{created['id']}/approve",
|
||||
json={"type": "generic", "label": "patch-house"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
assert "rack" in res.json()["detail"].lower()
|
||||
|
||||
async def test_bulk_approve_skips_rack_gear(self, client: AsyncClient, headers):
|
||||
rack_device = await self._rack_device(client, headers)
|
||||
scanned = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "nuc", "ip": "192.168.1.77", "suggested_type": "server"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [rack_device["id"], scanned["id"]]},
|
||||
headers=headers,
|
||||
)
|
||||
body = res.json()
|
||||
assert body["approved"] == 1
|
||||
assert body["device_ids"] == [scanned["id"]]
|
||||
skipped = next(s for s in body["skipped_devices"] if s["device_id"] == rack_device["id"])
|
||||
assert skipped["match"] == "rack"
|
||||
|
||||
async def test_rack_gear_is_offered_to_the_rack_inventory(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
created = await self._rack_device(client, headers, hostname="blank-panel")
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
assert any(i["id"] == created["id"] for i in items)
|
||||
|
||||
|
||||
class TestDesignLifecycle:
|
||||
async def test_deleting_a_design_removes_its_rack_rows(self, client: AsyncClient, headers):
|
||||
keeper = await _design(client, headers, name="Keeper", design_type="network")
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
assert (await client.delete(f"/api/v1/designs/{design_id}", headers=headers)).status_code == 204
|
||||
assert (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).status_code == 404
|
||||
# The other design is untouched.
|
||||
assert (await client.get(f"/api/v1/racks?design_id={keeper}", headers=headers)).status_code == 200
|
||||
|
||||
async def test_unmounting_keeps_the_inventory_entry(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "sw-24", "suggested_type": "switch"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = created["id"]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
# Unmount: the device disappears from the rack payload entirely.
|
||||
unmounted = _state(design_id)
|
||||
unmounted["devices"] = [d for d in unmounted["devices"] if d["id"] != "dev-sw"]
|
||||
unmounted["cables"] = []
|
||||
await client.post("/api/v1/racks/save", json=unmounted, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == created["id"])
|
||||
assert entry["racked"] is False
|
||||
|
||||
async def test_copying_a_rack_design_duplicates_its_racks(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
copy = (
|
||||
await client.post(
|
||||
f"/api/v1/designs/{design_id}/copy",
|
||||
json={"name": "Rack Room (copy)", "icon": "server"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
assert copy["design_type"] == "rack"
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={copy['id']}", headers=headers)).json()
|
||||
assert [r["name"] for r in loaded["racks"]] == ["Main"]
|
||||
assert len(loaded["devices"]) == 2
|
||||
assert len(loaded["cables"]) == 1
|
||||
# Fresh ids, so editing the copy cannot touch the original.
|
||||
assert loaded["racks"][0]["id"] != "rack-1"
|
||||
assert {d["id"] for d in loaded["devices"]}.isdisjoint({"dev-sw", "dev-nas"})
|
||||
# Cables still join the copied devices, not the originals.
|
||||
copied_ids = {d["id"] for d in loaded["devices"]}
|
||||
assert loaded["cables"][0]["from_device_id"] in copied_ids
|
||||
assert loaded["cables"][0]["to_device_id"] in copied_ids
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.database import Base
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -588,7 +588,7 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice))
|
||||
result = await session.execute(sa_select(InventoryDevice))
|
||||
devices = result.scalars().all()
|
||||
|
||||
assert any(d.ip == "192.168.1.5" for d in devices)
|
||||
@@ -674,7 +674,7 @@ async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
||||
"""Devices found only by mDNS (not nmap) should appear in device_inventory."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
@@ -691,7 +691,7 @@ async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.80"))
|
||||
device = result.scalar_one_or_none()
|
||||
|
||||
assert device is not None
|
||||
@@ -709,7 +709,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
|
||||
session.add(PendingDevice(
|
||||
session.add(InventoryDevice(
|
||||
id="pve-row", ieee_address="pve-pve1-101", ip=None,
|
||||
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
|
||||
discovery_source="proxmox", discovery_sources=["proxmox"],
|
||||
@@ -727,7 +727,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
|
||||
rows = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert len(rows) == 1 # merged, not duplicated
|
||||
row = rows[0]
|
||||
@@ -756,7 +756,7 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.10"))
|
||||
devices = result.scalars().all()
|
||||
|
||||
assert len(devices) == 1 # not duplicated
|
||||
@@ -787,7 +787,7 @@ async def test_run_scan_keeps_canvas_nodes(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.100"))
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
assert device.status == "pending"
|
||||
@@ -801,7 +801,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
|
||||
hidden = InventoryDevice(ip="192.168.1.55", status="hidden")
|
||||
session.add(hidden)
|
||||
await session.commit()
|
||||
|
||||
@@ -815,7 +815,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(
|
||||
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
|
||||
sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.55", InventoryDevice.status == "pending")
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
@@ -943,7 +943,7 @@ async def test_run_scan_probe_enriches_services(mem_db):
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50"))
|
||||
device = result.scalar_one_or_none()
|
||||
|
||||
assert device is not None
|
||||
|
||||
@@ -167,3 +167,64 @@ def test_save_overrides_omits_mesh_credentials(tmp_path):
|
||||
assert written["zigbee_sync_enabled"] is True
|
||||
assert written["zigbee_sync_interval"] == 900
|
||||
assert written["zwave_sync_interval"] == 1200
|
||||
|
||||
|
||||
def _valid_oidc_settings(**overrides):
|
||||
values = {
|
||||
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "client-secret",
|
||||
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
|
||||
"cors_origins": ["https://homelable.example"],
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_complete_client_configuration():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
|
||||
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_openid_scope():
|
||||
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
|
||||
_valid_oidc_settings(oidc_scopes="profile email")
|
||||
|
||||
|
||||
def test_oidc_mode_requires_strong_session_secret():
|
||||
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
|
||||
_valid_oidc_settings(secret_key="too-short")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_callback():
|
||||
with pytest.raises(ValueError, match="must use HTTPS"):
|
||||
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_discovery():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
|
||||
_valid_oidc_settings(
|
||||
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
|
||||
def test_oidc_insecure_dev_mode_allows_http_urls():
|
||||
configured = _valid_oidc_settings(
|
||||
oidc_cookie_secure=False,
|
||||
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
|
||||
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
|
||||
)
|
||||
assert configured.oidc_cookie_secure is False
|
||||
|
||||
|
||||
def test_oidc_mode_rejects_wildcard_cors():
|
||||
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
|
||||
_valid_oidc_settings(cors_origins=["*"])
|
||||
|
||||
|
||||
def test_valid_oidc_configuration_is_accepted():
|
||||
configured = _valid_oidc_settings()
|
||||
assert configured.auth_mode == "oidc"
|
||||
assert configured.oidc_cookie_secure is True
|
||||
|
||||
@@ -9,7 +9,7 @@ from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -75,9 +75,9 @@ async def test_summary_aggregates_counts(
|
||||
Node(type="server", label="D", status="unknown"),
|
||||
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
||||
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
||||
PendingDevice(ip="10.0.0.1", status="pending"),
|
||||
PendingDevice(ip="10.0.0.2", status="pending"),
|
||||
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||
InventoryDevice(ip="10.0.0.1", status="pending"),
|
||||
InventoryDevice(ip="10.0.0.2", status="pending"),
|
||||
InventoryDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||
ScanRun(status="success", finished_at=finished),
|
||||
ScanRun(status="success",
|
||||
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
||||
|
||||
@@ -338,7 +338,7 @@ async def test_persist_pending_import_coordinator_goes_to_pending(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
assert result.device_count == 3
|
||||
@@ -354,7 +354,7 @@ async def test_persist_pending_import_coordinator_goes_to_pending(
|
||||
# Coordinator sits in the pending inventory.
|
||||
coord = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.status == "pending"
|
||||
@@ -385,14 +385,14 @@ 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
|
||||
from app.db.models import InventoryDeviceLink
|
||||
|
||||
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()
|
||||
rows = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
|
||||
|
||||
@@ -403,7 +403,7 @@ async def test_persist_pending_import_sets_coordinator_pending_fields(db_session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||
nodes_with_meta[0]["vendor"] = "TI"
|
||||
@@ -413,7 +413,7 @@ async def test_persist_pending_import_sets_coordinator_pending_fields(db_session
|
||||
|
||||
coord = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.vendor == "TI"
|
||||
@@ -432,10 +432,10 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# Simulate: router was approved earlier → exists as a canvas Node, but with
|
||||
# no matching pending_devices row (e.g. a legacy auto-placed device).
|
||||
# no matching device_inventory row (e.g. a legacy auto-placed device).
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
@@ -455,7 +455,7 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
||||
# An inventory row is backfilled as "approved" (it is on a canvas).
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
@@ -481,13 +481,13 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
db_session.add(Node(
|
||||
label="router_1", type="zigbee_router", status="online",
|
||||
check_method="none", ieee_address="0xR1", services=[], properties=[],
|
||||
))
|
||||
db_session.add(PendingDevice(
|
||||
db_session.add(InventoryDevice(
|
||||
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
|
||||
device_subtype="Router", status="hidden", discovery_source="zigbee",
|
||||
))
|
||||
@@ -497,7 +497,7 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
|
||||
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "hidden" # stays hidden
|
||||
@@ -509,7 +509,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||
) -> None:
|
||||
"""Regression for #167: approve → delete node → re-import must re-list device.
|
||||
|
||||
When a device was approved (PendingDevice.status="approved") and its canvas
|
||||
When a device was approved (InventoryDevice.status="approved") and its canvas
|
||||
Node was later deleted, the orphaned "approved" row must be reset to
|
||||
"pending" on re-import so it shows up in the Pending list again — instead of
|
||||
being silently swallowed (re-import reports "found" but Pending stays empty).
|
||||
@@ -517,11 +517,11 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
# Simulate prior approve: a PendingDevice marked approved, but NO matching
|
||||
# Simulate prior approve: a InventoryDevice marked approved, but NO matching
|
||||
# Node exists (the user deleted the canvas node afterwards).
|
||||
orphan = PendingDevice(
|
||||
orphan = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
hostname="router_1",
|
||||
@@ -541,7 +541,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||
# No new row created for 0xR1 — the existing one was updated/revived.
|
||||
revived = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert revived.status == "pending"
|
||||
@@ -552,7 +552,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||
# It is now visible to the Pending list (status filter == "pending").
|
||||
listed = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.status == "pending")
|
||||
select(InventoryDevice).where(InventoryDevice.status == "pending")
|
||||
)
|
||||
).scalars().all()
|
||||
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
|
||||
@@ -566,9 +566,9 @@ async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
hidden = PendingDevice(
|
||||
hidden = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
@@ -583,7 +583,7 @@ async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
|
||||
|
||||
still_hidden = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert still_hidden.status == "hidden"
|
||||
@@ -644,9 +644,9 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# Legacy: coordinator auto-placed as a canvas Node, no pending_devices row.
|
||||
# Legacy: coordinator auto-placed as a canvas Node, no device_inventory row.
|
||||
db_session.add(Node(
|
||||
label="Coordinator", type="zigbee_coordinator", status="online",
|
||||
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
|
||||
@@ -669,7 +669,7 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||
# Inventory row backfilled as approved → now visible in the inventory list.
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
|
||||
@@ -294,7 +294,7 @@ async def test_persist_coordinator_goes_to_pending(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
assert result.device_count == 3
|
||||
@@ -308,7 +308,7 @@ async def test_persist_coordinator_goes_to_pending(db_session) -> None:
|
||||
assert nodes == []
|
||||
coord = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.status == "pending"
|
||||
@@ -333,12 +333,12 @@ async def test_persist_replaces_links(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDeviceLink
|
||||
from app.db.models import InventoryDeviceLink
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
||||
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
||||
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
rows = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
|
||||
|
||||
@@ -349,7 +349,7 @@ async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
nodes = [dict(n) for n in _PENDING_NODES]
|
||||
nodes[0]["vendor"] = "Aeotec"
|
||||
@@ -357,7 +357,7 @@ async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
|
||||
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
||||
coord = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.vendor == "Aeotec"
|
||||
@@ -372,7 +372,7 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
approved = Node(
|
||||
label="Wall Plug",
|
||||
@@ -390,7 +390,7 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
|
||||
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
@@ -407,9 +407,9 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
orphan = PendingDevice(
|
||||
orphan = InventoryDevice(
|
||||
ieee_address="zwave-0xh-2",
|
||||
friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router",
|
||||
@@ -423,7 +423,7 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
revived = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert revived.status == "pending"
|
||||
@@ -437,9 +437,9 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
hidden = PendingDevice(
|
||||
hidden = InventoryDevice(
|
||||
ieee_address="zwave-0xh-2",
|
||||
friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router",
|
||||
@@ -453,7 +453,7 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
still_hidden = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert still_hidden.status == "hidden"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 82" width="64" height="82" 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,192 @@
|
||||
# OpenID Connect (OIDC) Authentication
|
||||
|
||||
Homelable can delegate login to your own identity provider (IdP) instead of the
|
||||
built-in username/password. Sign-in uses the standard **Authorization Code flow
|
||||
with PKCE**: the browser is redirected to your IdP, and on return Homelable
|
||||
issues its own short-lived session cookie. Provider access/ID tokens are
|
||||
exchanged server-side and **never** reach the browser.
|
||||
|
||||
Use it when you want single sign-on, per-user accounts managed at the IdP, MFA,
|
||||
or central account revocation — Authentik, Keycloak, Authelia, Zitadel, Google,
|
||||
Microsoft Entra ID, Okta, or any spec-compliant OpenID Connect provider.
|
||||
|
||||
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
|
||||
> in the no-backend standalone/demo build, which has no login at all.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. Homelable exposes its mode at `GET /api/v1/auth/config`. In OIDC mode the
|
||||
login screen shows **Sign in with OpenID Connect**.
|
||||
2. `GET /api/v1/auth/oidc/login` redirects to your IdP with `state`, `nonce`,
|
||||
and a PKCE `code_challenge` (S256).
|
||||
3. The IdP authenticates the user and redirects back to
|
||||
`GET /api/v1/auth/oidc/callback`.
|
||||
4. Homelable validates the response (signature via the IdP's JWKS, issuer,
|
||||
audience, expiry, `nonce`, `state`, PKCE) and mints its **own** session token.
|
||||
5. That token is stored in a `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`
|
||||
cookie. The browser never sees the provider tokens.
|
||||
|
||||
The display name shown in the app is the first present of
|
||||
`preferred_username` → `name` → `email` → `sub`.
|
||||
|
||||
Local Bearer authentication and the MCP service key keep working unchanged, so
|
||||
the API and the MCP server are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A reachable **OpenID Connect** provider with a discovery document
|
||||
(`.well-known/openid-configuration`).
|
||||
2. Homelable served over **HTTPS** in production (required for the secure
|
||||
session cookie).
|
||||
3. A **confidential** OIDC client (client id **and** secret) registered at the
|
||||
IdP, with Homelable's callback URL allow-listed as a redirect URI.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `.env` (see `.env.example`). Switching to OIDC is
|
||||
exclusive — it replaces local login. Existing installs stay on
|
||||
`AUTH_MODE=local` until you change this.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
|
||||
# Pin CORS to the exact browser origin(s). Wildcard is rejected in OIDC mode.
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
|
||||
# From your IdP:
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
|
||||
# Must EXACTLY match a redirect URI registered at the IdP.
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
```
|
||||
|
||||
Then restart the backend (`docker compose restart backend`).
|
||||
|
||||
### Reference
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `AUTH_MODE` | yes | `local` | Set to `oidc` to enable. |
|
||||
| `OIDC_DISCOVERY_URL` | yes | — | The IdP's `.well-known/openid-configuration` URL. |
|
||||
| `OIDC_CLIENT_ID` | yes | — | Confidential client id. |
|
||||
| `OIDC_CLIENT_SECRET` | yes | — | Client secret. Keep it out of Git and Compose YAML. |
|
||||
| `OIDC_REDIRECT_URI` | yes | — | Must equal the registered redirect **exactly**, path included. |
|
||||
| `OIDC_SCOPES` | no | `openid profile email` | Must contain `openid`. |
|
||||
| `OIDC_COOKIE_SECURE` | no | `true` | `true` requires an HTTPS redirect URI and enables the `__Host-` cookie prefix. Set `false` only for local HTTP testing. |
|
||||
| `OIDC_SESSION_EXPIRE_MINUTES` | no | `480` | App session lifetime (5–1440). |
|
||||
| `OIDC_TRANSACTION_EXPIRE_SECONDS` | no | `600` | Lifetime of the short login-flow cookie holding `state`/`nonce`/PKCE (60–3600). |
|
||||
| `SECRET_KEY` | yes | — | Must be **≥ 32 bytes** in OIDC mode. Generate: `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
|
||||
| `CORS_ORIGINS` | yes | — | Exact browser origin(s). `*` is rejected in OIDC mode. |
|
||||
|
||||
### Fail-closed validation
|
||||
|
||||
The backend refuses to start in OIDC mode unless the configuration is complete
|
||||
and safe. It errors if any of the following is true:
|
||||
|
||||
- any of `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
|
||||
`OIDC_REDIRECT_URI` is missing;
|
||||
- `SECRET_KEY` is shorter than 32 bytes;
|
||||
- `OIDC_SCOPES` does not contain `openid`;
|
||||
- `OIDC_DISCOVERY_URL` or `OIDC_REDIRECT_URI` is not an HTTP(S) URL;
|
||||
- `OIDC_COOKIE_SECURE=true` but `OIDC_REDIRECT_URI` is not HTTPS;
|
||||
- `CORS_ORIGINS` contains `*`.
|
||||
|
||||
---
|
||||
|
||||
## Provider setup
|
||||
|
||||
The callback URL to register everywhere is:
|
||||
|
||||
```
|
||||
https://<your-host>/api/v1/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Authentik
|
||||
|
||||
1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
|
||||
2. Client type **Confidential**; add the redirect URI above.
|
||||
3. Note the client id/secret and the discovery URL:
|
||||
`https://authentik.example/application/o/<app-slug>/.well-known/openid-configuration`.
|
||||
4. Create an **Application** bound to that provider.
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. In your realm: **Clients → Create client** (OpenID Connect), **Client
|
||||
authentication ON** (confidential).
|
||||
2. Set **Valid redirect URIs** to the callback URL.
|
||||
3. Discovery URL:
|
||||
`https://kc.example/realms/<realm>/.well-known/openid-configuration`.
|
||||
4. Copy the secret from the **Credentials** tab.
|
||||
|
||||
### Authelia
|
||||
|
||||
1. Add Homelable to the `identity_providers.oidc.clients` list with a
|
||||
`client_secret`, the redirect URI, and scopes `openid profile email`.
|
||||
2. Discovery URL: `https://auth.example/.well-known/openid-configuration`.
|
||||
|
||||
### Google
|
||||
|
||||
1. **Google Cloud Console → APIs & Services → Credentials → OAuth client ID**
|
||||
(type *Web application*).
|
||||
2. Add the callback URL under **Authorized redirect URIs** (Google requires
|
||||
HTTPS).
|
||||
3. Discovery URL: `https://accounts.google.com/.well-known/openid-configuration`.
|
||||
|
||||
Any other spec-compliant provider works the same way — point
|
||||
`OIDC_DISCOVERY_URL` at its discovery document.
|
||||
|
||||
---
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Homelable derives the redirect target from `OIDC_REDIRECT_URI`, so set it to the
|
||||
**public** HTTPS URL your users reach — not the internal container address. Make
|
||||
sure the proxy:
|
||||
|
||||
- terminates TLS and forwards `/api/v1/auth/oidc/*` to the backend;
|
||||
- passes the original `Host` and `X-Forwarded-Proto` headers;
|
||||
- does not strip the `Cookie` / `Set-Cookie` headers on those routes.
|
||||
|
||||
---
|
||||
|
||||
## Logout & sessions
|
||||
|
||||
- **Logout** clears the Homelable session cookie (`POST /api/v1/auth/logout`).
|
||||
- Sessions are stateless JWTs signed with `SECRET_KEY`; there is no server-side
|
||||
revocation, so a session stays valid until `OIDC_SESSION_EXPIRE_MINUTES`
|
||||
elapses. Keep the lifetime modest for sensitive deployments.
|
||||
- Logout is local to Homelable; it does not sign the user out at the IdP.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|---|---|
|
||||
| IdP shows `redirect_uri_mismatch` | `OIDC_REDIRECT_URI` must equal the registered URI **exactly**, including scheme, host, port and the `/api/v1/auth/oidc/callback` path. |
|
||||
| `OIDC authentication failed` (401) after IdP login | Wrong client secret, `openid` missing from scopes, or clock skew breaking `nonce`/`exp` — sync server time (NTP). |
|
||||
| Logged out immediately / cookie not set | `OIDC_COOKIE_SECURE=true` while serving over HTTP. Serve HTTPS, or set `OIDC_COOKIE_SECURE=false` for local testing only. |
|
||||
| `403 CSRF validation failed` on save/actions | The request `Origin` is not in `CORS_ORIGINS`, or a stale tab is missing the CSRF token — reload the page. |
|
||||
| Backend won't start in OIDC mode | Re-read the error: a required `OIDC_*` value is missing, `SECRET_KEY` < 32 bytes, `CORS_ORIGINS` has `*`, or the redirect isn't HTTPS while the cookie is secure. |
|
||||
| Login button doesn't appear | `AUTH_MODE` is still `local`, or the frontend can't reach `/api/v1/auth/config` — check the proxy and `CORS_ORIGINS`. |
|
||||
|
||||
---
|
||||
|
||||
## Reverting to local auth
|
||||
|
||||
Set `AUTH_MODE=local` (or remove it) and restart the backend. The
|
||||
`AUTH_USERNAME` / `AUTH_PASSWORD_HASH` login is restored; the `OIDC_*` values are
|
||||
ignored while in local mode.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Rack Canvas
|
||||
|
||||
A rack canvas is the **physical** counterpart to the network diagram: real racks,
|
||||
the gear mounted in them, and the patch cables between their ports. It is a
|
||||
canvas kind of its own — you create it like any other canvas, and Homelable swaps
|
||||
the renderer when you switch to it.
|
||||
|
||||
Where the diagram answers *what talks to what*, the rack answers *what is where*:
|
||||
which U a machine sits in, what plate it wears, which switch port its uplink
|
||||
lands on.
|
||||
|
||||
> Works with **and** without the backend. In the no-backend standalone/demo build
|
||||
> a rack canvas persists to your browser's local storage; the Device Inventory
|
||||
> picker and the live status check are backend-only (marked 🔒 below).
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **Racks** — any U height, 19" or 10" wide, bottom-up or top-down numbering, per-rack frame / rail / interior colours, open or enclosed.
|
||||
- **Mounted gear** — a device occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U.
|
||||
- **Faceplates** — a visual catalog of plates (servers, switches, routers, patch panels, UPS, PDUs, desktop NAS towers, shelves, blanks, cable managers) drawn as vector artwork that scales with the rack.
|
||||
- **Ports & patching** — RJ45 and SFP ports per device, cabled port-to-port by dragging from one to the other, across racks if needed.
|
||||
- **Device Inventory** 🔒 — a mount points at a real inventory entry, so a rack shows the same devices your scans found, and gear you rack by hand joins that inventory.
|
||||
- **Live status** 🔒 — a mount can follow the status check already configured on the matching diagram node, and light its plate LED accordingly.
|
||||
- **Import links** 🔒 — derive patches from the physical links already drawn on your diagrams, as often as you like.
|
||||
|
||||
---
|
||||
|
||||
## Create a rack canvas
|
||||
|
||||
1. Open the **canvas switcher** at the top of the left sidebar.
|
||||
2. Click **New Canvas**.
|
||||
3. Under **Kind**, pick **Rack** (the other kind, *Diagram*, is the usual node/link canvas).
|
||||
4. Name it, pick an icon, and create.
|
||||
|
||||
A canvas cannot change kind afterwards — a diagram and a rack store different
|
||||
things. Deleting a rack canvas deletes its racks, mounts and cables with it;
|
||||
duplicating a canvas duplicates them too.
|
||||
|
||||
---
|
||||
|
||||
## Add a rack
|
||||
|
||||
**Add Rack** in the header drops a new rack on the canvas. Drag it anywhere; a
|
||||
canvas can hold as many as you like, and cables can run between them.
|
||||
|
||||
**Double-click the rack chrome** (the frame, not a device) to open its settings:
|
||||
|
||||
| Setting | What it does |
|
||||
|---|---|
|
||||
| Name / Location | Free text — "Baie salon", "Garage", … |
|
||||
| U height | How many mountable U the rack has (42 by default). |
|
||||
| Width | 19" or 10" — drives the drawn inner width. |
|
||||
| Numbering | `bottom-up` or `top-down`. **Labels only** — gear never moves. |
|
||||
| Show U numbers | Print the U scale on the rails. |
|
||||
| Enclosed | Draw side panels / a closed chassis. |
|
||||
| Colours | Frame, rails and interior. Defaults come from your app theme. |
|
||||
| Delete | Removes the rack and everything mounted in it. |
|
||||
|
||||
---
|
||||
|
||||
## Mount a device
|
||||
|
||||
Click **+ Device** in the left sidebar, or drag an accessory from the sidebar
|
||||
tray straight onto a rack. The device modal offers three sources:
|
||||
|
||||
- **Device Inventory entry** 🔒 — pick something already discovered. The field
|
||||
opens the real Device Inventory, with its search, source and type filters, and
|
||||
it opens pre-filtered to **rackable** hardware (no light bulbs, no sockets).
|
||||
A device already mounted on this canvas is refused.
|
||||
- **New device** — for hardware no scan can find (a patch panel, a PDU, an
|
||||
unmanaged switch). It is created as a normal Device Inventory entry tagged
|
||||
**Rack devices**, so you can search, filter, hide and delete it like any other.
|
||||
- **Accessory** — rack-only furniture: blanking plate, shelf, cable manager.
|
||||
These are not devices and never appear in the inventory.
|
||||
|
||||
Then set:
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| Label | Shown on the plate. |
|
||||
| Faceplate | Opens the visual catalog — see below. |
|
||||
| U position / height | `U` is counted **from the bottom rail**, whatever the numbering setting prints. |
|
||||
| Column / width | Full, half, third, sixth or quarter of the rack width. |
|
||||
| Status | A fixed colour, or **Check device** 🔒 — see below. |
|
||||
| Colour | Overrides the plate's default tint. |
|
||||
| Ports | The list of RJ45 / SFP ports, seeded by the faceplate and editable afterwards. |
|
||||
|
||||
**Double-click a plate** to reopen this modal on an existing mount; a single
|
||||
click only selects it. **Unmount** takes the device out of the rack — it stays in
|
||||
the Device Inventory.
|
||||
|
||||
### Linked device 🔒
|
||||
|
||||
Under the port list, a mount that stands for a real device prints what is known
|
||||
about that box: name, type, hostname, IP, MAC, OS, the status check it runs, the
|
||||
canvas it is drawn on, when it was last seen, and the services discovery
|
||||
fingerprinted on it.
|
||||
|
||||
It is read-only — edit those facts in the Device Inventory or on the logical
|
||||
canvas, not in the rack — and an accessory shows nothing at all. A device that is
|
||||
on no diagram still prints everything discovery found about it; only the
|
||||
canvas-side lines go missing, and the panel says *Not on a logical canvas.*
|
||||
|
||||
**Link to another device…** points the plate at a different Device Inventory
|
||||
entry — the whole inventory, not just the devices you approved onto a diagram.
|
||||
Use it when you created the gear from the rack (the entry starts empty, with just
|
||||
a name) or when the plate ended up on the wrong twin of two look-alike hosts. The
|
||||
plate takes the entry's IP, MAC, services, status and — unless you renamed it —
|
||||
its name, **Check device** becomes available if that device is on a diagram, and
|
||||
the link is saved with the rest of the canvas. A device already mounted elsewhere
|
||||
in the design is not offered: one entry, one plate. The empty placeholder the
|
||||
rack created for the plate is removed from the inventory once nothing uses it.
|
||||
|
||||
### Placement rules
|
||||
|
||||
- A drop **snaps to the nearest free slot**; a placement that cannot work
|
||||
previews in red.
|
||||
- Drag a mounted device inside its rack to move it — same snapping.
|
||||
- **Growing** a device, by hand or by picking a taller plate, relocates it to the
|
||||
nearest slot that takes the new size. Only a rack with no such slot refuses the
|
||||
edit, and it tells you so.
|
||||
|
||||
---
|
||||
|
||||
## Faceplates
|
||||
|
||||
The **Faceplate** field opens a catalog, not a dropdown of names: every plate is
|
||||
drawn with the real renderer, at its real relative width and U height, grouped by
|
||||
family and searchable.
|
||||
|
||||
| Family | Examples |
|
||||
|---|---|
|
||||
| Servers | 1U, 1U with drive bays, 2U with bays, 4U storage, half-width SFF, third-width mini PC |
|
||||
| Network | 8 / 24 / 48-port switches, 1U router |
|
||||
| Patch | 24-port copper panel, 12-port fibre panel |
|
||||
| Storage | 2U NAS, desktop NAS towers in 2, 4 and 5 bays |
|
||||
| Power | 2U UPS, 1U PDU |
|
||||
| Accessories | Blanking plate, shelf, cable manager |
|
||||
|
||||
Picking a plate **reseeds the ports** it comes with; your own port edits are kept
|
||||
until you change plate again. Plates are drawn as vector artwork in relative
|
||||
coordinates, so they stay correct at any rack width and U height, and their
|
||||
colours follow the active app theme.
|
||||
|
||||
---
|
||||
|
||||
## Ports and patching
|
||||
|
||||
Ports are **RJ45** or **SFP/SFP+**, drawn as real jack artwork at a fixed size so
|
||||
plates of different heights line up. Power outlets are artwork only — they are
|
||||
never a cable endpoint.
|
||||
|
||||
Patch-facing gear (switches, patch panels) shows its ports permanently;
|
||||
everything else reveals them on hover, on selection, or when cables are shown.
|
||||
|
||||
To cable:
|
||||
|
||||
1. Click **Patch** in the header.
|
||||
2. **Drag from one port to another** — a dashed rubber band follows the pointer.
|
||||
Clicking the two ports in turn works just as well. **Escape** drops a
|
||||
half-drawn patch.
|
||||
3. Repeat. One cable per port; copper or fibre follows the port you start from.
|
||||
4. Click **Exit patching** when done — the canvas returns to the cable visibility
|
||||
it had before.
|
||||
|
||||
To unplug: **click the cable** (it gets an accent halo), then press
|
||||
**Delete/Backspace** or click **Unplug** in the header. A stray click never
|
||||
destroys a patch.
|
||||
|
||||
A header select controls what you see: cables **on hover**, **always**, or
|
||||
**hidden**. Cables
|
||||
pan and zoom with the canvas and may run from one rack to another. A cable you
|
||||
have selected stays drawn whatever that select says.
|
||||
|
||||
### Documenting a cable
|
||||
|
||||
**Click a cable** — patching or not — and a panel opens on the right:
|
||||
|
||||
| Field | What it does |
|
||||
|---|---|
|
||||
| Endpoints | The two devices and ports the run connects. Read-only: move a patch by unplugging it and drawing it again. |
|
||||
| Type | Ethernet or fibre. A cable still wearing its type's default colour is recoloured to match; a colour you picked yourself is kept. |
|
||||
| Colour | The sheath colours you actually have on the shelf, plus a free hex field for anything else. |
|
||||
| Label | A name for the run — "Uplink to core", a patch-panel reference. **Show on canvas** prints it next to the cable. |
|
||||
| Properties | Anything else worth recording: length, VLAN, speed, category, patch reference. Same editor as a node's properties on a diagram — a label, a value, an optional icon, and an eye that decides whether it shows on the canvas. |
|
||||
| Unplug cable | Removes the patch. The devices and their ports stay. |
|
||||
|
||||
What you tick as visible is drawn on a small plate at the middle of the run, so a
|
||||
rack photo exports with its cable lengths and VLANs on it. Everything else stays
|
||||
in the panel, one click away.
|
||||
|
||||
### Import links 🔒
|
||||
|
||||
**Import links** derives patches from the physical links (ethernet, fibre, vlan,
|
||||
cluster) already drawn on your diagrams, matching both ends to mounts that point
|
||||
at those nodes. Run it as often as you like: a pair of devices already cabled
|
||||
is left alone, so a second run after racking more gear only adds what is
|
||||
missing.
|
||||
|
||||
---
|
||||
|
||||
## Status 🔒
|
||||
|
||||
A mount's **Status** is either pinned by hand (online / offline / unknown) or set
|
||||
to **Check device**: the plate then follows the status check already configured
|
||||
on the matching diagram node (ping, HTTP, SSH, …) and its LED lights accordingly.
|
||||
|
||||
The rack runs no checker of its own — it reads the result of the one your diagram
|
||||
already performs. So:
|
||||
|
||||
- **Check device** is only offered when the mount resolves to a diagram node.
|
||||
- Losing that link drops the mount back to `unknown`, rather than leaving it on a
|
||||
status nothing answers for.
|
||||
- The refresh polls every 60 s, and only while at least one mount is on
|
||||
**Check device**.
|
||||
|
||||
---
|
||||
|
||||
## Saving
|
||||
|
||||
Like the diagram canvas, a rack canvas is **saved explicitly** — **Save Rack** in
|
||||
the left sidebar, or the header's save button. Nothing is written behind your
|
||||
back unless you turn on **Autosave** in Settings.
|
||||
|
||||
The sidebar footer counts racks, mounts, cables and free U instead of the
|
||||
diagram's online/offline tally. **PNG export** works here too.
|
||||
|
||||
---
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Front view only** — no rear view, no half-depth pairing.
|
||||
- No 0U side-mounted PDU.
|
||||
- No power draw or outlet budgeting.
|
||||
- No undo/redo on a rack canvas (the diagram canvas keeps its own).
|
||||
- Rack canvases are not shown by the read-only [Live View](../README.md#live-view-read-only-public-canvas).
|
||||
- The MCP server exposes diagram nodes and links only, not racks.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [FEATURES.md](../FEATURES.md) — every feature, one page.
|
||||
- [Device Inventory](../FEATURES.md#12-device-inventory-) — where racked devices live.
|
||||
- `frontend/src/rack/README.md` — the developer reference for this canvas.
|
||||
Generated
+516
-122
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,11 +27,10 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"html-to-image": "^1.11.13",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"shadcn": "^4.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -60,6 +59,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
|
||||
+272
-73
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
@@ -32,7 +32,7 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
|
||||
import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||
import { DeviceInventoryModal } from '@/components/modals/DeviceInventoryModal'
|
||||
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||
@@ -45,8 +45,17 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
|
||||
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
|
||||
import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
|
||||
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
|
||||
import { DEMO_SCAN_RUNS, DEMO_INVENTORY_DEVICES } from '@/walkthrough/demoTourData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
||||
import { bootstrapAuth } from '@/auth/bootstrap'
|
||||
import { RackCanvas } from '@/rack/components/RackCanvas'
|
||||
import { RackCablePanel } from '@/rack/components/RackCablePanel'
|
||||
import { useRackStore } from '@/rack/store'
|
||||
import type { NodeData, EdgeData, CustomStyleDef, DesignType, FloorMapConfig, NodeType } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||
import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types'
|
||||
@@ -57,12 +66,29 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
export default function App() {
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { isAuthenticated, isInitialized } = useAuthStore()
|
||||
const authBootstrapStarted = useRef(false)
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||
const { activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore()
|
||||
const isRackDesign = activeDesignType === 'rack'
|
||||
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
|
||||
const rackEditSeq = useRackStore((s) => s.editSeq)
|
||||
|
||||
/** Kind of a design by id, read straight from the store (no stale closure). */
|
||||
const designTypeOf = useCallback(
|
||||
(id: string | null | undefined): DesignType =>
|
||||
useDesignStore.getState().designs.find((d) => d.id === id)?.design_type ?? 'network',
|
||||
[],
|
||||
)
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
useEffect(() => {
|
||||
if (STANDALONE || authBootstrapStarted.current) return
|
||||
authBootstrapStarted.current = true
|
||||
void bootstrapAuth()
|
||||
}, [])
|
||||
|
||||
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||
|
||||
@@ -74,18 +100,30 @@ export default function App() {
|
||||
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
|
||||
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
|
||||
|
||||
// True while the last canvas load failed (backend down/error). Drives the error
|
||||
// banner and keeps us from masking a real failure with the demo canvas.
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
// True when the loaded canvas is the demo (a brand-new user). Reserved as the
|
||||
// entry signal for the upcoming Getting Started walkthrough.
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
|
||||
// Getting Started tour: when true, the corresponding modal renders injected demo
|
||||
// data instead of hitting the backend.
|
||||
const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
|
||||
const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||
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 [inventoryModalOpen, setInventoryModalOpen] = useState(false)
|
||||
const [inventoryModalStatus, setInventoryModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||
const [inventoryHighlightId, setInventoryHighlightId] = useState<string | undefined>(undefined)
|
||||
const openInventoryModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
|
||||
setInventoryHighlightId(undefined)
|
||||
setInventoryModalStatus(status)
|
||||
setInventoryModalOpen(true)
|
||||
if (deviceId) setTimeout(() => setInventoryHighlightId(deviceId), 0)
|
||||
}, [])
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
@@ -109,6 +147,18 @@ export default function App() {
|
||||
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
|
||||
try {
|
||||
const saveDesignId = designIdOverride ?? activeDesignId
|
||||
// Rack canvases own their own state and persistence path.
|
||||
if (designTypeOf(saveDesignId) === 'rack') {
|
||||
// Named explicitly: the design-switch flow saves the *old* design, and
|
||||
// the store refuses rather than writing under whichever one it holds.
|
||||
const ok = await useRackStore.getState().save(saveDesignId)
|
||||
if (ok) {
|
||||
if (!options?.silent) toast.success('Rack canvas saved')
|
||||
} else {
|
||||
toast.error('Save failed')
|
||||
}
|
||||
return ok
|
||||
}
|
||||
if (STANDALONE) {
|
||||
if (!saveDesignId) return false
|
||||
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
||||
@@ -129,7 +179,7 @@ export default function App() {
|
||||
toast.error('Save failed')
|
||||
return false
|
||||
}
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap])
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap, designTypeOf])
|
||||
|
||||
// Keep a ref so the keydown handler always calls the latest version
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
@@ -140,70 +190,116 @@ export default function App() {
|
||||
useAutosave({
|
||||
enabled: autosave.enabled,
|
||||
delaySeconds: autosave.delay,
|
||||
hasUnsavedChanges,
|
||||
hasUnsavedChanges: isRackDesign ? rackDirty : hasUnsavedChanges,
|
||||
designId: loadedDesignId,
|
||||
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
|
||||
// identity — live status polling churns those arrays without a user edit and
|
||||
// would otherwise keep re-arming (and starving) the timer during monitoring.
|
||||
changeSignals: [editSeq],
|
||||
changeSignals: [isRackDesign ? rackEditSeq : editSeq],
|
||||
getLiveDesignId: () => loadedDesignIdRef.current,
|
||||
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
|
||||
})
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
let res
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
||||
// Clear when the target design has no floor plan, so it doesn't bleed
|
||||
// across canvases when switching designs.
|
||||
setFloorMap(savedFloorMap ?? null)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
res = await canvasApi.load(designId)
|
||||
} catch {
|
||||
// Backend down / errored. Surface it and STOP — never fall back to the demo
|
||||
// canvas, which would hide the real error and look like a fresh account.
|
||||
// Leave the on-screen canvas and provenance untouched so an autosave can't
|
||||
// clobber real data with an empty canvas.
|
||||
setLoadError(true)
|
||||
toast.error('Could not load canvas — backend not responding')
|
||||
return
|
||||
}
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true)
|
||||
if (mode === 'real') {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
||||
// Clear when the target design has no floor plan, so it doesn't bleed
|
||||
// across canvases when switching designs.
|
||||
setFloorMap(savedFloorMap ?? null)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else if (mode === 'empty') {
|
||||
// Initialized but no nodes: the user cleared this canvas on purpose — respect
|
||||
// it and keep it empty instead of re-seeding the demo.
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
setFloorMap(null)
|
||||
loadCanvas([], [])
|
||||
} else {
|
||||
// Brand-new, never-saved canvas: seed the demo.
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
} finally {
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId ?? null)
|
||||
}
|
||||
setLoadError(false)
|
||||
setIsNewUser(isNewUserCanvas(mode))
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId ?? null)
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
||||
// localStorage, falling back to the demo canvas when it has never been saved.
|
||||
const loadStandaloneCanvas = useCallback((designId: string) => {
|
||||
const saved = standaloneStorage.loadCanvas(designId)
|
||||
if (saved && saved.nodes.length > 0) {
|
||||
// A stored entry (even with zero nodes) means the user has saved this canvas,
|
||||
// so treat it as initialized and don't re-seed the demo on top of a canvas
|
||||
// they deliberately cleared.
|
||||
const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null)
|
||||
if (mode === 'real' && saved) {
|
||||
if (saved.theme_id) setTheme(saved.theme_id)
|
||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||
// Floor plans are backend-only; keep the store clear in standalone mode.
|
||||
setFloorMap(null)
|
||||
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
|
||||
loadCanvas(migrated.nodes, migrated.edges)
|
||||
} else if (mode === 'empty' && saved) {
|
||||
if (saved.theme_id) setTheme(saved.theme_id)
|
||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||
setFloorMap(null)
|
||||
loadCanvas([], [])
|
||||
} else {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
setLoadError(false)
|
||||
setIsNewUser(isNewUserCanvas(mode))
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId)
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
/**
|
||||
* Load whichever canvas the design holds. Rack designs bypass the node/edge
|
||||
* canvas entirely — different store, different endpoints.
|
||||
*/
|
||||
const loadAnyDesign = useCallback(async (designId: string) => {
|
||||
if (designTypeOf(designId) !== 'rack') {
|
||||
if (STANDALONE) loadStandaloneCanvas(designId)
|
||||
else await loadCanvasFromApi(designId)
|
||||
return
|
||||
}
|
||||
// The rack store owns the fetch; App only records provenance afterwards, so
|
||||
// none of these run synchronously inside the calling effect.
|
||||
await useRackStore.getState().loadDesign(designId)
|
||||
setIsNewUser(false)
|
||||
setLoadError(false)
|
||||
setLoadedDesignId(designId)
|
||||
}, [designTypeOf, loadStandaloneCanvas, loadCanvasFromApi])
|
||||
|
||||
const loadDesignsAndCanvas = useCallback(async () => {
|
||||
// Prefer a design id explicitly requested via the URL (?design=<id>), so a
|
||||
// refresh or shared link opens that design. Ignore it when it doesn't match
|
||||
@@ -216,7 +312,7 @@ export default function App() {
|
||||
const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id
|
||||
if (targetId) {
|
||||
setActiveDesign(targetId)
|
||||
loadStandaloneCanvas(targetId)
|
||||
void loadAnyDesign(targetId)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -228,19 +324,76 @@ export default function App() {
|
||||
const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id
|
||||
if (targetId) {
|
||||
setActiveDesign(targetId)
|
||||
await loadCanvasFromApi(targetId)
|
||||
await loadAnyDesign(targetId)
|
||||
}
|
||||
} catch {
|
||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
|
||||
// hide a real outage behind a fake "new account" canvas.
|
||||
setLoadError(true)
|
||||
toast.error('Could not reach backend — check the server and retry')
|
||||
}
|
||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
|
||||
}, [setDesigns, setActiveDesign, loadAnyDesign, activeDesignId])
|
||||
|
||||
// Keep a ref so the auth effect can call the latest loader without listing it
|
||||
// as a dependency (which would re-fire on every design switch).
|
||||
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
|
||||
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
|
||||
|
||||
// Retry a failed load from the error banner.
|
||||
const handleRetryLoad = useCallback(() => {
|
||||
setLoadError(false)
|
||||
void loadDesignsAndCanvasRef.current()
|
||||
}, [])
|
||||
|
||||
// Bridge the Getting Started tour steps to the App's modal controls. The overlay
|
||||
// calls closeAll() before each step, then the step's action to open the target.
|
||||
const walkthroughActions = useMemo<WalkthroughActionApi>(() => ({
|
||||
closeAll: () => {
|
||||
setScanConfigOpen(false)
|
||||
setScanHistoryOpen(false)
|
||||
setTourScanHistoryDemo(false)
|
||||
setInventoryModalOpen(false)
|
||||
setTourInventoryDemo(false)
|
||||
setEditNodeId(null)
|
||||
setThemeModalOpen(false)
|
||||
setZigbeeImportOpen(false)
|
||||
// Clear any tour-driven multi-selection so the DetailPanel closes.
|
||||
useCanvasStore.setState((s) => ({
|
||||
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
|
||||
selectedNodeIds: [],
|
||||
selectedNodeId: null,
|
||||
}))
|
||||
},
|
||||
openScanConfig: () => setScanConfigOpen(true),
|
||||
openScanHistoryDemo: () => {
|
||||
setTourScanHistoryDemo(true)
|
||||
setScanHistoryOpen(true)
|
||||
},
|
||||
openInventoryDemo: () => {
|
||||
setTourInventoryDemo(true)
|
||||
openInventoryModal(undefined, 'pending')
|
||||
},
|
||||
editFirstNode: () => {
|
||||
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
if (first) setEditNodeId(first.id)
|
||||
},
|
||||
selectTwoNodes: () => {
|
||||
const ids = useCanvasStore.getState().nodes
|
||||
.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
.slice(0, 2)
|
||||
.map((n) => n.id)
|
||||
if (ids.length < 2) return
|
||||
const set = new Set(ids)
|
||||
useCanvasStore.setState((s) => ({
|
||||
nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
|
||||
selectedNodeIds: ids,
|
||||
selectedNodeId: null,
|
||||
}))
|
||||
},
|
||||
openStyle: () => setThemeModalOpen(true),
|
||||
openZigbeeImport: () => setZigbeeImportOpen(true),
|
||||
}), [openInventoryModal])
|
||||
|
||||
// Load designs + canvas on auth (or immediately in standalone mode, which has
|
||||
// no auth gate).
|
||||
useEffect(() => {
|
||||
@@ -266,7 +419,7 @@ export default function App() {
|
||||
}
|
||||
// Standalone has no auth gate; backed mode requires authentication.
|
||||
const ready = STANDALONE || isAuthenticated
|
||||
const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi
|
||||
const loadForDesign = loadAnyDesign
|
||||
if (ready && activeDesignId && initialLoadDone.current) {
|
||||
const oldId = prevDesignRef.current
|
||||
// If the previous design was deleted (no longer in the list), don't try to
|
||||
@@ -280,7 +433,7 @@ export default function App() {
|
||||
const targetId = activeDesignId
|
||||
handleSave(oldId).then((ok) => {
|
||||
if (ok) {
|
||||
loadForDesign(targetId)
|
||||
void loadForDesign(targetId)
|
||||
} else {
|
||||
// Save failed: don't load the new design — that would overwrite the
|
||||
// unsaved in-memory canvas. Revert the selection back to the old
|
||||
@@ -291,7 +444,12 @@ export default function App() {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
loadForDesign(activeDesignId)
|
||||
// Loading a design is exactly the "synchronize with an external system"
|
||||
// case: it fetches and then publishes the result into React state. The
|
||||
// rule can't see through the loader, so silence it here rather than
|
||||
// deferring the fetch by a tick.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadForDesign(activeDesignId)
|
||||
}
|
||||
}
|
||||
if (activeDesignId) {
|
||||
@@ -820,12 +978,22 @@ export default function App() {
|
||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||
|
||||
if (!STANDALONE && !isInitialized) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-sm text-muted-foreground">
|
||||
Loading authentication…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
||||
|
||||
return (
|
||||
<WalkthroughActionsProvider value={walkthroughActions}>
|
||||
<TooltipProvider>
|
||||
<ReactFlowProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
||||
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
|
||||
Getting Started walkthrough keys off. */}
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
|
||||
<Sidebar
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||
@@ -837,9 +1005,24 @@ export default function App() {
|
||||
onSave={handleSave}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||
onOpenPending={openPendingModal}
|
||||
onOpenInventory={openInventoryModal}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{loadError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
|
||||
>
|
||||
<span>Backend not responding — the canvas could not be loaded.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetryLoad}
|
||||
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Toolbar
|
||||
onSave={handleSave}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
@@ -855,17 +1038,28 @@ export default function App() {
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
<CanvasContainer
|
||||
onConnect={handleEdgeConnect}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onRequestAddToGroup={setPendingGroupAdd}
|
||||
onRequestAddToContainer={setPendingContainerAdd}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
/>
|
||||
{isRackDesign ? (
|
||||
<RackCanvas />
|
||||
) : (
|
||||
<CanvasContainer
|
||||
onConnect={handleEdgeConnect}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onRequestAddToGroup={setPendingGroupAdd}
|
||||
onRequestAddToContainer={setPendingContainerAdd}
|
||||
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||
{/* Rack designs keep the full width for mounted gear — a mount is
|
||||
edited in its own modal — but a selected cable has no plate to
|
||||
double-click, so it gets the rail. */}
|
||||
{isRackDesign
|
||||
? <RackCablePanel />
|
||||
: (selectedNodeId || selectedNodeIds.length > 1) && (
|
||||
<DetailPanel onEdit={handleEditNode} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -943,7 +1137,7 @@ export default function App() {
|
||||
open={zigbeeImportOpen}
|
||||
onClose={() => setZigbeeImportOpen(false)}
|
||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
onInventoryImported={() => {
|
||||
toast.success('Zigbee import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
@@ -954,7 +1148,7 @@ export default function App() {
|
||||
open={zwaveImportOpen}
|
||||
onClose={() => setZwaveImportOpen(false)}
|
||||
onAddToCanvas={handleZwaveAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
onInventoryImported={() => {
|
||||
toast.success('Z-Wave import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
@@ -965,7 +1159,7 @@ export default function App() {
|
||||
open={proxmoxImportOpen}
|
||||
onClose={() => setProxmoxImportOpen(false)}
|
||||
onAddToCanvas={handleProxmoxAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
onInventoryImported={() => {
|
||||
toast.success('Proxmox import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
@@ -975,6 +1169,7 @@ export default function App() {
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
onClose={() => setScanHistoryOpen(false)}
|
||||
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1063,7 +1258,7 @@ export default function App() {
|
||||
<SearchModal
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
|
||||
/>
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
@@ -1094,11 +1289,12 @@ export default function App() {
|
||||
but canvas prefs (snap, hide-IP) still apply. */}
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
<PendingDevicesModal
|
||||
open={pendingModalOpen}
|
||||
onClose={() => setPendingModalOpen(false)}
|
||||
highlightId={pendingHighlightId}
|
||||
initialStatus={pendingModalStatus}
|
||||
<DeviceInventoryModal
|
||||
open={inventoryModalOpen}
|
||||
onClose={() => setInventoryModalOpen(false)}
|
||||
highlightId={inventoryHighlightId}
|
||||
initialStatus={inventoryModalStatus}
|
||||
demoDevices={tourInventoryDemo ? DEMO_INVENTORY_DEVICES : undefined}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
@@ -1108,7 +1304,10 @@ export default function App() {
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
<WalkthroughInvite />
|
||||
<WalkthroughOverlay />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
</WalkthroughActionsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ type Interceptor<T> = {
|
||||
}
|
||||
|
||||
interface MockInstance {
|
||||
defaults: { baseURL?: string }
|
||||
defaults: { baseURL?: string; withCredentials?: boolean }
|
||||
interceptors: {
|
||||
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
@@ -25,9 +25,9 @@ const instances = hoisted.instances as MockInstance[]
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
create: (cfg: { baseURL?: string }) => {
|
||||
create: (cfg: { baseURL?: string; withCredentials?: boolean }) => {
|
||||
const inst: MockInstance = {
|
||||
defaults: { baseURL: cfg?.baseURL },
|
||||
defaults: { baseURL: cfg?.baseURL, withCredentials: cfg?.withCredentials },
|
||||
interceptors: {
|
||||
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
||||
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
||||
@@ -54,7 +54,7 @@ describe('api/client', () => {
|
||||
const [api, publicApi] = instances
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
useAuthStore.setState({ token: null, csrfToken: null, isAuthenticated: false })
|
||||
api.get.mockClear()
|
||||
api.post.mockClear()
|
||||
api.patch.mockClear()
|
||||
@@ -66,6 +66,7 @@ describe('api/client', () => {
|
||||
it('creates two axios instances with /api/v1 baseURL', () => {
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(api.defaults.baseURL).toBe('/api/v1')
|
||||
expect(api.defaults.withCredentials).toBe(true)
|
||||
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||
})
|
||||
|
||||
@@ -86,6 +87,18 @@ describe('api/client', () => {
|
||||
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds the in-memory OIDC CSRF token only to unsafe requests', () => {
|
||||
useAuthStore.setState({ csrfToken: 'csrf-123' })
|
||||
const post = { method: 'post', headers: {} as Record<string, string> }
|
||||
const get = { method: 'get', headers: {} as Record<string, string> }
|
||||
|
||||
api.__req.fulfilled!(post)
|
||||
api.__req.fulfilled!(get)
|
||||
|
||||
expect(post.headers['X-Homelable-CSRF']).toBe('csrf-123')
|
||||
expect(get.headers['X-Homelable-CSRF']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('response interceptor passes through 2xx responses', () => {
|
||||
const r = { status: 200, data: { ok: true } }
|
||||
expect(api.__res.fulfilled!(r)).toBe(r)
|
||||
@@ -125,6 +138,15 @@ describe('api/client', () => {
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
||||
})
|
||||
|
||||
it('authApi discovers auth publicly and manages the current session', () => {
|
||||
mod.authApi.config()
|
||||
expect(publicApi.get).toHaveBeenCalledWith('/auth/config')
|
||||
mod.authApi.me()
|
||||
expect(api.get).toHaveBeenCalledWith('/auth/me')
|
||||
mod.authApi.logout()
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('canvasApi.load GETs /canvas', () => {
|
||||
mod.canvasApi.load()
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthMode, AuthUser } from '@/stores/authStore'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
|
||||
const publicApi = axios.create({ baseURL: '/api/v1' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
const { token, csrfToken } = useAuthStore.getState()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
const method = config.method?.toUpperCase() ?? 'GET'
|
||||
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
config.headers['X-Homelable-CSRF'] = csrfToken
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -23,8 +29,11 @@ api.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
config: () => publicApi.get<{ mode: AuthMode; oidc_login_url: string | null }>('/auth/config'),
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ access_token: string }>('/auth/login', { username, password }),
|
||||
me: () => api.get<AuthUser>('/auth/me'),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
}
|
||||
|
||||
export const canvasApi = {
|
||||
@@ -52,7 +61,29 @@ export const mediaApi = {
|
||||
delete: (filename: string) => api.delete(`/media/${filename}`),
|
||||
}
|
||||
|
||||
/**
|
||||
* A canvas node as `GET /nodes` reports it — the fields a reader needs to tell
|
||||
* two hosts apart. The canvas itself keeps its own richer React Flow shape; this
|
||||
* is only for pickers and lookups.
|
||||
*/
|
||||
export interface ApiCanvasNode {
|
||||
id: string
|
||||
design_id: string | null
|
||||
label: string
|
||||
type: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
status: string
|
||||
check_method: string | null
|
||||
last_seen: string | null
|
||||
}
|
||||
|
||||
export const nodesApi = {
|
||||
/** Every node, across every design. `label` filters case-insensitively. */
|
||||
list: (label?: string) =>
|
||||
api.get<ApiCanvasNode[]>('/nodes', { params: label ? { label } : {} }),
|
||||
create: (data: object) => api.post('/nodes', data),
|
||||
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/nodes/${id}`),
|
||||
@@ -97,12 +128,33 @@ export interface DuplicateNodeConflict {
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The Device Inventory rides the scan routes.
|
||||
*
|
||||
* `/scan/pending/*` is what the table was called when it was a queue of finds
|
||||
* awaiting approval; the rows are the Device Inventory now, but the paths are a
|
||||
* published contract the MCP server also calls, so they stay. The method names
|
||||
* follow the paths rather than the concept, so a route is easy to find.
|
||||
*/
|
||||
export const scanApi = {
|
||||
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
|
||||
pending: () => api.get('/scan/pending'),
|
||||
/** Add an inventory entry by hand, for hardware no scan can discover. */
|
||||
createPending: (data: {
|
||||
hostname: string
|
||||
ip?: string | null
|
||||
mac?: string | null
|
||||
suggested_type?: string | null
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
/** "manual" (default) or "rack" for gear created from a rack canvas. */
|
||||
discovery_source?: 'manual' | 'rack'
|
||||
}) => api.post<{ id: string; hostname: string | null }>('/scan/pending', data),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
/** Remove one inventory entry. 409 when a rack still mounts it. */
|
||||
deletePending: (id: string) => api.delete<{ deleted: boolean }>(`/scan/pending/${id}`),
|
||||
approve: (id: string, nodeData: object) =>
|
||||
api.post<{
|
||||
approved: boolean
|
||||
@@ -211,6 +263,17 @@ export const designsApi = {
|
||||
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||
}
|
||||
|
||||
export const racksApi = {
|
||||
load: (designId: string) =>
|
||||
api.get<import('@/utils/rackSerializer').ApiRackState>('/racks', { params: { design_id: designId } }),
|
||||
save: (payload: import('@/utils/rackSerializer').RackSavePayload) =>
|
||||
api.post<{ saved: boolean }>('/racks/save', payload),
|
||||
inventory: (designId: string) =>
|
||||
api.get<{ items: import('@/utils/rackSerializer').ApiInventoryItem[] }>('/racks/inventory', {
|
||||
params: { design_id: designId },
|
||||
}),
|
||||
}
|
||||
|
||||
export interface ZigbeeConfigData {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
config: vi.fn(),
|
||||
me: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/client'
|
||||
import { bootstrapAuth } from '../bootstrap'
|
||||
|
||||
describe('bootstrapAuth', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
authMode: null,
|
||||
authMethod: null,
|
||||
oidcLoginUrl: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
})
|
||||
vi.mocked(authApi.config).mockReset()
|
||||
vi.mocked(authApi.me).mockReset()
|
||||
})
|
||||
|
||||
it('restores an OIDC cookie session without storing an IdP token', async () => {
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'oidc', oidc_login_url: '/api/v1/auth/oidc/login' },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockResolvedValue({
|
||||
data: {
|
||||
subject: 'user-1',
|
||||
display_name: 'Alice',
|
||||
auth_method: 'oidc',
|
||||
issuer: 'https://id.example/application/o/homelable/',
|
||||
csrf_token: 'csrf-123',
|
||||
},
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(authApi.me).toHaveBeenCalledOnce()
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.authMethod).toBe('oidc')
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.csrfToken).toBe('csrf-123')
|
||||
})
|
||||
|
||||
it('does not probe /auth/me for local mode without a persisted token', async () => {
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(authApi.me).not.toHaveBeenCalled()
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
authMode: 'local',
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates and retains a persisted local bearer token', async () => {
|
||||
useAuthStore.setState({ token: 'local-token' })
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockResolvedValue({
|
||||
data: {
|
||||
subject: 'admin',
|
||||
display_name: 'admin',
|
||||
auth_method: 'local',
|
||||
issuer: null,
|
||||
csrf_token: null,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
token: 'local-token',
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the backend rejects the session', async () => {
|
||||
useAuthStore.setState({ token: 'expired-token', isAuthenticated: true })
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockRejectedValue({ response: { status: 401 } })
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the auth mode unknown when discovery is unavailable', async () => {
|
||||
vi.mocked(authApi.config).mockRejectedValue(new Error('network down'))
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
authMode: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/**
|
||||
* Discover the configured authentication mode and restore a valid session.
|
||||
* OIDC sessions are represented only by the backend's HttpOnly cookie; local
|
||||
* sessions retain their existing bearer token until /auth/me validates it.
|
||||
*/
|
||||
export async function bootstrapAuth(): Promise<void> {
|
||||
const store = useAuthStore.getState()
|
||||
|
||||
try {
|
||||
const { data: config } = await authApi.config()
|
||||
store.setAuthConfig(config.mode, config.oidc_login_url)
|
||||
|
||||
const token = useAuthStore.getState().token
|
||||
if (config.mode === 'oidc' || token) {
|
||||
try {
|
||||
const { data: user } = await authApi.me()
|
||||
useAuthStore.getState().authenticate(user)
|
||||
} catch {
|
||||
// The response interceptor clears invalid credentials on 401. Also
|
||||
// clear them for malformed/unreachable session checks, failing closed.
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep authMode unknown when discovery is unreachable. The login screen
|
||||
// reports that state without guessing which authentication mode is active.
|
||||
} finally {
|
||||
useAuthStore.getState().finishInitialization()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Network, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const { authMode, oidcLoginUrl, login } = useAuthStore()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -42,62 +44,92 @@ export function LoginPage() {
|
||||
<div className="relative w-full max-w-sm px-4">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-3 mb-8">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
|
||||
<Network size={28} className="text-[#00d4ff]" />
|
||||
</div>
|
||||
<Logo size={64} showText={false} />
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
<span className="text-[#e6edf3]">Home</span>
|
||||
<span className="text-[#00d4ff]">lable</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
{authMode === 'local' && (
|
||||
<>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[#f85149] text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authMode === 'oidc' && (
|
||||
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Continue through the configured OpenID Connect provider.
|
||||
</p>
|
||||
<a
|
||||
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
|
||||
)}
|
||||
>
|
||||
Sign in with OpenID Connect
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
required
|
||||
/>
|
||||
{authMode === null && (
|
||||
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-[#f85149]">Could not load authentication configuration.</p>
|
||||
<Button type="button" variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[#f85149] text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { LoginPage } from '../LoginPage'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
@@ -13,7 +13,12 @@ import { authApi } from '@/api/client'
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
authMode: 'local',
|
||||
oidcLoginUrl: null,
|
||||
})
|
||||
vi.mocked(authApi.login).mockReset()
|
||||
})
|
||||
|
||||
@@ -30,6 +35,41 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders the official Homelable logo mark — not a generic lucide icon', () => {
|
||||
render(<LoginPage />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
// The official mark from docs/logo/icon.svg, not lucide's Network glyph
|
||||
expect(svg.querySelector('path')?.getAttribute('d'))
|
||||
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
|
||||
expect(svg.classList.contains('lucide')).toBe(false)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Homelable')
|
||||
expect(screen.getByText('HomeLab Visualizer')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders only the provider redirect in OIDC mode', () => {
|
||||
useAuthStore.setState({
|
||||
authMode: 'oidc',
|
||||
oidcLoginUrl: '/api/v1/auth/oidc/login',
|
||||
})
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.queryByLabelText('Username')).toBeNull()
|
||||
expect(screen.queryByLabelText('Password')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'Sign in with OpenID Connect' }))
|
||||
.toHaveAttribute('href', '/api/v1/auth/oidc/login')
|
||||
})
|
||||
|
||||
it('does not guess an auth mode when discovery failed', () => {
|
||||
useAuthStore.setState({ authMode: null })
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByText('Could not load authentication configuration.')).toBeDefined()
|
||||
expect(screen.queryByLabelText('Username')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Security checks ──────────────────────────────────────────────────────
|
||||
|
||||
it('password field type is "password" — not rendered as plain text', () => {
|
||||
@@ -102,7 +142,9 @@ describe('LoginPage', () => {
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
await act(async () => {
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
})
|
||||
})
|
||||
|
||||
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
|
||||
|
||||
@@ -34,10 +34,10 @@ interface CanvasContainerProps {
|
||||
onNodeDragStart?: () => void
|
||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
onOpenInventory?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenInventory }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
@@ -202,7 +202,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<FloorMapLayer />
|
||||
<SearchBar onOpenPending={onOpenPending} />
|
||||
<SearchBar onOpenInventory={onOpenInventory} />
|
||||
<AlignmentGuides guides={guides} />
|
||||
<Controls>
|
||||
<ControlButton
|
||||
|
||||
@@ -4,23 +4,23 @@ import { Search, X } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { NODE_TYPE_LABELS } from '@/types'
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
|
||||
|
||||
interface SearchBarProps {
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
onOpenInventory?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
export function SearchBar({ onOpenInventory }: SearchBarProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { nodes, setSelectedNode } = useCanvasStore()
|
||||
const { setCenter } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +86,7 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
: []
|
||||
|
||||
const pendingResults = q
|
||||
? pendingDevices.filter((d) =>
|
||||
? inventoryDevices.filter((d) =>
|
||||
d.ip?.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.friendly_name?.toLowerCase().includes(q) ||
|
||||
@@ -215,7 +215,7 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
|
||||
onClick={() => { onOpenInventory?.(d.id); setOpen(false); setQuery('') }}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
|
||||
@@ -44,9 +44,11 @@ vi.mock('@/utils/nodeColors', () => ({
|
||||
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
// Partial mock: node-type icon resolution is stubbed, but the brand/lucide
|
||||
// resolver stays real so service icons render as they do in the app.
|
||||
vi.mock('@/utils/nodeIcons', async (importActual) => ({
|
||||
...(await importActual<typeof import('@/utils/nodeIcons')>()),
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
@@ -252,6 +254,27 @@ describe('BaseNode — services visibility toggle', () => {
|
||||
|
||||
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the brand icon of a service that has one', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'plex', port: 32400, protocol: 'tcp', icon: 'brand:plex' }],
|
||||
})
|
||||
|
||||
const img = screen.getByAltText('plex') as HTMLImageElement
|
||||
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
|
||||
})
|
||||
|
||||
it('renders no service icon when the service has none', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||
import { ServiceIcon } from '@/components/ui/ServiceIcon'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
@@ -172,13 +173,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
>
|
||||
|
||||
<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, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
{/* LEFT: icon + service name */}
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<ServiceIcon iconKey={svc.icon} size={11} />
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* RIGHT: path + port */}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Editable list of `NodeProperty` records — key, value, optional icon, and a
|
||||
* per-property "show it on the canvas" toggle.
|
||||
*
|
||||
* Extracted from `DetailPanel` so the rack cable panel gets the same editor
|
||||
* rather than a second implementation of it. The component owns only its form
|
||||
* and drag state; the array itself belongs to the caller, which receives every
|
||||
* mutation as a whole new array through `onChange`.
|
||||
*/
|
||||
import { createElement, useState } from 'react'
|
||||
import { Eye, EyeOff, GripVertical, Pencil, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import type { NodeProperty } from '@/types'
|
||||
|
||||
export type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
|
||||
interface PropertyListProps {
|
||||
properties: NodeProperty[]
|
||||
/** Receives the full next array — the caller decides how to persist it. */
|
||||
onChange: (next: NodeProperty[]) => void
|
||||
/** Wording of the per-property visibility toggle ("Show on node" by default). */
|
||||
visibleLabel?: string
|
||||
/** Placeholder examples, so a cable does not suggest "CPU Model". */
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
/** One-click keys pre-filling the add form (e.g. Length, VLAN). */
|
||||
suggestions?: string[]
|
||||
/** Section heading. */
|
||||
title?: string
|
||||
emptyHint?: string
|
||||
}
|
||||
|
||||
export function PropertyList({
|
||||
properties,
|
||||
onChange,
|
||||
visibleLabel = 'Show on node',
|
||||
keyPlaceholder = 'Label (e.g. CPU Model)',
|
||||
valuePlaceholder = 'Value (e.g. i7-12700K)',
|
||||
suggestions,
|
||||
title = 'Properties',
|
||||
emptyHint = 'No properties — click Add to define one.',
|
||||
}: PropertyListProps) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newProp.key.trim() || !newProp.value.trim()) return
|
||||
onChange([
|
||||
...properties,
|
||||
{ key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible },
|
||||
])
|
||||
setNewProp(EMPTY_PROP)
|
||||
setAdding(false)
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(properties.filter((_, i) => i !== index))
|
||||
// `editingIndex` is a position into an array the caller owns, so any
|
||||
// mutation from outside the form invalidates it: removing an earlier row
|
||||
// shifted the open form onto its neighbour, and Save then overwrote the
|
||||
// wrong property. Close the form rather than try to track the move.
|
||||
setEditingIndex(null)
|
||||
}
|
||||
|
||||
const handleToggleVisible = (index: number) => {
|
||||
onChange(properties.map((p, i) => (i === index ? { ...p, visible: !p.visible } : p)))
|
||||
}
|
||||
|
||||
const handleStartEdit = (index: number) => {
|
||||
const p = properties[index]
|
||||
if (!p) return
|
||||
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
|
||||
setEditingIndex(index)
|
||||
setAdding(false)
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
|
||||
onChange(
|
||||
properties.map((p, i) =>
|
||||
i === editingIndex
|
||||
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
|
||||
: p
|
||||
)
|
||||
)
|
||||
setEditingIndex(null)
|
||||
}
|
||||
|
||||
const handleReorder = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
|
||||
const reordered = [...properties]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, moved)
|
||||
onChange(reordered)
|
||||
// Same positional hazard as a remove — see handleRemove.
|
||||
setEditingIndex(null)
|
||||
}
|
||||
|
||||
const startAddWith = (key: string) => {
|
||||
setEditingIndex(null)
|
||||
setNewProp({ ...EMPTY_PROP, key })
|
||||
setAdding(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">{title}{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
onClick={() => { setAdding((v) => !v); setEditingIndex(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{suggestions && suggestions.length > 0 && !adding && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{suggestions.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => startAddWith(key)}
|
||||
title={`Add a ${key} property`}
|
||||
className="rounded border border-[#30363d] px-1.5 py-0.5 text-[10px] text-muted-foreground hover:border-[#00d4ff] hover:text-[#00d4ff] transition-colors cursor-pointer"
|
||||
>
|
||||
+ {key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{adding && (
|
||||
<PropertyForm
|
||||
form={newProp}
|
||||
onChange={setNewProp}
|
||||
onConfirm={handleAdd}
|
||||
onCancel={() => { setAdding(false); setNewProp(EMPTY_PROP) }}
|
||||
confirmLabel="Add"
|
||||
visibleLabel={visibleLabel}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
/>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{properties.map((prop, i) =>
|
||||
editingIndex === i ? (
|
||||
<PropertyForm
|
||||
key={`edit-${i}`}
|
||||
form={editProp}
|
||||
onChange={setEditProp}
|
||||
onConfirm={handleSaveEdit}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
confirmLabel="Save"
|
||||
visibleLabel={visibleLabel}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
/>
|
||||
) : (
|
||||
<PropertyBadge
|
||||
key={`${prop.key}-${i}`}
|
||||
prop={prop}
|
||||
visibleLabel={visibleLabel}
|
||||
draggable={properties.length > 1}
|
||||
isDragging={dragIndex === i}
|
||||
isDragOver={dragOverIndex === i && dragIndex !== i}
|
||||
onDragStart={() => setDragIndex(i)}
|
||||
onDragEnter={() => { if (dragIndex !== null) setDragOverIndex(i) }}
|
||||
onDragEnd={() => { setDragIndex(null); setDragOverIndex(null) }}
|
||||
onDrop={() => {
|
||||
if (dragIndex !== null) handleReorder(dragIndex, i)
|
||||
setDragIndex(null)
|
||||
setDragOverIndex(null)
|
||||
}}
|
||||
onToggleVisible={() => handleToggleVisible(i)}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemove(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{properties.length === 0 && !adding && (
|
||||
<p className="text-[10px] text-muted-foreground/50">{emptyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel, visibleLabel = 'Show on node', keyPlaceholder = 'Label (e.g. CPU Model)', valuePlaceholder = 'Value (e.g. i7-12700K)' }: {
|
||||
form: PropForm
|
||||
onChange: (f: PropForm) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
visibleLabel?: string
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: 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={keyPlaceholder}
|
||||
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={valuePlaceholder}
|
||||
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">{visibleLabel}</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>
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertyBadge({ prop, visibleLabel = 'node', draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
|
||||
prop: NodeProperty
|
||||
/** Used to word the eye tooltip — "Show on node" / "Show on canvas". */
|
||||
visibleLabel?: string
|
||||
draggable: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
onDragStart: () => void
|
||||
onDragEnter: () => void
|
||||
onDragEnd: () => void
|
||||
onDrop: () => void
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
const where = visibleLabel.replace(/^Show on /i, '')
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop() }}
|
||||
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: isDragOver ? '#00d4ff' : '#30363d',
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{draggable && (
|
||||
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||
{createElement(GripVertical, { size: 11 })}
|
||||
</span>
|
||||
)}
|
||||
{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 ${where}` : `Show on ${where}`}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared property editor — used by the node DetailPanel and the rack cable
|
||||
* panel, so a positional bug here lands on both canvases.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { PropertyList } from '../PropertyList'
|
||||
import type { NodeProperty } from '@/types'
|
||||
|
||||
const props = (): NodeProperty[] => [
|
||||
{ key: 'A', value: '1', icon: null, visible: true },
|
||||
{ key: 'B', value: '2', icon: null, visible: true },
|
||||
{ key: 'C', value: '3', icon: null, visible: true },
|
||||
]
|
||||
|
||||
/** The form's confirm button; the section header carries an "Add" too. */
|
||||
const confirm = (name: string) => {
|
||||
const buttons = screen.getAllByRole('button', { name })
|
||||
return buttons[buttons.length - 1]
|
||||
}
|
||||
|
||||
describe('PropertyList', () => {
|
||||
it('renders each property with its key and value', () => {
|
||||
render(<PropertyList properties={props()} onChange={vi.fn()} />)
|
||||
expect(screen.getByTitle('A')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a property', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={[]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX' } })
|
||||
fireEvent.click(confirm('Add'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ key: 'GPU', value: 'RTX', icon: null, visible: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a property with no key or no value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={[]} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
|
||||
fireEvent.click(confirm('Add'))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles a property between shown and hidden', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={props()} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Hide on node')[1])
|
||||
expect(onChange.mock.calls[0][0][1].visible).toBe(false)
|
||||
})
|
||||
|
||||
it('closes the open edit form when another property is removed', () => {
|
||||
// The form index is a position into an array the caller owns. Removing an
|
||||
// earlier row shifted the open form onto its neighbour, and Save then
|
||||
// overwrote the wrong property.
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<PropertyList properties={props()} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Edit property')[1]) // editing B
|
||||
expect(screen.getByDisplayValue('B')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Remove property')[0]) // remove A
|
||||
const next = onChange.mock.calls[0][0]
|
||||
rerender(<PropertyList properties={next} onChange={onChange} />)
|
||||
|
||||
// No form at all — index 1 now points at C, so a surviving form would sit
|
||||
// on the wrong property and Save would overwrite it.
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
expect(next.map((p: NodeProperty) => p.key)).toEqual(['B', 'C'])
|
||||
})
|
||||
|
||||
it('closes the open edit form when the list is reordered', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<PropertyList properties={props()} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Edit property')[2]) // editing C
|
||||
expect(screen.getByDisplayValue('C')).toBeInTheDocument()
|
||||
|
||||
const rows = screen.getAllByTitle('Drag to reorder')
|
||||
fireEvent.dragStart(rows[0])
|
||||
fireEvent.dragEnter(rows[1])
|
||||
fireEvent.drop(rows[1])
|
||||
|
||||
rerender(<PropertyList properties={onChange.mock.calls[0][0]} onChange={onChange} />)
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
})
|
||||
|
||||
it('saves an edit onto the property it was opened on', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<PropertyList properties={props()} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Edit property')[1])
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '22' } })
|
||||
fireEvent.click(confirm('Save'))
|
||||
|
||||
expect(onChange.mock.calls[0][0]).toEqual([
|
||||
{ key: 'A', value: '1', icon: null, visible: true },
|
||||
{ key: 'B', value: '22', icon: null, visible: true },
|
||||
{ key: 'C', value: '3', icon: null, visible: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('words the visibility toggle for the surface it edits', () => {
|
||||
render(<PropertyList properties={props()} onChange={vi.fn()} visibleLabel="Show on canvas" />)
|
||||
expect(screen.getAllByTitle('Hide on canvas')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('pre-fills the add form from a suggestion', () => {
|
||||
render(<PropertyList properties={[]} onChange={vi.fn()} suggestions={['Length']} />)
|
||||
fireEvent.click(screen.getByText('+ Length'))
|
||||
expect(screen.getByDisplayValue('Length')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,22 @@ import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
|
||||
import type { Design, FloorMapConfig } from '@/types'
|
||||
import type { Design, DesignType, FloorMapConfig } from '@/types'
|
||||
|
||||
/** Canvas kinds offered on create. `electrical` shares the network renderer. */
|
||||
const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [
|
||||
{ value: 'network', label: 'Diagram', hint: 'Nodes and links on a free canvas' },
|
||||
{ value: 'rack', label: 'Rack', hint: 'Racks, mounted gear and patching' },
|
||||
]
|
||||
|
||||
export interface DesignFormData {
|
||||
name: string
|
||||
icon: string
|
||||
/**
|
||||
* Kind of canvas to create. Only offered in create mode — a canvas cannot
|
||||
* change kind afterwards, since network and rack store different things.
|
||||
*/
|
||||
designType?: DesignType
|
||||
/**
|
||||
* Floor plan for THIS canvas. Only present when the floor-plan section is
|
||||
* shown (edit mode on the active canvas). `null` means "remove the floor
|
||||
@@ -60,9 +71,12 @@ export function DesignModal({
|
||||
}: DesignModalProps) {
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
||||
const [designType, setDesignType] = useState<DesignType>(initial?.designType ?? 'network')
|
||||
|
||||
// "Copy from existing" is create-mode only (no floor-plan section shown).
|
||||
const canCopy = !showFloorMap && sourceDesigns.length > 0
|
||||
// Editing never changes the kind; `initial` is only passed in edit mode.
|
||||
const canPickKind = !initial && !showFloorMap
|
||||
const [fromExisting, setFromExisting] = useState(false)
|
||||
const [sourceId, setSourceId] = useState<string>(sourceDesigns[0]?.id ?? '')
|
||||
|
||||
@@ -103,6 +117,7 @@ export function DesignModal({
|
||||
if (!trimmed) return
|
||||
if (canCopy && fromExisting && !sourceId) return
|
||||
const data: DesignFormData = { name: trimmed, icon }
|
||||
if (canPickKind) data.designType = designType
|
||||
if (canCopy && fromExisting && sourceId) {
|
||||
data.sourceId = sourceId
|
||||
}
|
||||
@@ -146,6 +161,35 @@ export function DesignModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* A copy inherits the source's kind, so the picker would be a lie. */}
|
||||
{canPickKind && !fromExisting && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kind</Label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{CANVAS_KINDS.map((kind) => {
|
||||
const selected = kind.value === designType
|
||||
return (
|
||||
<button
|
||||
key={kind.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
title={kind.hint}
|
||||
onClick={() => setDesignType(kind.value)}
|
||||
className={`rounded-md border px-2 py-2 text-left transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-medium">{kind.label}</span>
|
||||
<span className="block text-[10px] leading-tight opacity-70">{kind.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Icon</Label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
|
||||
+82
-24
@@ -10,7 +10,7 @@ import { useDesignStore } from '@/stores/designStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { toast } from 'sonner'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import { InventoryDeviceModal, type InventoryEntry } from '@/components/modals/InventoryDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
import { applyAutoEdges, type AutoEdge } from '@/utils/autoEdges'
|
||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
@@ -18,13 +18,24 @@ import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||
import { buildMacProperty } from '@/utils/macProperty'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
|
||||
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
|
||||
import { isRackable } from '@/utils/rackable'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
interface DeviceInventoryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
highlightId?: string
|
||||
initialStatus?: 'pending' | 'hidden'
|
||||
/** Getting Started tour: render these canned devices and skip the backend fetch. */
|
||||
demoDevices?: InventoryEntry[]
|
||||
/**
|
||||
* Picker mode. Clicking a card hands the device back instead of opening its
|
||||
* detail modal, so another feature (the rack canvas) can reuse this list —
|
||||
* search, filters, badges and all — rather than reimplementing a `<select>`.
|
||||
*/
|
||||
onPick?: (device: InventoryEntry) => void
|
||||
/** Start with the Rackable filter on (what the rack picker wants). */
|
||||
initialRackableOnly?: boolean
|
||||
}
|
||||
|
||||
const PORT_COLORS: Record<number, string> = {
|
||||
@@ -80,7 +91,7 @@ type StatusFilter = 'pending' | 'hidden'
|
||||
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
|
||||
function specialServiceName(d: PendingDevice): string | undefined {
|
||||
function specialServiceName(d: InventoryEntry): string | undefined {
|
||||
const candidates = (d.services ?? []).filter(
|
||||
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||
)
|
||||
@@ -89,7 +100,7 @@ function specialServiceName(d: PendingDevice): string | undefined {
|
||||
return (nonWeb ?? candidates[0])?.service_name ?? undefined
|
||||
}
|
||||
|
||||
function deviceLabel(d: PendingDevice): string {
|
||||
function deviceLabel(d: InventoryEntry): string {
|
||||
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
@@ -113,10 +124,10 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices, onPick, initialRackableOnly = false }: DeviceInventoryModalProps) {
|
||||
const [devices, setDevices] = useState<InventoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const [selected, setSelected] = useState<InventoryEntry | null>(null)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -127,15 +138,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
const [showOnCanvas, setShowOnCanvas] = useState(true)
|
||||
// Optionally restrict to devices that have at least one detected service.
|
||||
const [withServicesOnly, setWithServicesOnly] = useState(false)
|
||||
// Hardware only — what a rack can hold. On by default when the rack canvas
|
||||
// opens the inventory to pick a mount.
|
||||
const [rackableOnly, setRackableOnly] = useState(initialRackableOnly)
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||
const activeDesignId = useDesignStore((s) => s.activeDesignId)
|
||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||
// Set when a single approve is refused because the same host is already on
|
||||
// this design — the user decides: link to the existing node or duplicate.
|
||||
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
||||
const [dupPrompt, setDupPrompt] = useState<{ device: InventoryEntry; conflict: DuplicateNodeConflict } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Tour/demo mode: show injected devices, never touch the backend.
|
||||
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||
@@ -145,7 +161,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
}, [statusFilter, demoDevices])
|
||||
|
||||
useEffect(() => { if (open) load() }, [open, load])
|
||||
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||
@@ -158,8 +174,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
setSearch('')
|
||||
} else {
|
||||
setStatusFilter(initialStatus)
|
||||
// Reopening from the rack picker must re-arm the filter even if the user
|
||||
// turned it off during the previous visit.
|
||||
setRackableOnly(initialRackableOnly)
|
||||
}
|
||||
}, [open, initialStatus])
|
||||
}, [open, initialStatus, initialRackableOnly])
|
||||
|
||||
const distinctTypes = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
@@ -175,6 +194,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
// Inventory-only: optionally hide devices already placed on a canvas.
|
||||
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
|
||||
if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
|
||||
if (rackableOnly && !isRackable(d)) return false
|
||||
if (q) {
|
||||
const hay = [
|
||||
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||
@@ -184,7 +204,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
|
||||
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly, rackableOnly])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId || loading || !open) return
|
||||
@@ -199,13 +219,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
})
|
||||
}
|
||||
|
||||
const handleCardClick = (d: PendingDevice) => {
|
||||
const handleCardClick = (d: InventoryEntry) => {
|
||||
// Picker mode wins over everything else: the caller opened this list to get
|
||||
// one device back, not to approve or hide anything.
|
||||
if (onPick) { onPick(d); return }
|
||||
if (selectMode) { toggleSelect(d.id); return }
|
||||
if (statusFilter === 'hidden') { handleRestore(d); return }
|
||||
setSelected(d)
|
||||
}
|
||||
|
||||
const handleRestore = async (device: PendingDevice) => {
|
||||
const handleRestore = async (device: InventoryEntry) => {
|
||||
try {
|
||||
await scanApi.restore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
@@ -271,7 +294,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
|
||||
// force=true is sent only after the user confirms they want a duplicate node
|
||||
// on this design (the backend otherwise 409s to let us ask).
|
||||
const approveDevice = async (device: PendingDevice, force = false) => {
|
||||
const approveDevice = async (device: InventoryEntry, force = false) => {
|
||||
// Rack gear documents a mount, not a host: it stays out of logical canvases.
|
||||
// The backend refuses it too — this is the friendly half of the guard.
|
||||
if (isRackDevice(device)) {
|
||||
toast.error('Rack devices belong to a rack canvas, not a logical one')
|
||||
return
|
||||
}
|
||||
const fallbackLabel = deviceLabel(device)
|
||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||
const zwave = isZwaveType(type)
|
||||
@@ -325,7 +354,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = (device: PendingDevice) => approveDevice(device, false)
|
||||
const handleApprove = (device: InventoryEntry) => approveDevice(device, false)
|
||||
|
||||
const goToExistingNode = (nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
@@ -334,7 +363,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleHide = async (device: PendingDevice) => {
|
||||
const handleHide = async (device: InventoryEntry) => {
|
||||
try {
|
||||
await scanApi.hide(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
@@ -345,7 +374,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = async (device: PendingDevice) => {
|
||||
const handleIgnore = async (device: InventoryEntry) => {
|
||||
try {
|
||||
await scanApi.ignore(device.id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
@@ -356,7 +385,14 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
const ids = [...selectedIds]
|
||||
const rackIds = new Set(devices.filter(isRackDevice).map((d) => d.id))
|
||||
const ids = [...selectedIds].filter((id) => !rackIds.has(id))
|
||||
const skippedRack = selectedIds.size - ids.length
|
||||
if (skippedRack > 0) {
|
||||
toast.error(
|
||||
`${skippedRack} rack device${skippedRack > 1 ? 's' : ''} skipped — they belong to a rack canvas`,
|
||||
)
|
||||
}
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await scanApi.bulkApprove(ids, activeDesignId)
|
||||
@@ -442,6 +478,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
if (inField) return
|
||||
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||
// Bulk actions are meaningless in picker mode — the card click returns a
|
||||
// device, so select mode must stay unreachable from the keyboard too.
|
||||
else if (onPick) return
|
||||
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) {
|
||||
@@ -457,7 +496,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
// statusFilter is included so Enter dispatches the correct bulk action
|
||||
// (approve vs restore) even if the device list doesn't change on switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, selectMode, selectedIds, filtered, statusFilter])
|
||||
}, [open, selectMode, selectedIds, filtered, statusFilter, onPick])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -469,7 +508,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
<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' ? 'Device Inventory' : 'Hidden Devices'}
|
||||
{onPick ? 'Pick a Device' : statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||
</span>
|
||||
@@ -478,7 +517,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
<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 && (
|
||||
{/* Never offer a destructive bulk clear from a picker. */}
|
||||
{!onPick && statusFilter === 'pending' && devices.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
|
||||
@@ -547,6 +587,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
>
|
||||
Proxmox
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('rack')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'rack' ? 'bg-[#39d353]/20 text-[#39d353]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
title="Gear created from a rack canvas"
|
||||
>
|
||||
Rack devices
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
@@ -591,6 +638,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
<ServerCog size={12} />
|
||||
With services
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRackableOnly((v) => !v)}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${rackableOnly ? 'bg-[#39d353]/20 text-[#39d353] border-[#39d353]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||
title="Only show hardware you could mount in a rack (no VMs, containers or mesh devices)"
|
||||
aria-pressed={rackableOnly}
|
||||
>
|
||||
<Server size={12} />
|
||||
Rackable
|
||||
</button>
|
||||
{!onPick && (
|
||||
<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'}`}
|
||||
@@ -598,6 +655,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
>
|
||||
{selectMode ? 'Exit select' : 'Select mode'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -734,7 +792,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PendingDeviceModal
|
||||
<InventoryDeviceModal
|
||||
device={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onApprove={handleApprove}
|
||||
@@ -746,7 +804,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: PendingDevice
|
||||
device: InventoryEntry
|
||||
selected: boolean
|
||||
selectMode: boolean
|
||||
highlighted: boolean
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
|
||||
interface IconPickerPanelProps {
|
||||
/** Current icon key — a lucide registry key or `brand:<slug>`. */
|
||||
value?: string
|
||||
/** Called with the new key, or `undefined` when the current generic icon is toggled off. */
|
||||
onSelect: (key: string | undefined) => void
|
||||
/** Focus the generic-icon search field on mount. */
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Generic/Brand icon picker body. Used by NodeModal and ServiceModal so
|
||||
* both surfaces offer the same lucide registry + dashboard-icons brand catalog.
|
||||
*/
|
||||
export function IconPickerPanel({ value, onSelect, autoFocus }: IconPickerPanelProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState<'generic' | 'brand'>(isBrandIconKey(value) ? 'brand' : 'generic')
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
active ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button type="button" role="tab" aria-selected={tab === 'generic'} onClick={() => setTab('generic')} className={tabClass(tab === 'generic')}>
|
||||
Generic
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'brand'} onClick={() => setTab('brand')} className={tabClass(tab === 'brand')}>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'brand' ? (
|
||||
<BrandIconPicker value={value} onSelect={onSelect} />
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
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">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const q = search.toLowerCase()
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat && (search === '' || e.label.toLowerCase().includes(q) || e.key.includes(q))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = value === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => onSelect(isSelected ? undefined : entry.key)}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+20
-14
@@ -1,6 +1,7 @@
|
||||
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isRackDevice } from '@/utils/deviceSources'
|
||||
import type { NodeProperty } from '@/types'
|
||||
|
||||
interface Service {
|
||||
@@ -11,7 +12,7 @@ interface Service {
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
export interface PendingDevice {
|
||||
export interface InventoryEntry {
|
||||
id: string
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
@@ -44,12 +45,12 @@ export interface PendingDevice {
|
||||
node_last_seen?: string | null
|
||||
}
|
||||
|
||||
interface PendingDeviceModalProps {
|
||||
device: PendingDevice | null
|
||||
interface InventoryDeviceModalProps {
|
||||
device: InventoryEntry | null
|
||||
onClose: () => void
|
||||
onApprove: (device: PendingDevice) => void
|
||||
onHide: (device: PendingDevice) => void
|
||||
onIgnore: (device: PendingDevice) => void
|
||||
onApprove: (device: InventoryEntry) => void
|
||||
onHide: (device: InventoryEntry) => void
|
||||
onIgnore: (device: InventoryEntry) => void
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
@@ -94,11 +95,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: PendingDeviceModalProps) {
|
||||
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: InventoryDeviceModalProps) {
|
||||
if (!device) return null
|
||||
|
||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const isZigbee = device.discovery_source === 'zigbee'
|
||||
const rackOnly = isRackDevice(device)
|
||||
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
|
||||
const handleApprove = () => { onApprove(device) }
|
||||
@@ -178,13 +180,17 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
{/* Rack gear is mounted from a rack canvas, never approved onto a
|
||||
logical one — so it gets no Approve button at all. */}
|
||||
{!rackOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -10,8 +10,8 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||
|
||||
@@ -142,9 +142,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const merged = { ...DEFAULT_DATA, ...initial }
|
||||
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||
@@ -298,85 +296,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'generic'}
|
||||
onClick={() => setIconTab('generic')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Generic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'brand'}
|
||||
onClick={() => setIconTab('brand')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
{iconTab === 'brand' ? (
|
||||
<BrandIconPicker
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
<div className="col-span-2">
|
||||
<IconPickerPanel
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat &&
|
||||
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = form.custom_icon === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ScanRun {
|
||||
interface ScanHistoryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Getting Started tour: render these canned runs and skip the backend fetch. */
|
||||
demoRuns?: ScanRun[]
|
||||
}
|
||||
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
|
||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stopping, setStopping] = useState<string | null>(null)
|
||||
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
const prevRunsRef = useRef<ScanRun[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Tour/demo mode: show injected runs, never touch the backend.
|
||||
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.runs()
|
||||
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [demoRuns])
|
||||
|
||||
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||
useEffect(() => {
|
||||
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
}, [open, runs])
|
||||
|
||||
const handleStop = async (runId: string) => {
|
||||
if (demoRuns) return // tour mode — no backend
|
||||
setStopping(runId)
|
||||
try {
|
||||
await scanApi.stop(runId)
|
||||
|
||||
@@ -3,24 +3,24 @@ import { useReactFlow } from '@xyflow/react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenPending: (deviceId: string) => void
|
||||
onOpenInventory: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
|
||||
export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
|
||||
const nodes = useCanvasStore((s) => s.nodes)
|
||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||
const { fitView } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
|
||||
}, [open])
|
||||
|
||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
@@ -32,7 +32,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
n.data.hostname?.toLowerCase().includes(q)
|
||||
).slice(0, 6)
|
||||
|
||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||
const pendingResults = q.length === 0 ? [] : inventoryDevices.filter((d) =>
|
||||
d.ip?.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.friendly_name?.toLowerCase().includes(q) ||
|
||||
@@ -52,11 +52,11 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
setQuery('')
|
||||
}, [fitView, setSelectedNode, onClose])
|
||||
|
||||
const handleSelectPending = useCallback((deviceId: string) => {
|
||||
onOpenPending(deviceId)
|
||||
const handleSelectInventoryDevice = useCallback((deviceId: string) => {
|
||||
onOpenInventory(deviceId)
|
||||
onClose()
|
||||
setQuery('')
|
||||
}, [onOpenPending, onClose])
|
||||
}, [onOpenInventory, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -77,7 +77,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
|
||||
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
|
||||
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectInventoryDevice(pendingResults[0].id)
|
||||
}}
|
||||
/>
|
||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||
@@ -109,7 +109,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
<li
|
||||
key={device.id}
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelectPending(device.id)}
|
||||
onClick={() => handleSelectInventoryDevice(device.id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { ChevronDown, RotateCcw, Circle } from 'lucide-react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ICON_REGISTRY, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { EMPTY_SERVICE_FORM, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
|
||||
interface ServiceModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: ServiceSubmitData) => void
|
||||
initial?: ServiceFormData
|
||||
title?: string
|
||||
confirmLabel?: string
|
||||
}
|
||||
|
||||
export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Service', confirmLabel = 'Add' }: ServiceModalProps) {
|
||||
const [form, setForm] = useState<ServiceFormData>(initial ?? EMPTY_SERVICE_FORM)
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [nameError, setNameError] = useState(false)
|
||||
|
||||
const set = <K extends keyof ServiceFormData>(key: K, value: ServiceFormData[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const setPort = (value: string) => set('port', value.replace(/\D/g, '').slice(0, 5))
|
||||
|
||||
const clampPort = () => {
|
||||
if (!form.port) return
|
||||
const parsed = Number.parseInt(form.port, 10)
|
||||
set('port', Number.isFinite(parsed) ? String(Math.max(1, Math.min(65535, parsed))) : '')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const name = form.service_name.trim()
|
||||
if (!name) {
|
||||
setNameError(true)
|
||||
return
|
||||
}
|
||||
const trimmedPort = form.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10)
|
||||
if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
const path = form.path.trim()
|
||||
onSubmit({
|
||||
service_name: name,
|
||||
protocol: form.protocol,
|
||||
port,
|
||||
path: path || undefined,
|
||||
icon: form.icon,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const iconPreview = () => {
|
||||
if (isBrandIconKey(form.icon)) {
|
||||
const slug = brandIconSlug(form.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.icon)
|
||||
if (entry) {
|
||||
return (
|
||||
<>
|
||||
{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}
|
||||
<span className="text-foreground truncate">{entry.label}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A key we can't resolve (e.g. a legacy scanner signature name) is still
|
||||
// shown verbatim so saving doesn't look like it silently dropped it.
|
||||
return (
|
||||
<>
|
||||
{createElement(Circle, { size: 13, className: 'text-muted-foreground shrink-0' })}
|
||||
<span className="text-muted-foreground truncate">{form.icon ?? 'None'}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Name *</Label>
|
||||
<Input
|
||||
value={form.service_name}
|
||||
onChange={(e) => { set('service_name', e.target.value); if (nameError) setNameError(false) }}
|
||||
placeholder="Service name"
|
||||
autoFocus
|
||||
className={`bg-[#21262d] text-sm h-8 ${nameError ? 'border-[#f85149]' : 'border-[#30363d]'}`}
|
||||
/>
|
||||
{nameError && <span className="text-[10px] text-[#f85149]">Name is required</span>}
|
||||
</div>
|
||||
|
||||
{/* Port + protocol */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={clampPort}
|
||||
placeholder="Port"
|
||||
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">Protocol</Label>
|
||||
<select
|
||||
value={form.protocol}
|
||||
aria-label="Protocol"
|
||||
onChange={(e) => set('protocol', e.target.value as 'tcp' | 'udp')}
|
||||
className={`bg-[#21262d] border border-[#30363d] text-sm h-8 px-2 text-foreground ${modalStyles['modal-radius']}`}
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Path</Label>
|
||||
<Input
|
||||
value={form.path}
|
||||
onChange={(e) => set('path', e.target.value)}
|
||||
placeholder="Path (/admin)"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Icon</Label>
|
||||
{form.icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { set('icon', undefined); setIconPickerOpen(false) }}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} /> Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIconPickerOpen((o) => !o)}
|
||||
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">{iconPreview()}</span>
|
||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{iconPickerOpen && (
|
||||
<IconPickerPanel
|
||||
value={form.icon}
|
||||
onSelect={(key) => { set('icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<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">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ZwaveConfigData,
|
||||
} from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type AlignmentSettings,
|
||||
@@ -401,6 +402,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-foreground">Getting started tour</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
|
||||
>
|
||||
Restart walkthrough
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('DesignModal', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
|
||||
})
|
||||
|
||||
it('submits the selected icon', () => {
|
||||
@@ -23,7 +23,41 @@ describe('DesignModal', () => {
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap', designType: 'network' })
|
||||
})
|
||||
|
||||
describe('canvas kind', () => {
|
||||
it('defaults to a diagram canvas', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Net' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit.mock.calls[0][0].designType).toBe('network')
|
||||
})
|
||||
|
||||
it('submits the rack kind when picked', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Room' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Rack/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit.mock.calls[0][0].designType).toBe('rack')
|
||||
})
|
||||
|
||||
it('is not offered in edit mode — a canvas cannot change kind', () => {
|
||||
renderModal({ initial: { name: 'Existing', icon: 'server' }, submitLabel: 'Save' })
|
||||
expect(screen.queryByRole('button', { name: /^Rack/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('is hidden once copying — a copy inherits the source kind', () => {
|
||||
renderModal({
|
||||
sourceDesigns: [
|
||||
{ id: 's1', name: 'Home Net', icon: 'network', design_type: 'network' as const,
|
||||
created_at: '', updated_at: '', node_count: 1, group_count: 0, text_count: 0 },
|
||||
],
|
||||
})
|
||||
expect(screen.getByRole('button', { name: /^Rack/ })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
|
||||
expect(screen.queryByRole('button', { name: /^Rack/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('trims whitespace and blocks empty names', () => {
|
||||
@@ -34,7 +68,7 @@ describe('DesignModal', () => {
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
|
||||
fireEvent.click(submit)
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
|
||||
})
|
||||
|
||||
it('prefills name and icon in edit mode', () => {
|
||||
@@ -55,7 +89,7 @@ describe('DesignModal', () => {
|
||||
const input = screen.getByLabelText('Name')
|
||||
fireEvent.change(input, { target: { value: 'Quick' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
|
||||
})
|
||||
|
||||
it('calls onClose from Cancel', () => {
|
||||
@@ -93,7 +127,7 @@ describe('DesignModal', () => {
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Home' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, sourceId: 's1' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, designType: 'network', sourceId: 's1' })
|
||||
})
|
||||
|
||||
it('includes the picked sourceId on submit', () => {
|
||||
@@ -102,14 +136,14 @@ describe('DesignModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Lab/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, sourceId: 's2' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, designType: 'network', sourceId: 's2' })
|
||||
})
|
||||
|
||||
it('omits sourceId when the blank option is kept', () => {
|
||||
const { onSubmit } = renderModal({ sourceDesigns })
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Fresh' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
|
||||
expect('sourceId' in onSubmit.mock.calls[0][0]).toBe(false)
|
||||
})
|
||||
|
||||
@@ -131,7 +165,7 @@ describe('DesignModal', () => {
|
||||
expect(screen.queryByText('Floor Plan')).toBeNull()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
|
||||
expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
+185
-42
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||
import { DeviceInventoryModal } from '../DeviceInventoryModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
@@ -33,8 +33,8 @@ vi.mock('@/api/client', () => ({
|
||||
|
||||
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
|
||||
vi.mock('@/components/modals/InventoryDeviceModal', () => ({
|
||||
InventoryDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
|
||||
device ? (
|
||||
<div data-testid="approval-modal">
|
||||
<button data-testid="do-approve" onClick={() => onApprove(device)}>approve</button>
|
||||
@@ -107,6 +107,33 @@ const DEVICE_PROXMOX = {
|
||||
discovered_at: '2026-01-04T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_RACK = {
|
||||
id: 'dev-rack',
|
||||
ip: null,
|
||||
hostname: 'patch-house',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: null,
|
||||
status: 'pending',
|
||||
discovery_source: 'rack',
|
||||
discovery_sources: ['rack'],
|
||||
discovered_at: '2026-01-05T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_VM = {
|
||||
id: 'dev-vm',
|
||||
ip: '192.168.1.55',
|
||||
hostname: 'web-vm',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'vm',
|
||||
status: 'pending',
|
||||
discovery_source: 'proxmox',
|
||||
discovered_at: '2026-01-06T00:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Apply the selector when one is passed (setSelectedNode is read via a
|
||||
@@ -134,29 +161,29 @@ const baseProps = {
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
describe('PendingDevicesModal', () => {
|
||||
describe('DeviceInventoryModal', () => {
|
||||
it('loads and renders pending devices on open', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<PendingDevicesModal open onClose={onClose} />)
|
||||
render(<DeviceInventoryModal open onClose={onClose} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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} />)
|
||||
render(<DeviceInventoryModal {...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()
|
||||
@@ -164,7 +191,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('filters by source (zigbee only)', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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()
|
||||
@@ -173,14 +200,14 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('shows source chip Z-WAVE for zwave device', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
|
||||
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colours the role badge with the node-type accent, not flat grey', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
const card = await waitFor(() => screen.getByTestId('pending-card-dev-c'))
|
||||
// zwave_router accent from the default theme = #e3b341 (amber), applied to
|
||||
// both the text colour and a translucent background.
|
||||
@@ -192,7 +219,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('filters by source (zwave only)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
@@ -201,7 +228,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('shows source chip PROXMOX for a proxmox device (not zigbee despite ieee)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_PROXMOX] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument())
|
||||
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ZIGBEE')).not.toBeInTheDocument()
|
||||
@@ -209,7 +236,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('filters by source (proxmox only)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_PROXMOX] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
@@ -217,7 +244,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('filters by suggested type', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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()
|
||||
@@ -228,7 +255,7 @@ describe('PendingDevicesModal', () => {
|
||||
mockHidden.mockResolvedValue({
|
||||
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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())
|
||||
@@ -236,7 +263,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('opens approval modal when card is clicked outside select mode', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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()
|
||||
@@ -259,7 +286,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('single approve prompts instead of failing when the host is already on the design', async () => {
|
||||
mockApprove.mockRejectedValueOnce(DUP_409)
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByTestId('do-approve'))
|
||||
@@ -273,7 +300,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('"Add duplicate anyway" retries the approve with force=true', async () => {
|
||||
mockApprove.mockRejectedValueOnce(DUP_409)
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByTestId('do-approve'))
|
||||
@@ -286,7 +313,7 @@ describe('PendingDevicesModal', () => {
|
||||
it('"Go to existing node" selects the existing node and closes the modal', async () => {
|
||||
mockApprove.mockRejectedValueOnce(DUP_409)
|
||||
const onClose = vi.fn()
|
||||
render(<PendingDevicesModal {...baseProps} onClose={onClose} />)
|
||||
render(<DeviceInventoryModal {...baseProps} onClose={onClose} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.click(screen.getByTestId('do-approve'))
|
||||
@@ -297,7 +324,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('toggles selection in select mode instead of opening approval', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -306,7 +333,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('select all visible selects only filtered devices', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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' } })
|
||||
@@ -315,7 +342,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('bulk approve calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -333,7 +360,7 @@ describe('PendingDevicesModal', () => {
|
||||
skipped_devices: [{ device_id: 'dev-a', label: 'host-a', match: 'ip', value: '192.168.1.10', existing_node_id: 'n-existing' }],
|
||||
},
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -355,7 +382,7 @@ describe('PendingDevicesModal', () => {
|
||||
{ ...DEVICE_IP, canvas_count: 1 },
|
||||
{ ...DEVICE_ZIGBEE, canvas_count: 1 },
|
||||
] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -369,7 +396,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -397,7 +424,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('bulk hide calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -406,20 +433,20 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(<PendingDevicesModal {...baseProps} open={false} />)
|
||||
render(<DeviceInventoryModal {...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" />)
|
||||
render(<DeviceInventoryModal {...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" />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -428,7 +455,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('bulk restore in hidden mode calls API with selected ids', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -437,7 +464,7 @@ describe('PendingDevicesModal', () => {
|
||||
})
|
||||
|
||||
it('Enter confirms approve in pending select mode', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -448,7 +475,7 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('Enter restores (not approves) in hidden select mode', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
render(<DeviceInventoryModal {...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'))
|
||||
@@ -460,21 +487,21 @@ describe('PendingDevicesModal', () => {
|
||||
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
|
||||
|
||||
it('titles the pending view "Device Inventory"', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Hidden Devices" title in hidden mode', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a corner canvas-count when canvas_count > 0', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
const corner = screen.getByLabelText('On 2 canvases')
|
||||
expect(corner).toHaveTextContent('2')
|
||||
@@ -482,21 +509,21 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('uses singular "canvas" for a single canvas', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('does not render the canvas-count corner when canvas_count is 0', async () => {
|
||||
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters to devices with detected services when "With services" is on', async () => {
|
||||
// dev-a has an http service; dev-b (zigbee) has none.
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /With services/ }))
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
@@ -507,7 +534,7 @@ describe('PendingDevicesModal', () => {
|
||||
mockPending.mockResolvedValue({
|
||||
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
// Default: both visible (on-canvas shown).
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
@@ -529,7 +556,7 @@ describe('PendingDevicesModal', () => {
|
||||
node_last_seen: '2026-06-25T09:15:00Z',
|
||||
}],
|
||||
})
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
const card = await screen.findByTestId('pending-card-dev-a')
|
||||
const inCard = within(card)
|
||||
expect(inCard.getByText('Created')).toBeInTheDocument()
|
||||
@@ -542,11 +569,127 @@ describe('PendingDevicesModal', () => {
|
||||
|
||||
it('falls back to Discovered for devices not on any canvas', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
const card = await screen.findByTestId('pending-card-dev-a')
|
||||
const inCard = within(card)
|
||||
expect(inCard.getByText('Discovered')).toBeInTheDocument()
|
||||
expect(inCard.queryByText('Created')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Gear created from a rack canvas lives in the same inventory (so it gets the
|
||||
// search / filter / hide / delete tooling) but describes a mount, not a host.
|
||||
describe('rack devices', () => {
|
||||
beforeEach(() => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_RACK] })
|
||||
})
|
||||
|
||||
it('filters them into their own section', async () => {
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rack devices' }))
|
||||
expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refuses to approve one onto a logical canvas', async () => {
|
||||
const { toast } = await import('sonner')
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
|
||||
fireEvent.click(screen.getByTestId('do-approve'))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('rack canvas')))
|
||||
expect(mockApprove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops them from a bulk approve and says how many', async () => {
|
||||
const { toast } = await import('sonner')
|
||||
render(<DeviceInventoryModal {...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-rack'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 rack device'))
|
||||
})
|
||||
|
||||
it('keeps rack gear in the rackable list — it is hardware', async () => {
|
||||
render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('approves nothing when the whole selection is rack gear', async () => {
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(1\)/ }))
|
||||
|
||||
await waitFor(() => expect(mockBulkApprove).not.toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
// The rack canvas opens this modal to pick a mount, so it needs a hardware
|
||||
// filter and a way to hand one device back instead of approving it.
|
||||
describe('rackable filter and picker mode', () => {
|
||||
beforeEach(() => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_VM] })
|
||||
})
|
||||
|
||||
it('drops virtual and mesh kinds when the filter is on', async () => {
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-vm')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rackable' }))
|
||||
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts filtered when the caller asks for it', async () => {
|
||||
render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Rackable' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('re-arms the filter when reopened after the user turned it off', async () => {
|
||||
const { rerender } = render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rackable' }))
|
||||
expect(screen.getByTestId('pending-card-dev-vm')).toBeInTheDocument()
|
||||
|
||||
rerender(<DeviceInventoryModal {...baseProps} open={false} initialRackableOnly />)
|
||||
rerender(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands the card back instead of opening the approval modal', async () => {
|
||||
const onPick = vi.fn()
|
||||
render(<DeviceInventoryModal {...baseProps} onPick={onPick} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ id: 'dev-a' }))
|
||||
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the bulk and clear controls a picker has no business offering', async () => {
|
||||
render(<DeviceInventoryModal {...baseProps} onPick={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Select mode' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle(/Clear all pending/)).not.toBeInTheDocument()
|
||||
// …and the keyboard shortcut cannot bring select mode back either.
|
||||
fireEvent.keyDown(window, { key: 's' })
|
||||
expect(screen.queryByRole('button', { name: /Approve \(/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { IconPickerPanel } from '../IconPickerPanel'
|
||||
|
||||
describe('IconPickerPanel', () => {
|
||||
it('opens on the Generic tab by default', () => {
|
||||
render(<IconPickerPanel onSelect={vi.fn()} />)
|
||||
expect(screen.getByRole('tab', { name: 'Generic' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('opens on the Brand tab when the current value is a brand key', () => {
|
||||
render(<IconPickerPanel value="brand:plex" onSelect={vi.fn()} />)
|
||||
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits the lucide key when a generic icon is picked', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<IconPickerPanel onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
|
||||
expect(onSelect).toHaveBeenCalledWith('database')
|
||||
})
|
||||
|
||||
it('emits undefined when the already-selected generic icon is clicked again', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<IconPickerPanel value="database" onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('filters the generic grid by search text', () => {
|
||||
render(<IconPickerPanel onSelect={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
|
||||
expect(screen.getByLabelText('Select icon Grafana / Kibana')).toBeDefined()
|
||||
expect(screen.queryByLabelText('Select icon Database (SQL/NoSQL)')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits a prefixed key when a brand icon is picked', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<IconPickerPanel onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
|
||||
expect(onSelect).toHaveBeenCalledWith('brand:plex')
|
||||
})
|
||||
|
||||
it('keeps the generic search box out of the Brand tab', () => {
|
||||
render(<IconPickerPanel onSelect={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||
})
|
||||
})
|
||||
+45
-18
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
|
||||
import { InventoryDeviceModal, type InventoryEntry } from '../InventoryDeviceModal'
|
||||
|
||||
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
|
||||
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
ip: '192.168.1.100',
|
||||
@@ -17,19 +17,19 @@ function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
|
||||
}
|
||||
}
|
||||
|
||||
describe('PendingDeviceModal', () => {
|
||||
describe('InventoryDeviceModal', () => {
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
it('renders nothing when device is null', () => {
|
||||
const { container } = render(
|
||||
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders dialog when device is provided', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByRole('dialog')).toBeDefined()
|
||||
})
|
||||
@@ -38,7 +38,7 @@ describe('PendingDeviceModal', () => {
|
||||
|
||||
it('shows hostname as title when available', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
// hostname appears in both title and info row — check at least one match
|
||||
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
|
||||
@@ -46,7 +46,7 @@ describe('PendingDeviceModal', () => {
|
||||
|
||||
it('falls back to IP as title when hostname is null', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
// IP appears in both title and info row when hostname is absent
|
||||
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
|
||||
@@ -54,28 +54,28 @@ describe('PendingDeviceModal', () => {
|
||||
|
||||
it('shows IP address', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('192.168.1.100')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows MAC address when present', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows OS when present', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Linux')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show hostname row when hostname is null', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
// "Hostname" label should not appear in the info rows
|
||||
expect(screen.queryByText('Hostname')).toBeNull()
|
||||
@@ -83,7 +83,7 @@ describe('PendingDeviceModal', () => {
|
||||
|
||||
it('shows suggested type when present', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('proxmox')).toBeDefined()
|
||||
})
|
||||
@@ -92,7 +92,7 @@ describe('PendingDeviceModal', () => {
|
||||
|
||||
it('shows "No services detected" when services list is empty', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('No services detected')).toBeDefined()
|
||||
})
|
||||
@@ -105,7 +105,7 @@ describe('PendingDeviceModal', () => {
|
||||
],
|
||||
})
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Services found (2)')).toBeDefined()
|
||||
expect(screen.getByText('HTTP')).toBeDefined()
|
||||
@@ -119,7 +119,7 @@ describe('PendingDeviceModal', () => {
|
||||
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
|
||||
})
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('hypervisor')).toBeDefined()
|
||||
})
|
||||
@@ -131,7 +131,7 @@ describe('PendingDeviceModal', () => {
|
||||
const onApprove = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
expect(onApprove).toHaveBeenCalledWith(device)
|
||||
@@ -143,7 +143,7 @@ describe('PendingDeviceModal', () => {
|
||||
const onHide = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
|
||||
<InventoryDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
|
||||
expect(onHide).toHaveBeenCalledWith(device)
|
||||
@@ -155,10 +155,37 @@ describe('PendingDeviceModal', () => {
|
||||
const onIgnore = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
|
||||
<InventoryDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onIgnore).toHaveBeenCalledWith(device)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — rack gear', () => {
|
||||
const rackDevice = makeDevice({
|
||||
id: 'dev-rack',
|
||||
hostname: 'patch-house',
|
||||
ip: null,
|
||||
discovery_source: 'rack',
|
||||
discovery_sources: ['rack'],
|
||||
})
|
||||
|
||||
it('offers no Approve button — rack gear never lands on a logical canvas', () => {
|
||||
render(
|
||||
<InventoryDeviceModal device={rackDevice} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
||||
// Hide and Delete still apply: the entry is inventory like any other.
|
||||
expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Approve for a scanned device', () => {
|
||||
render(
|
||||
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ vi.mock('@/api/client', () => ({
|
||||
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
|
||||
}))
|
||||
|
||||
const mockOnOpenPending = vi.fn()
|
||||
const mockOnOpenInventory = vi.fn()
|
||||
|
||||
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||
return {
|
||||
@@ -38,23 +38,23 @@ describe('SearchModal', () => {
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open={false} onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders search input when open', () => {
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Type to search" hint when query is empty', () => {
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
expect(screen.getByText(/type to search/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows no results message when query has no matches', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
||||
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||
})
|
||||
@@ -63,7 +63,7 @@ describe('SearchModal', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
||||
expect(screen.getByText('My Router')).toBeDefined()
|
||||
expect(screen.queryByText('NAS Server')).toBeNull()
|
||||
@@ -76,7 +76,7 @@ describe('SearchModal', () => {
|
||||
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
||||
expect(screen.getByText('Box A')).toBeDefined()
|
||||
expect(screen.queryByText('Box B')).toBeNull()
|
||||
@@ -89,7 +89,7 @@ describe('SearchModal', () => {
|
||||
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
||||
expect(screen.getByText('A')).toBeDefined()
|
||||
expect(screen.queryByText('B')).toBeNull()
|
||||
@@ -102,7 +102,7 @@ describe('SearchModal', () => {
|
||||
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
||||
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||
})
|
||||
@@ -111,7 +111,7 @@ describe('SearchModal', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
||||
const items = screen.getAllByText(/Server \d/)
|
||||
expect(items).toHaveLength(6)
|
||||
@@ -120,7 +120,7 @@ describe('SearchModal', () => {
|
||||
it('selects node and closes on result click', () => {
|
||||
const onClose = vi.fn()
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
||||
fireEvent.click(screen.getByText('Proxmox'))
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
@@ -131,7 +131,7 @@ describe('SearchModal', () => {
|
||||
it('selects first result and closes on Enter key', () => {
|
||||
const onClose = vi.fn()
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
|
||||
const input = screen.getByPlaceholderText(/search nodes/i)
|
||||
fireEvent.change(input, { target: { value: 'switch' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
@@ -141,14 +141,14 @@ describe('SearchModal', () => {
|
||||
|
||||
it('closes on Escape key', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes when clicking backdrop', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
|
||||
// The backdrop is the fixed inset div — clicking it fires onClose
|
||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
||||
fireEvent.click(backdrop)
|
||||
@@ -157,14 +157,14 @@ describe('SearchModal', () => {
|
||||
|
||||
it('does not close when clicking inside the search box', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('search is case-insensitive', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
||||
expect(screen.getByText('My NAS')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ServiceModal } from '../ServiceModal'
|
||||
import { serviceToForm } from '@/utils/serviceForm'
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof ServiceModal>> = {}) {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<ServiceModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||
return { onSubmit, onClose }
|
||||
}
|
||||
|
||||
const submit = (label = 'Add') =>
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
describe('ServiceModal', () => {
|
||||
describe('form submission', () => {
|
||||
it('submits name, port, protocol and path', () => {
|
||||
const { onSubmit, onClose } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
fireEvent.change(screen.getByLabelText('Protocol'), { target: { value: 'udp' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||
submit()
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
service_name: 'nginx',
|
||||
protocol: 'udp',
|
||||
port: 80,
|
||||
path: '/admin',
|
||||
icon: undefined,
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits without a port', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'health', port: undefined, path: undefined }))
|
||||
})
|
||||
|
||||
it('trims whitespace off the name and path', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' nginx ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: ' /admin ' } })
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'nginx', path: '/admin' }))
|
||||
})
|
||||
|
||||
it('blocks submission and flags the field when the name is blank', () => {
|
||||
const { onSubmit, onClose } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' ' } })
|
||||
submit()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Name is required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('clears the name error once the user types', () => {
|
||||
setup()
|
||||
submit()
|
||||
expect(screen.getByText('Name is required')).toBeDefined()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'n' } })
|
||||
expect(screen.queryByText('Name is required')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips non-digits from the port field', () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '8a0b80' } })
|
||||
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('8080')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range port on blur', () => {
|
||||
setup()
|
||||
const port = screen.getByPlaceholderText('Port')
|
||||
fireEvent.change(port, { target: { value: '99999' } })
|
||||
fireEvent.blur(port)
|
||||
expect((port as HTMLInputElement).value).toBe('65535')
|
||||
})
|
||||
|
||||
it('calls onClose without submitting on Cancel', () => {
|
||||
const { onSubmit, onClose } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit mode', () => {
|
||||
const initial = serviceToForm({ port: 80, protocol: 'tcp', service_name: 'nginx', path: '/admin', icon: 'brand:nginx' })
|
||||
|
||||
it('prefills the form from an existing service', () => {
|
||||
setup({ initial, title: 'Edit Service', confirmLabel: 'Save' })
|
||||
expect((screen.getByPlaceholderText('Service name') as HTMLInputElement).value).toBe('nginx')
|
||||
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('80')
|
||||
expect((screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement).value).toBe('/admin')
|
||||
expect(screen.getByText('Edit Service')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the existing icon when nothing else changes', () => {
|
||||
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:nginx' }))
|
||||
})
|
||||
|
||||
it('clears a port that the user emptied', () => {
|
||||
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '' } })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ port: undefined }))
|
||||
})
|
||||
|
||||
it('clears a path that the user emptied', () => {
|
||||
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '' } })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon picker', () => {
|
||||
it('shows "None" until an icon is picked', () => {
|
||||
setup()
|
||||
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('None')
|
||||
})
|
||||
|
||||
it('shows an unresolvable legacy key verbatim rather than "None"', () => {
|
||||
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }) })
|
||||
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('bar-chart-2')
|
||||
})
|
||||
|
||||
it('keeps an unresolvable legacy key on save instead of dropping it', () => {
|
||||
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }), confirmLabel: 'Save' })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'bar-chart-2' }))
|
||||
})
|
||||
|
||||
it('selects a generic lucide icon', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'db' } })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'database' }))
|
||||
})
|
||||
|
||||
it('selects a brand icon from the Brand tab', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:plex' }))
|
||||
})
|
||||
|
||||
it('opens on the Brand tab when the service already carries a brand icon', () => {
|
||||
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('previews the selected brand icon on the trigger', () => {
|
||||
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
|
||||
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('plex')
|
||||
})
|
||||
|
||||
it('resets the icon back to none', () => {
|
||||
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reset/ }))
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: undefined }))
|
||||
})
|
||||
|
||||
it('hides the Reset button when no icon is set', () => {
|
||||
setup()
|
||||
expect(screen.queryByRole('button', { name: /Reset/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,9 @@ describe('SettingsModal', () => {
|
||||
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
await screen.findByDisplayValue('60')
|
||||
// Wait for the async proxmox getConfig to hydrate the toggle before saving —
|
||||
// otherwise Save can fire with the default sync_enabled=false under CI load.
|
||||
await waitFor(() => expect(screen.getByLabelText('Toggle Proxmox auto-sync')).toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
|
||||
@@ -174,6 +177,9 @@ describe('SettingsModal', () => {
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
await screen.findByDisplayValue('60')
|
||||
await screen.findByText('Zigbee auto-sync')
|
||||
// Wait for the async zigbee getConfig to hydrate the toggle before saving —
|
||||
// otherwise Save can fire with the default sync_enabled=false under CI load.
|
||||
await waitFor(() => expect(screen.getByLabelText('Toggle Zigbee auto-sync')).toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
|
||||
|
||||
@@ -7,38 +7,30 @@ import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { PropertyList } from '@/components/common/PropertyList'
|
||||
import { formatTimestamp } from '@/utils/timeFormat'
|
||||
import { ServiceModal } from '@/components/modals/ServiceModal'
|
||||
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
import { ServiceIcon } from '@/components/ui/ServiceIcon'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
|
||||
|
||||
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
/** Open service editor: `index === null` means "add a new service". */
|
||||
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
||||
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
const [svcModal, setSvcModal] = useState<SvcModalState | null>(null)
|
||||
const [groupName, setGroupName] = useState('')
|
||||
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)
|
||||
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
|
||||
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
|
||||
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
|
||||
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
|
||||
|
||||
// Multi-select panel
|
||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||
@@ -87,8 +79,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
}
|
||||
|
||||
// Normal single-node panel
|
||||
const addingService = addingForNode === node.id
|
||||
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
|
||||
const openSvcModal = svcModal?.nodeId === node.id ? svcModal : null
|
||||
const { data } = node
|
||||
const services = data.services ?? []
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
@@ -102,115 +93,63 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddService = () => {
|
||||
const trimmedPort = newSvc.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||
if (!newSvc.service_name.trim()) return
|
||||
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
const handleSubmitService = (data: ServiceSubmitData) => {
|
||||
if (!openSvcModal) return
|
||||
snapshotHistory()
|
||||
const path = newSvc.path.trim()
|
||||
const svc: ServiceInfo = {
|
||||
...(port != null ? { port } : {}),
|
||||
protocol: newSvc.protocol,
|
||||
service_name: newSvc.service_name.trim(),
|
||||
...(path ? { path } : {}),
|
||||
if (openSvcModal.index === null) {
|
||||
const svc: ServiceInfo = {
|
||||
...(data.port != null ? { port: data.port } : {}),
|
||||
protocol: data.protocol,
|
||||
service_name: data.service_name,
|
||||
...(data.path ? { path: data.path } : {}),
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
}
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
return
|
||||
}
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
setAddingForNode(null)
|
||||
const updated = services.map((svc, i) =>
|
||||
i === openSvcModal.index
|
||||
? {
|
||||
...svc,
|
||||
protocol: data.protocol,
|
||||
service_name: data.service_name,
|
||||
port: data.port,
|
||||
path: data.path,
|
||||
icon: data.icon,
|
||||
}
|
||||
: svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
snapshotHistory()
|
||||
const updated = services.filter((_, i) => i !== index)
|
||||
updateNode(node.id, { services: updated })
|
||||
if (editingIndex === index) setEditingFor(null)
|
||||
if (openSvcModal?.index === index) setSvcModal(null)
|
||||
}
|
||||
|
||||
const handleStartEdit = (index: number) => {
|
||||
const svc = services[index]
|
||||
if (!svc) return
|
||||
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
|
||||
setEditingFor({ nodeId: node.id, index })
|
||||
setAddingForNode(null)
|
||||
setSvcModal({ nodeId: node.id, index, form: serviceToForm(svc) })
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null) return
|
||||
const trimmedPort = editSvc.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||
if (!editSvc.service_name.trim()) return
|
||||
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
const handleReorderService = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) return
|
||||
snapshotHistory()
|
||||
const path = editSvc.path.trim()
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex
|
||||
? {
|
||||
...svc,
|
||||
protocol: editSvc.protocol,
|
||||
service_name: editSvc.service_name.trim(),
|
||||
...(port != null ? { port } : { port: undefined }),
|
||||
...(path ? { path } : { path: undefined }),
|
||||
}
|
||||
: svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
setEditingFor(null)
|
||||
const reordered = [...services]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, moved)
|
||||
updateNode(node.id, { services: reordered })
|
||||
}
|
||||
|
||||
// --- Property handlers ---
|
||||
const properties: NodeProperty[] = data.properties ?? []
|
||||
|
||||
const handleAddProp = () => {
|
||||
if (!newProp.key.trim() || !newProp.value.trim()) return
|
||||
const handleChangeProps = (next: NodeProperty[]) => {
|
||||
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)
|
||||
}
|
||||
|
||||
const handleReorderProp = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
|
||||
snapshotHistory()
|
||||
const reordered = [...properties]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, moved)
|
||||
updateNode(node.id, { properties: reordered })
|
||||
updateNode(node.id, { properties: next })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -278,87 +217,55 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
/>
|
||||
|
||||
{/* Properties section */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</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}
|
||||
draggable={properties.length > 1}
|
||||
isDragging={dragPropIndex === i}
|
||||
isDragOver={dragOverPropIndex === i && dragPropIndex !== i}
|
||||
onDragStart={() => setDragPropIndex(i)}
|
||||
onDragEnter={() => { if (dragPropIndex !== null) setDragOverPropIndex(i) }}
|
||||
onDragEnd={() => { setDragPropIndex(null); setDragOverPropIndex(null) }}
|
||||
onDrop={() => {
|
||||
if (dragPropIndex !== null) handleReorderProp(dragPropIndex, i)
|
||||
setDragPropIndex(null)
|
||||
setDragOverPropIndex(null)
|
||||
}}
|
||||
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>
|
||||
<PropertyList properties={properties} onChange={handleChangeProps} />
|
||||
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||
<button onClick={() => setSvcModal({ nodeId: node.id, index: null })} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
|
||||
{services.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{services.map((svc, i) =>
|
||||
editingIndex === i ? (
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
)
|
||||
)}
|
||||
{services.map((svc, i) => (
|
||||
<ServiceBadge
|
||||
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
|
||||
draggable={services.length > 1}
|
||||
isDragging={dragSvcIndex === i}
|
||||
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
|
||||
onDragStart={() => setDragSvcIndex(i)}
|
||||
onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }}
|
||||
onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }}
|
||||
onDrop={() => {
|
||||
if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i)
|
||||
setDragSvcIndex(null)
|
||||
setDragOverSvcIndex(null)
|
||||
}}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||||
{services.length === 0 && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||||
</div>
|
||||
|
||||
{openSvcModal && (
|
||||
<ServiceModal
|
||||
key={`svc-${openSvcModal.index ?? 'new'}`}
|
||||
open
|
||||
onClose={() => setSvcModal(null)}
|
||||
onSubmit={handleSubmitService}
|
||||
initial={openSvcModal.form}
|
||||
title={openSvcModal.index === null ? 'Add Service' : 'Edit Service'}
|
||||
confirmLabel={openSvcModal.index === null ? 'Add' : 'Save'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.notes && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
||||
@@ -516,6 +423,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
data-tour="create-group"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
||||
variant="ghost"
|
||||
@@ -667,131 +575,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const setPort = (value: string) => {
|
||||
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
|
||||
onChange({ ...form, port: digitsOnly })
|
||||
}
|
||||
|
||||
const clampPort = (value: string) => {
|
||||
if (!value) return ''
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) return ''
|
||||
return String(Math.max(1, Math.min(65535, parsed)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
|
||||
placeholder="Port"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
// --- 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, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
|
||||
prop: NodeProperty
|
||||
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
|
||||
svc: ServiceInfo
|
||||
host?: string
|
||||
status?: ServiceStatus
|
||||
draggable: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
@@ -799,60 +590,9 @@ function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, o
|
||||
onDragEnter: () => void
|
||||
onDragEnd: () => void
|
||||
onDrop: () => void
|
||||
onToggleVisible: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop() }}
|
||||
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: isDragOver ? '#00d4ff' : '#30363d',
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{draggable && (
|
||||
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||
{createElement(GripVertical, { size: 11 })}
|
||||
</span>
|
||||
)}
|
||||
{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> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
// Manually-added services carry no category, so they fell back to grey even
|
||||
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
|
||||
@@ -863,11 +603,27 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop() }}
|
||||
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' }}
|
||||
style={{
|
||||
background: '#21262d',
|
||||
borderColor: isDragOver ? '#00d4ff' : '#30363d',
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{draggable && (
|
||||
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||
{createElement(GripVertical, { size: 11 })}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<ServiceIcon iconKey={svc.icon} size={12} className="shrink-0" color={color} />
|
||||
|
||||
{url ? (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Rack-specific middle group of the header toolbar.
|
||||
*
|
||||
* Replaces the logical canvas actions (auto layout, YAML import/export) with the
|
||||
* ones a rack canvas actually has: adding racks, patching, and controlling how
|
||||
* much of the cabling is on screen.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Cable, Eye, EyeOff, Link2, MousePointer2, Plus, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRackStore } from '@/rack/store'
|
||||
import { useRackPalette } from '@/rack/rackTheme'
|
||||
import { loadNetworkLinks } from '@/rack/networkLinks'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import type { CableVisibility } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
const ghost =
|
||||
'gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]'
|
||||
/** Reads as one of the ghost buttons around it: no box until you hover it. */
|
||||
const triggerClass =
|
||||
'gap-1.5 border-transparent bg-transparent px-2 text-xs font-medium text-muted-foreground hover:bg-[#21262d] hover:text-foreground dark:bg-transparent dark:hover:bg-[#21262d] cursor-pointer'
|
||||
|
||||
const VISIBILITY_OPTIONS: { value: CableVisibility; label: string; icon: typeof Eye }[] = [
|
||||
{ value: 'hover', label: 'Cables on hover', icon: MousePointer2 },
|
||||
{ value: 'always', label: 'Cables always', icon: Eye },
|
||||
{ value: 'hidden', label: 'Cables hidden', icon: EyeOff },
|
||||
]
|
||||
|
||||
export function RackToolbarActions() {
|
||||
const palette = useRackPalette()
|
||||
const addRack = useRackStore((s) => s.addRack)
|
||||
const cableMode = useRackStore((s) => s.cableMode)
|
||||
const toggleCableMode = useRackStore((s) => s.toggleCableMode)
|
||||
const cableVisibility = useRackStore((s) => s.cableVisibility)
|
||||
const setCableVisibility = useRackStore((s) => s.setCableVisibility)
|
||||
const cableDraft = useRackStore((s) => s.cableDraft)
|
||||
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
|
||||
const selectedCableId = useRackStore((s) => s.selectedCableId)
|
||||
const removeSelectedCable = useRackStore((s) => s.removeSelectedCable)
|
||||
const importCables = useRackStore((s) => s.importCablesFromNetwork)
|
||||
const designs = useDesignStore((s) => s.designs)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
async function handleImport() {
|
||||
setImporting(true)
|
||||
try {
|
||||
const hints = STANDALONE ? [] : await loadNetworkLinks(designs)
|
||||
const created = importCables(hints)
|
||||
toast[created > 0 ? 'success' : 'info'](
|
||||
created > 0
|
||||
? `${created} cable${created > 1 ? 's' : ''} imported from the network canvas`
|
||||
: 'No matching link found — rack the devices first',
|
||||
)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" className={ghost} onClick={() => addRack({ style: palette.defaultRackStyle })}>
|
||||
<Plus size={14} /> Rack
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`${ghost} ${cableMode ? 'text-[#00d4ff]' : ''}`}
|
||||
onClick={toggleCableMode}
|
||||
title="Drag from one port to another to patch, or click both in turn. Click a cable to select it, then Delete to unplug."
|
||||
>
|
||||
<Cable size={14} /> {cableMode ? 'Exit patching' : 'Patch'}
|
||||
</Button>
|
||||
|
||||
{cableMode && cableDraft && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`${ghost} text-[#e3b341]`}
|
||||
onClick={cancelCableDraft}
|
||||
>
|
||||
<X size={14} /> Cancel cable
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cableMode && selectedCableId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`${ghost} text-[#f85149]`}
|
||||
onClick={removeSelectedCable}
|
||||
title="Unplug the selected cable (Delete)"
|
||||
>
|
||||
<Trash2 size={14} /> Unplug
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select value={cableVisibility} onValueChange={(v) => setCableVisibility(v as CableVisibility)}>
|
||||
<SelectTrigger size="sm" className={triggerClass} aria-label="Cable visibility">
|
||||
<SelectValue>
|
||||
{(() => {
|
||||
const { icon: Icon, label } = VISIBILITY_OPTIONS.find((o) => o.value === cableVisibility)!
|
||||
return <><Icon size={14} /> {label}</>
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{VISIBILITY_OPTIONS.map(({ value, icon: Icon, label }) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">
|
||||
<Icon size={14} /> {label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!STANDALONE && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={ghost}
|
||||
disabled={importing}
|
||||
onClick={() => void handleImport()}
|
||||
title="One-shot: derive patches from the links already drawn on the logical canvases"
|
||||
>
|
||||
<Link2 size={14} /> Import links
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2, Rows3 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { designsApi, mediaApi } from '@/api/client'
|
||||
import { authApi, designsApi, mediaApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||
import { AccessoryTray } from '@/rack/components/AccessoryTray'
|
||||
import { useRackStore } from '@/rack/store'
|
||||
import { freeUnits } from '@/rack/layout'
|
||||
import type { Design } from '@/types'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
@@ -31,21 +34,34 @@ interface SidebarProps {
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
onOpenInventory: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenInventory }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
const { designs, activeDesignId, activeDesignType, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
const isRack = activeDesignType === 'rack'
|
||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
|
||||
// Bumped on every open so the modal remounts and re-seeds its local state from
|
||||
// the current floor plan — otherwise a reopen keeps stale width/height/lock
|
||||
// and Save would clobber canvas-side resize/move.
|
||||
const [openSeq, setOpenSeq] = useState(0)
|
||||
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
|
||||
const { nodes, hasUnsavedChanges: canvasDirty, floorMap, setFloorMap } = useCanvasStore()
|
||||
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
|
||||
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
|
||||
const openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
|
||||
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
logout()
|
||||
} catch {
|
||||
toast.error('Could not end the current session')
|
||||
}
|
||||
}, [logout])
|
||||
|
||||
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
|
||||
setOpenSeq((s) => s + 1)
|
||||
@@ -63,9 +79,10 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
? standaloneStorage.copyDesign(data.sourceId, data.name, data.icon)
|
||||
: (await designsApi.copy(data.sourceId, { name: data.name, icon: data.icon })).data
|
||||
} else {
|
||||
const designType = data.designType ?? 'network'
|
||||
created = STANDALONE
|
||||
? standaloneStorage.createDesign(data.name, data.icon)
|
||||
: (await designsApi.create({ name: data.name, icon: data.icon })).data
|
||||
? standaloneStorage.createDesign(data.name, data.icon, designType)
|
||||
: (await designsApi.create({ name: data.name, icon: data.icon, design_type: designType })).data
|
||||
}
|
||||
addDesign(created)
|
||||
} else if (designModal.design) {
|
||||
@@ -155,6 +172,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border relative">
|
||||
<button
|
||||
onClick={() => setDesignSwitcherOpen((o) => !o)}
|
||||
data-tour="canvas-switcher"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
|
||||
>
|
||||
{activeDesignId ? (() => {
|
||||
@@ -222,8 +240,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
{/* Views */}
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Canvas"
|
||||
icon={isRack ? Rows3 : LayoutDashboard}
|
||||
label={isRack ? 'Rack view' : 'Canvas'}
|
||||
collapsed={collapsed}
|
||||
active
|
||||
/>
|
||||
@@ -233,7 +251,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
icon={t.icon}
|
||||
label={t.label}
|
||||
collapsed={collapsed}
|
||||
onClick={() => onOpenPending(undefined, t.kind)}
|
||||
onClick={() => onOpenInventory(undefined, t.kind)}
|
||||
/>
|
||||
))}
|
||||
{!STANDALONE && (
|
||||
@@ -246,10 +264,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className="flex-1" />}
|
||||
{/* The rack inventory is the sidebar's body: it scrolls, everything else
|
||||
stays pinned. On a logical canvas the space is just a spacer. */}
|
||||
{isRack && !collapsed ? <AccessoryTray /> : !collapsed && <div className="flex-1" />}
|
||||
|
||||
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
|
||||
{!collapsed && !STANDALONE && (
|
||||
{!collapsed && isRack && <RackStats />}
|
||||
{!collapsed && !isRack && !STANDALONE && (
|
||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<span>Total</span>
|
||||
@@ -268,16 +289,22 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
|
||||
{isRack ? (
|
||||
<SidebarItem icon={Plus} label="Add Device" collapsed={collapsed} onClick={() => openDeviceEditor()} />
|
||||
) : (
|
||||
<>
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
|
||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
|
||||
</>
|
||||
)}
|
||||
{!STANDALONE && !isRack && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
|
||||
{!STANDALONE && !isRack && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
|
||||
{!STANDALONE && !isRack && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
|
||||
{!STANDALONE && !isRack && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
label={isRack ? 'Save Rack' : 'Save Canvas'}
|
||||
collapsed={collapsed}
|
||||
onClick={() => onSave()}
|
||||
badge={hasUnsavedChanges}
|
||||
@@ -294,7 +321,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
icon={LogOut}
|
||||
label="Logout"
|
||||
collapsed={collapsed}
|
||||
onClick={logout}
|
||||
onClick={() => void handleLogout()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -324,6 +351,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
)
|
||||
}
|
||||
|
||||
/** Rack equivalent of the node stats footer: capacity rather than reachability. */
|
||||
function RackStats() {
|
||||
const racks = useRackStore((s) => s.racks)
|
||||
const devices = useRackStore((s) => s.devices)
|
||||
const cables = useRackStore((s) => s.cables)
|
||||
|
||||
const free = racks.reduce((sum, rack) => sum + freeUnits(rack, devices), 0)
|
||||
const total = racks.reduce((sum, rack) => sum + rack.uHeight, 0)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<span>Racks</span>
|
||||
<span className="text-foreground font-mono">{racks.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Mounted</span>
|
||||
<span className="text-foreground font-mono">{devices.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Cables</span>
|
||||
<span className="text-foreground font-mono">{cables.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#39d353]">Free</span>
|
||||
<span className="font-mono text-[#39d353]">{free}U / {total}U</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionBadge() {
|
||||
const current = __APP_VERSION__
|
||||
const { latest, hasUpdate } = useLatestRelease(current)
|
||||
@@ -360,12 +418,15 @@ interface SidebarItemProps {
|
||||
badge?: boolean
|
||||
accent?: boolean
|
||||
onClick?: () => void
|
||||
/** Stable anchor for the Getting Started walkthrough spotlight. */
|
||||
dataTour?: string
|
||||
}
|
||||
|
||||
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
|
||||
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
|
||||
const btn = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-tour={dataTour}
|
||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Tab
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useRackStore } from '@/rack/store'
|
||||
import { RackToolbarActions } from './RackToolbarActions'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
@@ -21,7 +24,10 @@ interface ToolbarProps {
|
||||
}
|
||||
|
||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
|
||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||
const { hasUnsavedChanges: canvasDirty, past, future } = useCanvasStore()
|
||||
const isRack = useDesignStore((s) => s.activeDesignType) === 'rack'
|
||||
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
|
||||
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -40,29 +46,37 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||
<Logo size={28} showText={true} />
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||
onClick={onUndo}
|
||||
disabled={past.length === 0}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||
onClick={onRedo}
|
||||
disabled={future.length === 0}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</Button>
|
||||
{/* Undo/redo are canvas history; the rack canvas has none yet. */}
|
||||
{!isRack && (
|
||||
<>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||
onClick={onUndo}
|
||||
disabled={past.length === 0}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||
onClick={onRedo}
|
||||
disabled={future.length === 0}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
{isRack && <RackToolbarActions />}
|
||||
{!isRack && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||
<LayoutDashboard size={14} /> Auto Layout
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||
<Palette size={14} /> Style
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||
@@ -78,15 +92,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
|
||||
<Download size={14} /> Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* PNG capture is DOM-based, so it works for both canvas kinds. */}
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
|
||||
<FileDown size={14} /> PNG
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||
<Table2 size={14} /> MD
|
||||
</Button>
|
||||
{!isRack && (
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||
<Table2 size={14} /> MD
|
||||
</Button>
|
||||
)}
|
||||
{/* Live view reads backend/localStorage canvas; pointless in standalone
|
||||
where the editor already shows the only (localStorage) copy. */}
|
||||
{!STANDALONE && (
|
||||
where the editor already shows the only (localStorage) copy. Rack
|
||||
canvases have no live view yet. */}
|
||||
{!STANDALONE && !isRack && (
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
||||
<Eye size={14} /> View
|
||||
</Button>
|
||||
|
||||
@@ -343,15 +343,58 @@ describe('DetailPanel', () => {
|
||||
})
|
||||
|
||||
describe('Services — add/remove', () => {
|
||||
it('shows add form when Add is clicked', () => {
|
||||
it('opens the Add Service modal when Add is clicked', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Two "Add" buttons: first = properties, second = services
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
expect(screen.getByText('Add Service')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders no service form until the modal is opened', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the icon chosen in the modal', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
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: 'plex' } })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'plex', icon: 'brand:plex' })
|
||||
})
|
||||
|
||||
it('closes the modal after adding', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
|
||||
})
|
||||
|
||||
it('calls updateNode with new service on Add confirm', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
@@ -372,7 +415,7 @@ describe('DetailPanel', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
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: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
|
||||
})
|
||||
@@ -430,17 +473,56 @@ describe('DetailPanel', () => {
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('reorders services on drag and drop', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [
|
||||
{ port: 80, protocol: 'tcp', service_name: 'A' },
|
||||
{ port: 81, protocol: 'tcp', service_name: 'B' },
|
||||
{ port: 82, protocol: 'tcp', service_name: 'C' },
|
||||
] })],
|
||||
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 rows = screen.getAllByTitle('Drag to reorder').map(
|
||||
(g) => g.closest('[draggable="true"]') as HTMLElement,
|
||||
)
|
||||
// Drag last (C) onto first (A) position
|
||||
fireEvent.dragStart(rows[2])
|
||||
fireEvent.dragEnter(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('is not draggable with a single service', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Services — edit', () => {
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
|
||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||
it('opens the Edit Service modal pre-filled when pencil is clicked', () => {
|
||||
setupStore({ services: [{ ...svc, path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||
const editBtn = screen.getByTitle('Edit service')
|
||||
fireEvent.click(editBtn)
|
||||
expect(screen.getByText('Edit Service')).toBeDefined()
|
||||
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
|
||||
expect(nameInput.value).toBe('nginx')
|
||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||
@@ -474,6 +556,46 @@ describe('DetailPanel', () => {
|
||||
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
|
||||
})
|
||||
|
||||
it('keeps fields the modal does not own, like category', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [{ ...svc, category: 'web' }] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Edit service'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0].category).toBe('web')
|
||||
})
|
||||
|
||||
it('saves a brand icon picked while editing', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [svc] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Edit service'))
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'nginx' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nginx' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0].icon).toBe('brand:nginx')
|
||||
})
|
||||
|
||||
it('cancels edit without updating', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
@@ -548,6 +670,25 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText('8080/tcp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders a brand icon image when the service carries one', () => {
|
||||
setupStore({ services: [{ port: 32400, protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const img = screen.getByAltText('plex') as HTMLImageElement
|
||||
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
|
||||
})
|
||||
|
||||
it('renders no icon image when the service has no icon', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no icon image for an unresolvable legacy icon key', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx', icon: 'bar-chart-2' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders path label when path is set', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Rack header actions. The Unplug button is the visible half of cable
|
||||
* selection — the keyboard shortcut alone would be undiscoverable.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { RackToolbarActions } from '../RackToolbarActions'
|
||||
import { useRackStore } from '@/rack/store'
|
||||
|
||||
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||
|
||||
beforeEach(() => {
|
||||
useRackStore.getState().loadDemo()
|
||||
})
|
||||
|
||||
const unplug = () => screen.queryByRole('button', { name: /unplug/i })
|
||||
|
||||
describe('RackToolbarActions cable visibility', () => {
|
||||
it('shows the active visibility on the trigger', () => {
|
||||
useRackStore.getState().setCableVisibility('always')
|
||||
render(<RackToolbarActions />)
|
||||
expect(screen.getByLabelText('Cable visibility')).toHaveTextContent('Cables always')
|
||||
})
|
||||
|
||||
it('applies the picked visibility to the store', async () => {
|
||||
// base-ui commits its selection on real pointer sequences, not a bare click.
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 })
|
||||
render(<RackToolbarActions />)
|
||||
|
||||
await user.click(screen.getByLabelText('Cable visibility'))
|
||||
await user.click(await screen.findByRole('option', { name: 'Cables hidden' }))
|
||||
|
||||
expect(useRackStore.getState().cableVisibility).toBe('hidden')
|
||||
})
|
||||
|
||||
it('carries no cable type filter — copper and fibre always draw together', () => {
|
||||
render(<RackToolbarActions />)
|
||||
expect(screen.queryByLabelText('Cable type filter')).toBeNull()
|
||||
expect(screen.queryByText('All types')).toBeNull()
|
||||
expect('cableTypeFilter' in useRackStore.getState()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RackToolbarActions cable selection', () => {
|
||||
it('hides Unplug outside patch mode', () => {
|
||||
useRackStore.getState().selectCable(useRackStore.getState().cables[0].id)
|
||||
render(<RackToolbarActions />)
|
||||
expect(unplug()).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Unplug in patch mode while no cable is selected', () => {
|
||||
useRackStore.getState().toggleCableMode()
|
||||
render(<RackToolbarActions />)
|
||||
expect(unplug()).toBeNull()
|
||||
})
|
||||
|
||||
it('unplugs the selected cable', () => {
|
||||
useRackStore.getState().toggleCableMode()
|
||||
const cable = useRackStore.getState().cables[0]
|
||||
useRackStore.getState().selectCable(cable.id)
|
||||
render(<RackToolbarActions />)
|
||||
|
||||
fireEvent.click(unplug()!)
|
||||
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
|
||||
expect(unplug()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -13,6 +13,9 @@ vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/authStore')
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
logout: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
@@ -75,7 +78,7 @@ const defaultProps = {
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenHistory: vi.fn(),
|
||||
onOpenPending: vi.fn(),
|
||||
onOpenInventory: vi.fn(),
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -241,16 +244,16 @@ describe('Sidebar', () => {
|
||||
|
||||
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||
|
||||
it('calls onOpenPending with pending status when Device Inventory is clicked', () => {
|
||||
it('calls onOpenInventory with pending status when Device Inventory is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Device Inventory'))
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||
expect(defaultProps.onOpenInventory).toHaveBeenCalledWith(undefined, 'pending')
|
||||
})
|
||||
|
||||
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
|
||||
it('calls onOpenInventory with hidden status when Hidden Devices is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||
expect(defaultProps.onOpenInventory).toHaveBeenCalledWith(undefined, 'hidden')
|
||||
})
|
||||
|
||||
it('calls onOpenHistory when Scan History nav item is clicked', () => {
|
||||
@@ -272,10 +275,130 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when Logout is clicked', () => {
|
||||
it('ends the backend session before clearing local auth state', async () => {
|
||||
const { authApi } = await import('@/api/client')
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Logout'))
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(authApi.logout).toHaveBeenCalledOnce()
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Walkthrough anchor ─────────────────────────────────────────────────────
|
||||
|
||||
it('carries the walkthrough anchor the rack tour step spotlights', async () => {
|
||||
const { useDesignStore } = await import('@/stores/designStore')
|
||||
const { STEPS } = await import('@/walkthrough/steps')
|
||||
const before = useDesignStore.getState()
|
||||
useDesignStore.setState({
|
||||
designs: [{
|
||||
id: 'd1', name: 'Main', design_type: 'network', icon: null,
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
}],
|
||||
activeDesignId: 'd1',
|
||||
loaded: true,
|
||||
})
|
||||
|
||||
const { unmount } = render(<Sidebar {...defaultProps} />)
|
||||
|
||||
const step = STEPS.find((s) => s.id === 'rack')
|
||||
expect(step?.anchor).toBe('[data-tour="canvas-switcher"]')
|
||||
// The overlay resolves that selector against the DOM — an anchor the sidebar
|
||||
// stops carrying leaves the step spotlighting nothing.
|
||||
expect(document.querySelector(step!.anchor!)).toBeInTheDocument()
|
||||
|
||||
// Unmount before restoring, so the store reset never re-renders the sidebar.
|
||||
unmount()
|
||||
useDesignStore.setState({
|
||||
designs: before.designs,
|
||||
activeDesignId: before.activeDesignId,
|
||||
loaded: before.loaded,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Rack canvas ────────────────────────────────────────────────────────────────
|
||||
// Same component, a different half of it: the rack branch swaps the view label,
|
||||
// the actions, the body and the footer. A mocked canvas store would happily hide
|
||||
// all of that, so each branch is asserted rather than assumed.
|
||||
describe('Sidebar (rack canvas)', () => {
|
||||
let restore: { designs: Design[]; activeDesignId: string | null; activeDesignType: string }
|
||||
|
||||
beforeEach(async () => {
|
||||
mockStore()
|
||||
mockAuth()
|
||||
const { useDesignStore } = await import('@/stores/designStore')
|
||||
const { useRackStore } = await import('@/rack/store')
|
||||
const s = useDesignStore.getState()
|
||||
restore = { designs: s.designs, activeDesignId: s.activeDesignId, activeDesignType: s.activeDesignType }
|
||||
useDesignStore.setState({ activeDesignType: 'rack' })
|
||||
useRackStore.getState().loadDemo()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
const { useDesignStore } = await import('@/stores/designStore')
|
||||
useDesignStore.setState(restore)
|
||||
})
|
||||
|
||||
it('swaps the view and the actions for their rack equivalents', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
expect(screen.getByText('Rack view')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add Device')).toBeInTheDocument()
|
||||
expect(screen.getByText('Save Rack')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Canvas')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Add Zone')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Add Text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the logical-canvas discovery actions', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// Nothing here is scanned onto a rack — those actions belong to a diagram.
|
||||
expect(screen.queryByText('Scan Network')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Zigbee Import')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Z-Wave Import')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Proxmox Import')).not.toBeInTheDocument()
|
||||
// The Device Inventory stays: a rack mounts what the scans found.
|
||||
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the device editor from + Device', async () => {
|
||||
const { useRackStore } = await import('@/rack/store')
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Add Device'))
|
||||
expect(useRackStore.getState().deviceEditor).toEqual({ deviceId: null })
|
||||
})
|
||||
|
||||
it('counts capacity instead of reachability', async () => {
|
||||
const { useRackStore } = await import('@/rack/store')
|
||||
const { freeUnits } = await import('@/rack/layout')
|
||||
const { racks, devices, cables } = useRackStore.getState()
|
||||
const free = racks.reduce((sum, r) => sum + freeUnits(r, devices), 0)
|
||||
const total = racks.reduce((sum, r) => sum + r.uHeight, 0)
|
||||
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Racks').nextElementSibling).toHaveTextContent(String(racks.length))
|
||||
expect(screen.getByText('Mounted').nextElementSibling).toHaveTextContent(String(devices.length))
|
||||
expect(screen.getByText('Cables').nextElementSibling).toHaveTextContent(String(cables.length))
|
||||
expect(screen.getByText(`${free}U / ${total}U`)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Online')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tracks the rack store for the unsaved badge, not the canvas store', async () => {
|
||||
const { useRackStore } = await import('@/rack/store')
|
||||
useRackStore.setState({ hasUnsavedChanges: true })
|
||||
// The canvas store — mocked clean — must not be what answers here.
|
||||
const { container } = render(<Sidebar {...defaultProps} />)
|
||||
expect(container.querySelector('.bg-\\[\\#e3b341\\]')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
useRackStore.setState({ hasUnsavedChanges: false })
|
||||
const { container: clean } = render(<Sidebar {...defaultProps} />)
|
||||
expect(clean.querySelector('.bg-\\[\\#e3b341\\]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null,
|
||||
}))
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
|
||||
vi.mock('@/components/modals/InventoryDeviceModal', () => ({ InventoryDeviceModal: () => null }))
|
||||
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
|
||||
|
||||
vi.mock('@/hooks/useLatestRelease', () => ({
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ProxmoxImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ProxmoxNode[], edges: ProxmoxEdge[]) => void
|
||||
onPendingImported?: () => void
|
||||
onInventoryImported?: () => void
|
||||
}
|
||||
|
||||
type ImportMode = 'pending' | 'canvas'
|
||||
@@ -53,7 +53,7 @@ const DEVICE_TYPE_COLOR: Record<ProxmoxNodeType, string> = {
|
||||
lxc: '#39d353',
|
||||
}
|
||||
|
||||
export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ProxmoxImportModalProps) {
|
||||
export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onInventoryImported }: ProxmoxImportModalProps) {
|
||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||
const [connectionMsg, setConnectionMsg] = useState('')
|
||||
@@ -101,7 +101,7 @@ export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onPendingImpo
|
||||
if (importMode === 'pending') {
|
||||
await proxmoxApi.importToPending(buildPayload())
|
||||
toast.success('Proxmox import started — track progress in Scan History')
|
||||
onPendingImported?.()
|
||||
onInventoryImported?.()
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await proxmoxApi.importNetwork(buildPayload())
|
||||
|
||||
@@ -18,7 +18,7 @@ const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddToCanvas: vi.fn(),
|
||||
onPendingImported: vi.fn(),
|
||||
onInventoryImported: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleNodes = [
|
||||
@@ -44,7 +44,7 @@ describe('ProxmoxImportModal', () => {
|
||||
vi.mocked(toast.info).mockReset()
|
||||
defaultProps.onClose.mockReset()
|
||||
defaultProps.onAddToCanvas.mockReset()
|
||||
defaultProps.onPendingImported.mockReset()
|
||||
defaultProps.onInventoryImported.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
@@ -85,7 +85,7 @@ describe('ProxmoxImportModal', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||
await waitFor(() => expect(proxmoxApi.importToPending).toHaveBeenCalled())
|
||||
expect(defaultProps.onPendingImported).toHaveBeenCalled()
|
||||
expect(defaultProps.onInventoryImported).toHaveBeenCalled()
|
||||
expect(proxmoxApi.importNetwork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official Homelable mark — kept in sync with `docs/logo/icon.svg`.
|
||||
* Wordmark colors follow `docs/logo/logo-md.svg` (Home = #e6edf3, lable = #00d4ff).
|
||||
*/
|
||||
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
|
||||
// Colons are legal in a URL fragment but confuse some tooling — strip them.
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const bgGlow = `homelable-bg-glow-${uid}`;
|
||||
const nodeGlow = `homelable-node-glow-${uid}`;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<svg
|
||||
@@ -13,32 +24,73 @@ export function Logo({ size = 32, showText = true, className = '' }: LogoProps)
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-testid="logo-mark"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={bgGlow} cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.08" />
|
||||
<stop offset="100%" stopColor="#0d1117" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<filter id={nodeGlow}>
|
||||
<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(#${bgGlow})`} />
|
||||
|
||||
{/* House body */}
|
||||
<path
|
||||
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||
d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
|
||||
<circle cx="22" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="42" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="24" r="2" fill="#a855f7" />
|
||||
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
|
||||
|
||||
{/* Floor line (subtle) */}
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" strokeWidth="0.5" opacity="0.25" />
|
||||
|
||||
{/* Door */}
|
||||
<rect
|
||||
x="27"
|
||||
y="40"
|
||||
width="10"
|
||||
height="13"
|
||||
rx="1.5"
|
||||
fill="#0d1117"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1"
|
||||
opacity="0.9"
|
||||
/>
|
||||
|
||||
{/* Network lines (drawn under nodes) */}
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
|
||||
{/* Center hub */}
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter={`url(#${nodeGlow})`} />
|
||||
|
||||
{/* Peripheral nodes */}
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter={`url(#${nodeGlow})`} />
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="font-bold tracking-tight"
|
||||
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<span style={{ color: '#00d4ff' }}>Home</span>
|
||||
<span style={{ color: '#ffffff' }}>lable</span>
|
||||
<span style={{ color: '#e6edf3' }}>Home</span>
|
||||
<span style={{ color: '#00d4ff' }}>lable</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createElement } from 'react'
|
||||
import { resolveCustomIcon } from '@/utils/nodeIcons'
|
||||
|
||||
interface ServiceIconProps {
|
||||
/** Icon key on the service — a lucide registry key or `brand:<slug>`. */
|
||||
iconKey?: string
|
||||
size?: number
|
||||
className?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a service's icon, or nothing when the service has no icon (or carries
|
||||
* a key we can't resolve — e.g. a legacy scanner signature name).
|
||||
*/
|
||||
export function ServiceIcon({ iconKey, size = 12, className, color }: ServiceIconProps) {
|
||||
const resolved = resolveCustomIcon(iconKey)
|
||||
if (!resolved) return null
|
||||
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', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return createElement(resolved.icon, { size, className, color })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Logo } from '../Logo'
|
||||
|
||||
describe('Logo', () => {
|
||||
it('renders the official mark (house + network nodes), not a placeholder icon', () => {
|
||||
render(<Logo />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
// Canonical house outline from docs/logo/icon.svg
|
||||
expect(svg.querySelector('path')?.getAttribute('d'))
|
||||
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
|
||||
// Center hub + 3 peripheral nodes + hub glow + 2 background circles
|
||||
expect(svg.querySelectorAll('circle').length).toBe(7)
|
||||
})
|
||||
|
||||
it('uses the canonical wordmark colors (Home #e6edf3 / lable #00d4ff)', () => {
|
||||
render(<Logo showText />)
|
||||
expect(screen.getByText('Home')).toHaveStyle({ color: '#e6edf3' })
|
||||
expect(screen.getByText('lable')).toHaveStyle({ color: '#00d4ff' })
|
||||
})
|
||||
|
||||
it('hides the wordmark when showText is false', () => {
|
||||
render(<Logo showText={false} />)
|
||||
expect(screen.queryByText('Home')).toBeNull()
|
||||
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scales the svg to the requested size', () => {
|
||||
render(<Logo size={64} />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
expect(svg.getAttribute('width')).toBe('64')
|
||||
expect(svg.getAttribute('height')).toBe('64')
|
||||
})
|
||||
|
||||
it('generates unique gradient/filter ids per instance — two logos can coexist', () => {
|
||||
// Sidebar and Toolbar both render a Logo at the same time; duplicate SVG
|
||||
// defs ids would make one instance reference the other's gradient.
|
||||
render(<><Logo /><Logo /></>)
|
||||
const [first, second] = screen.getAllByTestId('logo-mark')
|
||||
const idOf = (svg: HTMLElement) => svg.querySelector('radialGradient')!.getAttribute('id')
|
||||
expect(idOf(first)).not.toBe(idOf(second))
|
||||
|
||||
// Each instance must point at its own gradient
|
||||
const gradientRef = (svg: HTMLElement) =>
|
||||
svg.querySelectorAll('circle')[1].getAttribute('fill')
|
||||
expect(gradientRef(first)).toBe(`url(#${idOf(first)!})`)
|
||||
expect(gradientRef(second)).toBe(`url(#${idOf(second)!})`)
|
||||
})
|
||||
|
||||
it('marks the svg aria-hidden — the adjacent wordmark carries the name', () => {
|
||||
render(<Logo />)
|
||||
expect(screen.getByTestId('logo-mark')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('forwards className to the wrapper', () => {
|
||||
const { container } = render(<Logo className="my-class" />)
|
||||
expect(container.firstElementChild).toHaveClass('my-class')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'
|
||||
import { render, cleanup, waitFor } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from '../sonner'
|
||||
|
||||
beforeAll(() => {
|
||||
// sonner reads matchMedia for theme detection; jsdom doesn't provide it.
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('Toaster', () => {
|
||||
it('renders error toasts on a red surface so failures are visible', async () => {
|
||||
render(<Toaster />)
|
||||
toast.error('Save failed')
|
||||
// The style (with the CSS vars) lands on the toast list, mounted on first toast.
|
||||
const list = await waitFor(() => {
|
||||
const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null
|
||||
expect(el).not.toBeNull()
|
||||
return el!
|
||||
})
|
||||
const style = list.getAttribute('style') ?? ''
|
||||
expect(style).toContain('--error-bg: #f85149')
|
||||
expect(style).toContain('--error-text: #ffffff')
|
||||
|
||||
// richColors must be on for sonner to apply the per-type surface, and the
|
||||
// toast itself must be tagged as an error so it picks up --error-bg.
|
||||
const item = await waitFor(() => {
|
||||
const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null
|
||||
expect(el).not.toBeNull()
|
||||
return el!
|
||||
})
|
||||
expect(item.getAttribute('data-rich-colors')).toBe('true')
|
||||
expect(item.getAttribute('data-type')).toBe('error')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
richColors
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
@@ -34,6 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
// richColors (below) is required for sonner to honor per-type surfaces.
|
||||
// Errors get a red surface so failures (e.g. a save while the backend
|
||||
// is down) stand out instead of reading like a normal toast...
|
||||
"--error-bg": "#f85149",
|
||||
"--error-text": "#ffffff",
|
||||
"--error-border": "#da3633",
|
||||
// ...while success/info/warning stay on the neutral popover surface so
|
||||
// the rest of the toast UX is unchanged.
|
||||
"--success-bg": "var(--popover)",
|
||||
"--success-text": "var(--popover-foreground)",
|
||||
"--success-border": "var(--border)",
|
||||
"--info-bg": "var(--popover)",
|
||||
"--info-text": "var(--popover-foreground)",
|
||||
"--info-border": "var(--border)",
|
||||
"--warning-bg": "var(--popover)",
|
||||
"--warning-text": "var(--popover-foreground)",
|
||||
"--warning-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ZigbeeImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||
onPendingImported?: (
|
||||
onInventoryImported?: (
|
||||
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||
) => void
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const DEVICE_TYPE_COLOR = {
|
||||
zigbee_enddevice: '#e3b341',
|
||||
} as const
|
||||
|
||||
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
|
||||
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onInventoryImported }: ZigbeeImportModalProps) {
|
||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||
const [connectionMsg, setConnectionMsg] = useState('')
|
||||
@@ -140,7 +140,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImpor
|
||||
if (importMode === 'pending') {
|
||||
await zigbeeApi.importToPending(buildPayload())
|
||||
toast.success('Zigbee import started — track progress in Scan History')
|
||||
onPendingImported?.(null)
|
||||
onInventoryImported?.(null)
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await zigbeeApi.importNetwork(buildPayload())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user