Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 332b6fb4f3 | |||
| a8d365c1ee | |||
| 6e21218f05 | |||
| fb33e5cb16 | |||
| 52de302f2b | |||
| f2162a663a | |||
| 1f2d298419 | |||
| d1c052c9e8 | |||
| 79cac7d80f | |||
| c356a65a5f | |||
| ca171089c2 | |||
| e153b42b2e | |||
| 20dc9069d7 | |||
| c7b4db206b | |||
| a05c82b4f6 | |||
| 9a066a3d6e | |||
| f0af367c34 | |||
| 46185d187f | |||
| fd2c50c1aa | |||
| 8b9972ca78 | |||
| 313feb8ceb | |||
| 23673eb0ca | |||
| cbc2bc03c2 | |||
| 7e99d77edc | |||
| da2c1c356a | |||
| 3cedb40d17 | |||
| 7fdce6af37 | |||
| c1b0c42f0c | |||
| 2a8c9d618b | |||
| e811d83ceb | |||
| c8d25c2383 | |||
| 3020b400ed | |||
| 8f8d9fe209 | |||
| 612280e924 | |||
| 19b7d38ec0 | |||
| b3f7c38c67 | |||
| 5b20d4fd75 | |||
| 16e2dedab0 | |||
| c8ed63712d | |||
| 10fdab52af | |||
| eddf752b50 | |||
| 13420bead8 | |||
| d41896fadf | |||
| f749b38edc | |||
| ecf3cbdfe4 | |||
| 5b08d57124 | |||
| 9b8f15bec3 | |||
| 8faf5c1c79 | |||
| c6076d133a | |||
| 662a240b05 | |||
| dfeb981e40 | |||
| 430c14ed5e | |||
| 234019c7b7 | |||
| d7ab4ba49a | |||
| 96107cc657 | |||
| c68084b751 | |||
| d01630bf37 | |||
| b6423c0115 | |||
| 9d1ebd6c6e | |||
| 44e0029f2b | |||
| b20c47a0a5 | |||
| be5771f7d0 | |||
| 5d62df45f7 | |||
| 83b296747c | |||
| 77718a5239 | |||
| 40f3edce68 | |||
| 1efc1c95ca | |||
| b70ee78d25 | |||
| 9efaaccb51 | |||
| 6e5bda5860 | |||
| 0796c96fc1 | |||
| 6fba0cdec4 | |||
| 2a4d109ee6 | |||
| 96bb048228 | |||
| 88c006764a | |||
| 9c3dace12f | |||
| ec53363fdb | |||
| 0e260ede73 | |||
| 60902167c0 | |||
| 592e7865c5 | |||
| f082c295fd | |||
| 10b981ad1d | |||
| 2058e453ff | |||
| b776b6b8f1 | |||
| fe3787f0cd | |||
| 7ea1fc2d69 | |||
| 71cc183efb | |||
| 7873f8b94f | |||
| b34b40794d | |||
| ed310f0d8d | |||
| 2db173cd56 | |||
| d032103b7d | |||
| bcc488993d | |||
| aa0a97c245 | |||
| a39d4d4406 | |||
| 7c2417f5a9 | |||
| ea66e6c9c7 |
@@ -14,6 +14,15 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
|
|||||||
# Scanner — JSON array of CIDR ranges to scan
|
# Scanner — JSON array of CIDR ranges to scan
|
||||||
SCANNER_RANGES=["192.168.1.0/24"]
|
SCANNER_RANGES=["192.168.1.0/24"]
|
||||||
|
|
||||||
|
# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
|
||||||
|
# custom ports. Defaults below are overridable per-scan from the scan dialog.
|
||||||
|
# SCANNER_HTTP_RANGES: JSON array of port specs, each a single port "N" or an
|
||||||
|
# inclusive range "N-M" (1–65535, N <= M). Not CIDRs, not bare ints.
|
||||||
|
# Example: SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
||||||
|
SCANNER_HTTP_RANGES=[]
|
||||||
|
SCANNER_HTTP_PROBE_ENABLED=false
|
||||||
|
SCANNER_HTTP_VERIFY_TLS=false
|
||||||
|
|
||||||
# Status checker interval in seconds
|
# Status checker interval in seconds
|
||||||
STATUS_CHECKER_INTERVAL=60
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smoke-and-integration:
|
smoke-and-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-scripts:
|
lint-scripts:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * 1' # Weekly on Monday
|
- cron: '0 9 * * 1' # Weekly on Monday
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secrets-scan:
|
secrets-scan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ htmlcov/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db.back
|
*.db.back
|
||||||
|
*.db.back-*
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
|
|||||||
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
||||||
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
||||||
|
|
||||||
|
### Deep scan (custom ports)
|
||||||
|
|
||||||
|
By default the scanner only probes nmap's standard port set. To fingerprint services on non-standard ports, enable the deep scan via `.env` (all options are overridable per-scan from the scan dialog):
|
||||||
|
|
||||||
|
```env
|
||||||
|
# JSON array of port specs — each entry is a single port "N" or an inclusive
|
||||||
|
# range "N-M" (1–65535, N <= M). These are ports, not CIDRs or bare integers.
|
||||||
|
SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
||||||
|
SCANNER_HTTP_PROBE_ENABLED=true # send an HTTP probe to those ports for service ID
|
||||||
|
SCANNER_HTTP_VERIFY_TLS=false # verify TLS certs on the HTTP probe
|
||||||
|
```
|
||||||
|
|
||||||
|
The listed ports are appended to nmap's `-p` spec. Invalid entries (out-of-range, malformed, or reversed ranges) are silently skipped.
|
||||||
|
|
||||||
### macOS / root privileges
|
### macOS / root privileges
|
||||||
|
|
||||||
Some nmap scan types (SYN scan, OS detection) require root. If the scan fails with a permissions error, run it manually with sudo using the included script:
|
Some nmap scan types (SYN scan, OS detection) require root. If the scan fails with a permissions error, run it manually with sudo using the included script:
|
||||||
@@ -109,6 +123,37 @@ LQI (Link Quality Indicator) is stored as a node property.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Z-Wave Import
|
||||||
|
|
||||||
|
Homelable can also import your **Z-Wave** network from **Z-Wave JS UI** (formerly `zwavejs2mqtt`) over the same MQTT broker, dropping each node on the canvas as a typed node.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||||
|
- **Z-Wave JS UI** connected to the broker with its MQTT gateway enabled and at least one node included
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. Click **Z-Wave Import** in the left sidebar (below "Zigbee Import")
|
||||||
|
2. Enter your broker host, port (default `1883`), optional credentials, MQTT prefix (default `zwave`), and gateway name (default `zwavejs2mqtt`)
|
||||||
|
3. Click **Test Connection** to verify reachability
|
||||||
|
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Devices**
|
||||||
|
5. Select the devices you want from the grouped list (Controller / Router / End Device) and click **Add N to Canvas**
|
||||||
|
|
||||||
|
### Node Types
|
||||||
|
|
||||||
|
| Type | Z-Wave Role | Icon |
|
||||||
|
|------|-------------|------|
|
||||||
|
| `zwave_coordinator` | Controller | Network hub |
|
||||||
|
| `zwave_router` | Routing (mains-powered) node | Radio |
|
||||||
|
| `zwave_enddevice` | End Device (battery) | Antenna |
|
||||||
|
|
||||||
|
Hierarchy is set automatically: controller → routers → end devices (`parent_id`), derived from each node's neighbor list. Z-Wave has no LQI, so that property is omitted.
|
||||||
|
|
||||||
|
> **Full documentation:** [docs/zwave-import.md](./docs/zwave-import.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Live View (read-only public canvas)
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import Edge
|
from app.db.models import Design, Edge
|
||||||
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -18,7 +18,14 @@ async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
|
|||||||
|
|
||||||
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
|
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
|
||||||
edge = Edge(**body.model_dump())
|
data = body.model_dump()
|
||||||
|
# Same reconciliation as nodes: clients omitting design_id (MCP write tools)
|
||||||
|
# would create design_id=null edges that never render until a restart.
|
||||||
|
# Fall back to the first design so the edge attaches to a canvas.
|
||||||
|
if data.get("design_id") is None:
|
||||||
|
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
|
data["design_id"] = first_design.id if first_design else None
|
||||||
|
edge = Edge(**data)
|
||||||
db.add(edge)
|
db.add(edge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(edge)
|
await db.refresh(edge)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import Node
|
from app.db.models import Design, Node
|
||||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -18,7 +18,15 @@ async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
|
|||||||
|
|
||||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
||||||
node = Node(**body.model_dump())
|
data = body.model_dump()
|
||||||
|
# Attach to a design so the node lands on a canvas. Clients that don't send a
|
||||||
|
# design_id (e.g. the MCP write tools) would otherwise create design_id=null
|
||||||
|
# nodes that exist in the DB but never render in the UI until a container
|
||||||
|
# restart reconciles them. Fall back to the first design, matching bulk-approve.
|
||||||
|
if data.get("design_id") is None:
|
||||||
|
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
|
data["design_id"] = first_design.id if first_design else None
|
||||||
|
node = Node(**data)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(node)
|
await db.refresh(node)
|
||||||
|
|||||||
+242
-29
@@ -1,6 +1,7 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
@@ -14,10 +15,30 @@ 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, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import request_cancel, run_scan
|
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||||
from app.services.zigbee_service import build_zigbee_properties
|
from app.services.zigbee_service import build_zigbee_properties
|
||||||
|
from app.services.zwave_service import build_zwave_properties
|
||||||
|
|
||||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||||
|
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _wireless_properties(
|
||||||
|
node_type: str | None,
|
||||||
|
ieee: str | None,
|
||||||
|
vendor: str | None,
|
||||||
|
model: str | None,
|
||||||
|
lqi: int | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build the right property rows for a mesh device (Z-Wave has no LQI)."""
|
||||||
|
if node_type in _ZWAVE_TYPES:
|
||||||
|
return build_zwave_properties(ieee, vendor, model)
|
||||||
|
return build_zigbee_properties(ieee, vendor, model, lqi)
|
||||||
|
|
||||||
|
|
||||||
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
||||||
@@ -50,10 +71,26 @@ def merge_mac_property(
|
|||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
class BulkActionRequest(BaseModel):
|
||||||
device_ids: list[str]
|
device_ids: list[str]
|
||||||
|
# Target design for approved nodes. Falls back to the first design when
|
||||||
|
# omitted (keeps older clients working), but the UI should send the active
|
||||||
|
# design so approved devices land on the canvas the user is looking at.
|
||||||
|
design_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_port_ranges(v: list[str]) -> list[str]:
|
||||||
|
for r in v:
|
||||||
|
if not _valid_port_range(r.strip()):
|
||||||
|
raise ValueError(f"Invalid port range: {r!r}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
|
"""Persisted scan defaults (Options page). Deep-scan fields are optional."""
|
||||||
|
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
|
http_ranges: list[str] = []
|
||||||
|
http_probe_enabled: bool = False
|
||||||
|
verify_tls: bool = False
|
||||||
|
|
||||||
@field_validator("ranges")
|
@field_validator("ranges")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -65,15 +102,35 @@ class ScanConfig(BaseModel):
|
|||||||
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("http_ranges")
|
||||||
|
@classmethod
|
||||||
|
def validate_http_ranges(cls, v: list[str]) -> list[str]:
|
||||||
|
return _check_port_ranges(v)
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerScanRequest(BaseModel):
|
||||||
|
"""Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
|
||||||
|
|
||||||
|
http_ranges: list[str] | None = None
|
||||||
|
http_probe_enabled: bool | None = None
|
||||||
|
verify_tls: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("http_ranges")
|
||||||
|
@classmethod
|
||||||
|
def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
|
||||||
|
return None if v is None else _check_port_ranges(v)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
async def _background_scan(
|
||||||
|
run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
|
||||||
|
) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
await run_scan(ranges, db, run_id)
|
await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Scan run %s failed unexpectedly", run_id)
|
logger.exception("Scan run %s failed unexpectedly", run_id)
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -83,18 +140,38 @@ async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
|
||||||
|
"""Merge per-scan overrides over persisted settings defaults."""
|
||||||
|
p = payload or TriggerScanRequest()
|
||||||
|
return DeepScanOptions(
|
||||||
|
http_ranges=(
|
||||||
|
p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
|
||||||
|
),
|
||||||
|
http_probe_enabled=(
|
||||||
|
p.http_probe_enabled
|
||||||
|
if p.http_probe_enabled is not None
|
||||||
|
else settings.scanner_http_probe_enabled
|
||||||
|
),
|
||||||
|
verify_tls=(
|
||||||
|
p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger", response_model=ScanRunResponse)
|
@router.post("/trigger", response_model=ScanRunResponse)
|
||||||
async def trigger_scan(
|
async def trigger_scan(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
payload: TriggerScanRequest | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> ScanRun:
|
) -> ScanRun:
|
||||||
ranges = settings.scanner_ranges
|
ranges = settings.scanner_ranges
|
||||||
|
deep_scan = _resolve_deep_scan(payload)
|
||||||
run = ScanRun(status="running", ranges=ranges)
|
run = ScanRun(status="running", ranges=ranges)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(run)
|
await db.refresh(run)
|
||||||
background_tasks.add_task(_background_scan, run.id, ranges)
|
background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
|
||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
@@ -114,13 +191,98 @@ async def stop_scan(
|
|||||||
if run.status != "running":
|
if run.status != "running":
|
||||||
raise HTTPException(status_code=409, detail="Scan is not running")
|
raise HTTPException(status_code=409, detail="Scan is not running")
|
||||||
request_cancel(run_id)
|
request_cancel(run_id)
|
||||||
|
# Flip status eagerly so the UI reflects the stop immediately, instead of
|
||||||
|
# waiting for run_scan to reach its next cancellation checkpoint (which may
|
||||||
|
# be blocked inside a long nmap call). run_scan converges to the same state.
|
||||||
|
run.status = "cancelled"
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
return {"stopping": True}
|
return {"stopping": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
||||||
|
"""Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
|
||||||
|
present = [v for v in values if v is not None]
|
||||||
|
if not present:
|
||||||
|
return None
|
||||||
|
return max(present) if newest else min(present)
|
||||||
|
|
||||||
|
|
||||||
|
async def _canvas_correlation(
|
||||||
|
db: AsyncSession, devices: list[PendingDevice]
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Correlate each device to existing canvas nodes by ``ieee_address`` or ``ip``.
|
||||||
|
|
||||||
|
Returns, per device id: the number of distinct canvases (designs) it appears
|
||||||
|
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
||||||
|
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
|
||||||
|
(node counts are small for a homelab), so no N+1 per device.
|
||||||
|
"""
|
||||||
|
if not devices:
|
||||||
|
return {}
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
Node.ip,
|
||||||
|
Node.ieee_address,
|
||||||
|
Node.design_id,
|
||||||
|
Node.created_at,
|
||||||
|
Node.last_scan,
|
||||||
|
Node.updated_at,
|
||||||
|
Node.last_seen,
|
||||||
|
).where(Node.design_id.isnot(None))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
# Index matching nodes by ip and by ieee so a device can look up both.
|
||||||
|
by_ip: dict[str, list[Any]] = {}
|
||||||
|
by_ieee: dict[str, list[Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if row.ip:
|
||||||
|
by_ip.setdefault(row.ip, []).append(row)
|
||||||
|
if row.ieee_address:
|
||||||
|
by_ieee.setdefault(row.ieee_address, []).append(row)
|
||||||
|
|
||||||
|
info: dict[str, dict[str, Any]] = {}
|
||||||
|
for d in devices:
|
||||||
|
matched = []
|
||||||
|
if d.ieee_address:
|
||||||
|
matched += by_ieee.get(d.ieee_address, [])
|
||||||
|
if d.ip:
|
||||||
|
matched += by_ip.get(d.ip, [])
|
||||||
|
# De-duplicate nodes matched by both ip and ieee.
|
||||||
|
matched = list({id(m): m for m in matched}.values())
|
||||||
|
designs = {m.design_id for m in matched}
|
||||||
|
info[d.id] = {
|
||||||
|
"canvas_count": len(designs),
|
||||||
|
"node_created_at": _agg([m.created_at for m in matched], newest=False),
|
||||||
|
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
|
||||||
|
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
||||||
|
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_canvas_counts(
|
||||||
|
db: AsyncSession, devices: list[PendingDevice]
|
||||||
|
) -> list[PendingDevice]:
|
||||||
|
"""Attach transient canvas count + linked-node timestamps for the response."""
|
||||||
|
info = await _canvas_correlation(db, devices)
|
||||||
|
for d in devices:
|
||||||
|
meta = info.get(d.id, {})
|
||||||
|
d.canvas_count = meta.get("canvas_count", 0)
|
||||||
|
d.node_created_at = meta.get("node_created_at")
|
||||||
|
d.node_last_scan = meta.get("node_last_scan")
|
||||||
|
d.node_last_modified = meta.get("node_last_modified")
|
||||||
|
d.node_last_seen = meta.get("node_last_seen")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
# Inventory: every scanned device except the user-hidden ones. Approved devices
|
||||||
return list(result.scalars().all())
|
# stay listed so they keep showing with a canvas-presence badge.
|
||||||
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
|
||||||
|
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/pending", response_model=dict)
|
@router.delete("/pending", response_model=dict)
|
||||||
@@ -137,7 +299,7 @@ async def clear_pending(
|
|||||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||||
return list(result.scalars().all())
|
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/bulk-approve", response_model=dict)
|
@router.post("/pending/bulk-approve", response_model=dict)
|
||||||
@@ -146,47 +308,80 @@ async def bulk_approve_devices(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# Determine target design (use first design as fallback)
|
# Target the design the user is on; fall back to the first design.
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
default_design_id = payload.design_id
|
||||||
default_design_id = first_design.id if first_design else None
|
if default_design_id is None:
|
||||||
|
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
|
default_design_id = first_design.id if first_design else None
|
||||||
|
|
||||||
|
# Accept every selected device that isn't user-hidden. We intentionally do NOT
|
||||||
|
# filter on status == "pending": a device's status is global, but canvas
|
||||||
|
# membership is per-design. A device approved onto another canvas (or whose
|
||||||
|
# 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(
|
result = await db.execute(
|
||||||
select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
PendingDevice.id.in_(payload.device_ids),
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
PendingDevice.status == "pending",
|
PendingDevice.status != "hidden",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
devices = result.scalars().all()
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
# What already sits on the target canvas, so we skip devices already placed
|
||||||
|
# here (by ip or ieee_address) instead of creating duplicate nodes.
|
||||||
|
existing = (
|
||||||
|
await db.execute(
|
||||||
|
select(Node.ip, Node.ieee_address).where(Node.design_id == default_design_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
placed_ips = {ip for ip, _ in existing if ip}
|
||||||
|
placed_ieee = {ieee for _, ieee in existing if ieee}
|
||||||
|
|
||||||
created_nodes: list[Node] = []
|
created_nodes: list[Node] = []
|
||||||
|
approved_devices: list[PendingDevice] = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
|
already_here = (
|
||||||
|
(device.ip is not None and device.ip in placed_ips)
|
||||||
|
or (device.ieee_address is not None and device.ieee_address in placed_ieee)
|
||||||
|
)
|
||||||
|
if already_here:
|
||||||
|
continue
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
node_type = device.suggested_type or "generic"
|
node_type = device.suggested_type or "generic"
|
||||||
is_zigbee = node_type in _ZIGBEE_TYPES
|
is_wireless = _is_wireless(node_type)
|
||||||
node = Node(
|
node = Node(
|
||||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||||
type=node_type,
|
type=node_type,
|
||||||
ip=device.ip,
|
ip=device.ip,
|
||||||
mac=device.mac,
|
mac=device.mac,
|
||||||
hostname=device.hostname,
|
hostname=device.hostname,
|
||||||
status="online" if is_zigbee else "unknown",
|
status="online" if is_wireless else "unknown",
|
||||||
services=device.services or [],
|
services=device.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=build_zigbee_properties(
|
properties=_wireless_properties(
|
||||||
device.ieee_address, device.vendor, device.model, device.lqi
|
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if is_zigbee else build_mac_property(device.mac),
|
) if is_wireless else build_mac_property(device.mac),
|
||||||
# Default to ping so the status checker actually polls the new node.
|
# Default to ping so the status checker actually polls the new node.
|
||||||
# Without this the scheduler skips it (check_method NULL → no check).
|
# Without this the scheduler skips it (check_method NULL → no check).
|
||||||
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
check_method="none" if is_wireless else ("ping" if device.ip else None),
|
||||||
design_id=default_design_id,
|
design_id=default_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
created_nodes.append(node)
|
created_nodes.append(node)
|
||||||
|
approved_devices.append(device)
|
||||||
|
# Track within this batch so a duplicate selection (same ip/ieee) is not
|
||||||
|
# placed twice on the same canvas.
|
||||||
|
if device.ip:
|
||||||
|
placed_ips.add(device.ip)
|
||||||
|
if device.ieee_address:
|
||||||
|
placed_ieee.add(device.ieee_address)
|
||||||
await db.flush() # populates node.id from Python-side default before reading
|
await db.flush() # populates node.id from Python-side default before reading
|
||||||
|
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
|
||||||
node_ids = [n.id for n in created_nodes]
|
node_ids = [n.id for n in created_nodes]
|
||||||
approved_device_ids = [d.id for d in devices]
|
approved_device_ids = [d.id for d in approved_devices]
|
||||||
|
|
||||||
all_edges: list[dict[str, str]] = []
|
all_edges: list[dict[str, str]] = []
|
||||||
for device in devices:
|
for device in approved_devices:
|
||||||
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -273,7 +468,7 @@ async def approve_device(
|
|||||||
if device.status != "pending":
|
if device.status != "pending":
|
||||||
raise HTTPException(status_code=409, detail="Device already processed")
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
wireless = _is_wireless(node_data.type)
|
||||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||||
# fall back to whatever the approve payload carried.
|
# fall back to whatever the approve payload carried.
|
||||||
_mac = device.mac or node_data.mac
|
_mac = device.mac or node_data.mac
|
||||||
@@ -283,14 +478,14 @@ async def approve_device(
|
|||||||
ip=node_data.ip,
|
ip=node_data.ip,
|
||||||
mac=_mac,
|
mac=_mac,
|
||||||
hostname=node_data.hostname,
|
hostname=node_data.hostname,
|
||||||
status="online" if _is_zigbee else node_data.status,
|
status="online" if wireless else node_data.status,
|
||||||
services=node_data.services or [],
|
services=node_data.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=build_zigbee_properties(
|
properties=_wireless_properties(
|
||||||
device.ieee_address, device.vendor, device.model, device.lqi
|
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
|
) if wireless else merge_mac_property(node_data.properties, _mac),
|
||||||
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||||
check_target=None if _is_zigbee else node_data.check_target,
|
check_target=None if wireless else node_data.check_target,
|
||||||
design_id=node_design_id,
|
design_id=node_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
@@ -427,17 +622,35 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
|||||||
|
|
||||||
@router.get("/config", response_model=ScanConfig)
|
@router.get("/config", response_model=ScanConfig)
|
||||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
return ScanConfig(ranges=settings.scanner_ranges)
|
return ScanConfig(
|
||||||
|
ranges=settings.scanner_ranges,
|
||||||
|
http_ranges=settings.scanner_http_ranges,
|
||||||
|
http_probe_enabled=settings.scanner_http_probe_enabled,
|
||||||
|
verify_tls=settings.scanner_http_verify_tls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
previous = settings.scanner_ranges
|
previous = (
|
||||||
|
settings.scanner_ranges,
|
||||||
|
settings.scanner_http_ranges,
|
||||||
|
settings.scanner_http_probe_enabled,
|
||||||
|
settings.scanner_http_verify_tls,
|
||||||
|
)
|
||||||
settings.scanner_ranges = payload.ranges
|
settings.scanner_ranges = payload.ranges
|
||||||
|
settings.scanner_http_ranges = payload.http_ranges
|
||||||
|
settings.scanner_http_probe_enabled = payload.http_probe_enabled
|
||||||
|
settings.scanner_http_verify_tls = payload.verify_tls
|
||||||
try:
|
try:
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
settings.scanner_ranges = previous
|
(
|
||||||
|
settings.scanner_ranges,
|
||||||
|
settings.scanner_http_ranges,
|
||||||
|
settings.scanner_http_probe_enabled,
|
||||||
|
settings.scanner_http_verify_tls,
|
||||||
|
) = previous
|
||||||
logger.error("Failed to save scan config: %s", exc)
|
logger.error("Failed to save scan config: %s", exc)
|
||||||
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
"""App-level settings (status checker interval, etc.)."""
|
"""App-level settings (status checker interval, etc.)."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class AppSettings(BaseModel):
|
class AppSettings(BaseModel):
|
||||||
interval_seconds: int
|
interval_seconds: int
|
||||||
|
service_check_enabled: bool = False
|
||||||
|
service_check_interval: int = Field(default=300, ge=30)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||||
return AppSettings(interval_seconds=settings.status_checker_interval)
|
return AppSettings(
|
||||||
|
interval_seconds=settings.status_checker_interval,
|
||||||
|
service_check_enabled=settings.service_check_enabled,
|
||||||
|
service_check_interval=settings.service_check_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AppSettings)
|
@router.post("", response_model=AppSettings)
|
||||||
@@ -23,7 +30,13 @@ async def update_settings(
|
|||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
try:
|
try:
|
||||||
settings.status_checker_interval = payload.interval_seconds
|
settings.status_checker_interval = payload.interval_seconds
|
||||||
|
settings.service_check_enabled = payload.service_check_enabled
|
||||||
|
settings.service_check_interval = payload.service_check_interval
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
|
# Apply the service-check schedule live.
|
||||||
|
set_service_checks_enabled(payload.service_check_enabled)
|
||||||
|
if payload.service_check_enabled:
|
||||||
|
reschedule_service_checks(payload.service_check_interval)
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
@@ -10,6 +11,12 @@ router = APIRouter()
|
|||||||
_connections: list[WebSocket] = []
|
_connections: list[WebSocket] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _drop(websocket: WebSocket) -> None:
|
||||||
|
"""Remove a connection if still present — idempotent, never raises."""
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
_connections.remove(websocket)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
# Accept first so we can send a close frame with a reason code
|
# Accept first so we can send a close frame with a reason code
|
||||||
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
|
|||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
_connections.remove(websocket)
|
pass
|
||||||
|
finally:
|
||||||
|
# Any error (disconnect or otherwise) must release the slot, else the
|
||||||
|
# dead socket lingers in the broadcast pool.
|
||||||
|
_drop(websocket)
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast(payload: str) -> None:
|
async def _broadcast(payload: str) -> None:
|
||||||
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
|
|||||||
try:
|
try:
|
||||||
await conn.send_text(payload)
|
await conn.send_text(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
_connections.remove(conn)
|
_drop(conn)
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||||
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||||
|
await _broadcast(json.dumps({
|
||||||
|
"type": "service_status",
|
||||||
|
"node_id": node_id,
|
||||||
|
"services": services,
|
||||||
|
"checked_at": checked_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||||
await _broadcast(json.dumps({
|
await _broadcast(json.dumps({
|
||||||
"type": "scan_device_found",
|
"type": "scan_device_found",
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""FastAPI router for Z-Wave JS UI (zwavejs2mqtt) import."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
|
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
|
from app.schemas.scan import ScanRunResponse
|
||||||
|
from app.schemas.zwave import (
|
||||||
|
ZwaveCoordinatorOut,
|
||||||
|
ZwaveEdgeOut,
|
||||||
|
ZwaveImportPendingResponse,
|
||||||
|
ZwaveImportRequest,
|
||||||
|
ZwaveImportResponse,
|
||||||
|
ZwaveNodeOut,
|
||||||
|
ZwaveTestConnectionRequest,
|
||||||
|
ZwaveTestConnectionResponse,
|
||||||
|
)
|
||||||
|
from app.services.zwave_service import (
|
||||||
|
build_zwave_properties,
|
||||||
|
fetch_zwave_network,
|
||||||
|
merge_zwave_properties,
|
||||||
|
test_zwave_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=ZwaveImportResponse)
|
||||||
|
async def import_zwave_network(
|
||||||
|
payload: ZwaveImportRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZwaveImportResponse:
|
||||||
|
"""Fetch the Z-Wave node list and return nodes + edges ready for canvas drop.
|
||||||
|
|
||||||
|
Connects to the broker, publishes a ``getNodes`` request to the Z-Wave JS UI
|
||||||
|
gateway, and waits for the response. Devices are returned as typed homelable
|
||||||
|
nodes with a coordinator → router → end-device hierarchy.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
nodes_raw, edges_raw = await fetch_zwave_network(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
prefix=payload.prefix,
|
||||||
|
gateway_name=payload.gateway_name,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
except ConnectionError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Unexpected error during Z-Wave import")
|
||||||
|
raise HTTPException(status_code=500, detail="Unexpected error during Z-Wave import") from exc
|
||||||
|
|
||||||
|
nodes = [ZwaveNodeOut(**n) for n in nodes_raw]
|
||||||
|
edges = [ZwaveEdgeOut(**e) for e in edges_raw]
|
||||||
|
return ZwaveImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||||
|
async def import_zwave_to_pending(
|
||||||
|
payload: ZwaveImportRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ScanRun:
|
||||||
|
"""Queue a Z-Wave pending import as a background scan run (kind=zwave)."""
|
||||||
|
run = ScanRun(
|
||||||
|
status="running",
|
||||||
|
kind="zwave",
|
||||||
|
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(run)
|
||||||
|
background_tasks.add_task(_background_zwave_import, run.id, payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
nodes_raw, edges_raw = await fetch_zwave_network(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
prefix=payload.prefix,
|
||||||
|
gateway_name=payload.gateway_name,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run:
|
||||||
|
run.status = "done"
|
||||||
|
run.devices_found = result.device_count
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Z-Wave import %s failed", run_id)
|
||||||
|
await db.rollback()
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run:
|
||||||
|
run.status = "error"
|
||||||
|
run.error = str(exc)[:500]
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_pending_import(
|
||||||
|
db: AsyncSession,
|
||||||
|
nodes_raw: list[dict[str, Any]],
|
||||||
|
edges_raw: list[dict[str, Any]],
|
||||||
|
) -> ZwaveImportPendingResponse:
|
||||||
|
"""Upsert nodes/edges into pending_devices + pending_device_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.
|
||||||
|
"""
|
||||||
|
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
|
default_design_id = first_design.id if first_design else None
|
||||||
|
|
||||||
|
coordinator_out: ZwaveCoordinatorOut | None = None
|
||||||
|
coordinator_existed = False
|
||||||
|
pending_created = 0
|
||||||
|
pending_updated = 0
|
||||||
|
|
||||||
|
for n in nodes_raw:
|
||||||
|
ieee = n.get("ieee_address")
|
||||||
|
if not ieee:
|
||||||
|
continue
|
||||||
|
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
||||||
|
|
||||||
|
if n.get("type") == "zwave_coordinator":
|
||||||
|
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
||||||
|
existing_node = existing.scalar_one_or_none()
|
||||||
|
if existing_node:
|
||||||
|
existing_node.properties = merge_zwave_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
|
coordinator_out = ZwaveCoordinatorOut(
|
||||||
|
id=existing_node.id,
|
||||||
|
label=existing_node.label,
|
||||||
|
ieee_address=ieee,
|
||||||
|
)
|
||||||
|
coordinator_existed = True
|
||||||
|
continue
|
||||||
|
label = n.get("friendly_name") or ieee
|
||||||
|
node = Node(
|
||||||
|
label=label,
|
||||||
|
type=n.get("type") or "zwave_coordinator",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address=ieee,
|
||||||
|
services=[],
|
||||||
|
properties=props,
|
||||||
|
design_id=default_design_id,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
await db.flush()
|
||||||
|
coordinator_out = ZwaveCoordinatorOut(
|
||||||
|
id=node.id, label=label, ieee_address=ieee
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Already approved as a canvas Node → refresh props, skip pending row.
|
||||||
|
existing_node_q = await db.execute(
|
||||||
|
select(Node).where(Node.ieee_address == ieee)
|
||||||
|
)
|
||||||
|
existing_node = existing_node_q.scalar_one_or_none()
|
||||||
|
if existing_node:
|
||||||
|
existing_node.properties = merge_zwave_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
|
)
|
||||||
|
pending = result.scalar_one_or_none()
|
||||||
|
if pending is None:
|
||||||
|
db.add(
|
||||||
|
PendingDevice(
|
||||||
|
ieee_address=ieee,
|
||||||
|
friendly_name=n.get("friendly_name"),
|
||||||
|
hostname=n.get("friendly_name"),
|
||||||
|
suggested_type=n.get("type"),
|
||||||
|
device_subtype=n.get("device_type"),
|
||||||
|
model=n.get("model"),
|
||||||
|
vendor=n.get("vendor"),
|
||||||
|
lqi=n.get("lqi"),
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_created += 1
|
||||||
|
else:
|
||||||
|
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
|
||||||
|
pending.suggested_type = n.get("type") or pending.suggested_type
|
||||||
|
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
||||||
|
pending.model = n.get("model") or pending.model
|
||||||
|
pending.vendor = n.get("vendor") or pending.vendor
|
||||||
|
if pending.status == "approved":
|
||||||
|
# Approved earlier but the canvas Node is gone (deleted) — revive
|
||||||
|
# to "pending" so it reappears in the list instead of vanishing.
|
||||||
|
pending.status = "pending"
|
||||||
|
elif pending.status == "hidden":
|
||||||
|
pass
|
||||||
|
pending_updated += 1
|
||||||
|
|
||||||
|
# Replace all zwave-source links with the freshly discovered set.
|
||||||
|
await db.execute(
|
||||||
|
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
|
||||||
|
)
|
||||||
|
|
||||||
|
links_recorded = 0
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for e in edges_raw:
|
||||||
|
src = e.get("source")
|
||||||
|
tgt = e.get("target")
|
||||||
|
if not src or not tgt or (src, tgt) in seen:
|
||||||
|
continue
|
||||||
|
seen.add((src, tgt))
|
||||||
|
db.add(
|
||||||
|
PendingDeviceLink(
|
||||||
|
source_ieee=src,
|
||||||
|
target_ieee=tgt,
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
links_recorded += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ZwaveImportPendingResponse(
|
||||||
|
pending_created=pending_created,
|
||||||
|
pending_updated=pending_updated,
|
||||||
|
coordinator=coordinator_out,
|
||||||
|
coordinator_already_existed=coordinator_existed,
|
||||||
|
links_recorded=links_recorded,
|
||||||
|
device_count=len(nodes_raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection", response_model=ZwaveTestConnectionResponse)
|
||||||
|
async def test_connection_endpoint(
|
||||||
|
payload: ZwaveTestConnectionRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZwaveTestConnectionResponse:
|
||||||
|
"""Quick MQTT ping to validate broker connection before importing."""
|
||||||
|
try:
|
||||||
|
await test_zwave_connection(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
return ZwaveTestConnectionResponse(connected=True, message="Connection successful")
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
except (ConnectionError, TimeoutError) as exc:
|
||||||
|
return ZwaveTestConnectionResponse(connected=False, message=str(exc))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error during connection test")
|
||||||
|
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
|
||||||
@@ -48,9 +48,19 @@ class Settings(BaseSettings):
|
|||||||
# Scanner
|
# Scanner
|
||||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||||
|
|
||||||
|
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
|
||||||
|
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
||||||
|
scanner_http_ranges: list[str] = []
|
||||||
|
scanner_http_probe_enabled: bool = False
|
||||||
|
scanner_http_verify_tls: bool = False
|
||||||
|
|
||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
# Per-service status checker (independent of node checks). Off by default.
|
||||||
|
service_check_enabled: bool = False
|
||||||
|
service_check_interval: int = 300
|
||||||
|
|
||||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
# Used by the MCP server to authenticate against the backend without a user password.
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
# Leave empty to disable MCP service key auth.
|
# Leave empty to disable MCP service key auth.
|
||||||
@@ -77,6 +87,16 @@ class Settings(BaseSettings):
|
|||||||
self.scanner_ranges = data["scanner_ranges"]
|
self.scanner_ranges = data["scanner_ranges"]
|
||||||
if "status_checker_interval" in data:
|
if "status_checker_interval" in data:
|
||||||
self.status_checker_interval = int(data["status_checker_interval"])
|
self.status_checker_interval = int(data["status_checker_interval"])
|
||||||
|
if "service_check_enabled" in data:
|
||||||
|
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||||
|
if "service_check_interval" in data:
|
||||||
|
self.service_check_interval = int(data["service_check_interval"])
|
||||||
|
if "scanner_http_ranges" in data:
|
||||||
|
self.scanner_http_ranges = list(data["scanner_http_ranges"])
|
||||||
|
if "scanner_http_probe_enabled" in data:
|
||||||
|
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
||||||
|
if "scanner_http_verify_tls" in data:
|
||||||
|
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -86,6 +106,11 @@ class Settings(BaseSettings):
|
|||||||
self._override_path().write_text(json.dumps({
|
self._override_path().write_text(json.dumps({
|
||||||
"scanner_ranges": self.scanner_ranges,
|
"scanner_ranges": self.scanner_ranges,
|
||||||
"status_checker_interval": self.status_checker_interval,
|
"status_checker_interval": self.status_checker_interval,
|
||||||
|
"service_check_enabled": self.service_check_enabled,
|
||||||
|
"service_check_interval": self.service_check_interval,
|
||||||
|
"scanner_http_ranges": self.scanner_http_ranges,
|
||||||
|
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
||||||
|
"scanner_http_verify_tls": self.scanner_http_verify_tls,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal
|
from app.db.database import AsyncSessionLocal
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
from app.services.status_checker import check_node
|
from app.services.status_checker import check_node, check_services
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||||
|
"""Pick the address to probe services on: first IP, else hostname."""
|
||||||
|
if ip:
|
||||||
|
first = ip.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return hostname or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_service_checks() -> None:
|
||||||
|
"""Check every service of every node and broadcast per-service results."""
|
||||||
|
if not settings.service_check_enabled:
|
||||||
|
return
|
||||||
|
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Node))
|
||||||
|
nodes = result.scalars().all()
|
||||||
|
checkable = [
|
||||||
|
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||||
|
for n in nodes
|
||||||
|
if n.services
|
||||||
|
]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for node_id, host, services in checkable:
|
||||||
|
try:
|
||||||
|
statuses = await check_services(host, services)
|
||||||
|
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_service_check_job() -> None:
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_service_checks,
|
||||||
|
"interval",
|
||||||
|
seconds=settings.service_check_interval,
|
||||||
|
id="service_checks",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
if settings.service_check_enabled:
|
||||||
|
_add_service_check_job()
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
|
|||||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule_service_checks(interval_seconds: int) -> None:
|
||||||
|
"""Update the service-check interval on the running scheduler (if enabled)."""
|
||||||
|
if interval_seconds < 30:
|
||||||
|
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
||||||
|
if not scheduler.running:
|
||||||
|
logger.warning("Scheduler not running, skipping reschedule")
|
||||||
|
return
|
||||||
|
if scheduler.get_job("service_checks"):
|
||||||
|
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
||||||
|
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def set_service_checks_enabled(enabled: bool) -> None:
|
||||||
|
"""Add or remove the service-check job on the running scheduler."""
|
||||||
|
if not scheduler.running:
|
||||||
|
return
|
||||||
|
job = scheduler.get_job("service_checks")
|
||||||
|
if enabled and not job:
|
||||||
|
_add_service_check_job()
|
||||||
|
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
||||||
|
elif not enabled and job:
|
||||||
|
scheduler.remove_job("service_checks")
|
||||||
|
logger.info("Service checks disabled")
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"vendor": "Proxmox / QEMU / KVM",
|
||||||
|
"type": "vm",
|
||||||
|
"prefixes": ["52:54:00", "bc:24:11"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "VMware",
|
||||||
|
"type": "vm",
|
||||||
|
"prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "VirtualBox",
|
||||||
|
"type": "vm",
|
||||||
|
"prefixes": ["08:00:27"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Microsoft Hyper-V",
|
||||||
|
"type": "vm",
|
||||||
|
"prefixes": ["00:15:5d"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Xen",
|
||||||
|
"type": "vm",
|
||||||
|
"prefixes": ["00:16:3e"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "MikroTik",
|
||||||
|
"type": "router",
|
||||||
|
"prefixes": [
|
||||||
|
"00:0c:42",
|
||||||
|
"08:55:31",
|
||||||
|
"18:fd:74",
|
||||||
|
"2c:c8:1b",
|
||||||
|
"48:8f:5a",
|
||||||
|
"4c:5e:0c",
|
||||||
|
"64:d1:54",
|
||||||
|
"6c:3b:6b",
|
||||||
|
"74:4d:28",
|
||||||
|
"b8:69:f4",
|
||||||
|
"c4:ad:34",
|
||||||
|
"cc:2d:e0",
|
||||||
|
"d4:ca:6d",
|
||||||
|
"dc:2c:6e",
|
||||||
|
"e4:8d:8c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Ubiquiti",
|
||||||
|
"type": "ap",
|
||||||
|
"prefixes": [
|
||||||
|
"00:15:6d",
|
||||||
|
"00:27:22",
|
||||||
|
"04:18:d6",
|
||||||
|
"24:5a:4c",
|
||||||
|
"24:a4:3c",
|
||||||
|
"44:d9:e7",
|
||||||
|
"68:72:51",
|
||||||
|
"68:d7:9a",
|
||||||
|
"74:83:c2",
|
||||||
|
"78:8a:20",
|
||||||
|
"78:45:58",
|
||||||
|
"80:2a:a8",
|
||||||
|
"94:2a:6f",
|
||||||
|
"9c:05:d6",
|
||||||
|
"b4:fb:e4",
|
||||||
|
"dc:9f:db",
|
||||||
|
"e0:63:da",
|
||||||
|
"f0:9f:c2",
|
||||||
|
"fc:ec:da"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Ruckus Wireless",
|
||||||
|
"type": "ap",
|
||||||
|
"prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Aruba Networks (HPE)",
|
||||||
|
"type": "ap",
|
||||||
|
"prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "Cisco Systems",
|
||||||
|
"type": "switch",
|
||||||
|
"prefixes": [
|
||||||
|
"00:00:0c",
|
||||||
|
"00:1b:0d",
|
||||||
|
"00:1c:f6",
|
||||||
|
"00:1e:13",
|
||||||
|
"00:23:04",
|
||||||
|
"00:24:13",
|
||||||
|
"00:25:45",
|
||||||
|
"00:50:0b",
|
||||||
|
"b0:00:b4",
|
||||||
|
"b8:38:61",
|
||||||
|
"f8:c0:01"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Juniper Networks",
|
||||||
|
"type": "switch",
|
||||||
|
"prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Zyxel",
|
||||||
|
"type": "switch",
|
||||||
|
"prefixes": ["00:13:49", "60:31:97", "ec:43:f6"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "Netgear",
|
||||||
|
"type": "router",
|
||||||
|
"prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "TP-Link",
|
||||||
|
"type": "router",
|
||||||
|
"prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "Synology",
|
||||||
|
"type": "nas",
|
||||||
|
"prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "QNAP Systems",
|
||||||
|
"type": "nas",
|
||||||
|
"prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Asustor",
|
||||||
|
"type": "nas",
|
||||||
|
"prefixes": ["e8:9c:25"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "Hikvision",
|
||||||
|
"type": "camera",
|
||||||
|
"prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Dahua / Amcrest",
|
||||||
|
"type": "camera",
|
||||||
|
"prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Reolink",
|
||||||
|
"type": "camera",
|
||||||
|
"prefixes": ["ec:71:db"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Axis Communications",
|
||||||
|
"type": "camera",
|
||||||
|
"prefixes": ["00:40:8c", "ac:cc:8e"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "Raspberry Pi Foundation",
|
||||||
|
"type": "server",
|
||||||
|
"prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Dell",
|
||||||
|
"type": "server",
|
||||||
|
"prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Supermicro",
|
||||||
|
"type": "server",
|
||||||
|
"prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "Shelly",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Espressif (ESP8266 / ESP32)",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": [
|
||||||
|
"24:62:ab",
|
||||||
|
"30:ae:a4",
|
||||||
|
"3c:71:bf",
|
||||||
|
"8c:aa:b5",
|
||||||
|
"a0:20:a6",
|
||||||
|
"ac:67:b2",
|
||||||
|
"b4:e6:2d",
|
||||||
|
"cc:50:e3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Sonoff / ITEAD",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": ["dc:4f:22", "e8:db:84"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "TP-Link Tapo / Kasa",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Philips Hue",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": ["00:17:88", "ec:b5:fa"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "IKEA Tradfri",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": ["00:21:2e", "34:13:e8"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Tuya / Smart Life",
|
||||||
|
"type": "iot",
|
||||||
|
"prefixes": ["68:57:2d", "d8:f1:5b"]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -142,5 +142,69 @@
|
|||||||
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
||||||
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -293,6 +293,9 @@ async def init_db() -> None:
|
|||||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
(_json.dumps(props), node_id),
|
(_json.dumps(props), node_id),
|
||||||
)
|
)
|
||||||
|
# Inventory timestamp: last time a scan observed this node (idempotent)
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN last_scan DATETIME")
|
||||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class Node(Base):
|
|||||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
@@ -99,6 +100,9 @@ class CanvasState(Base):
|
|||||||
|
|
||||||
class PendingDevice(Base):
|
class PendingDevice(Base):
|
||||||
__tablename__ = "pending_devices"
|
__tablename__ = "pending_devices"
|
||||||
|
# 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
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
ip: Mapped[str | None] = mapped_column(String, nullable=True)
|
ip: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
@@ -117,6 +121,16 @@ class PendingDevice(Base):
|
|||||||
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
# Transient (not persisted): populated per-request by the scan routes to report
|
||||||
|
# how many canvases this device already appears on. Not a mapped column.
|
||||||
|
canvas_count: int = 0
|
||||||
|
# Transient (not persisted): timestamps from the linked canvas node(s),
|
||||||
|
# correlated by ip / ieee_address. None when the device is not on any canvas.
|
||||||
|
node_created_at: datetime | None = None
|
||||||
|
node_last_scan: datetime | None = None
|
||||||
|
node_last_modified: datetime | None = None
|
||||||
|
node_last_seen: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class PendingDeviceLink(Base):
|
class PendingDeviceLink(Base):
|
||||||
"""Link between two Zigbee endpoints discovered during import.
|
"""Link between two Zigbee endpoints discovered during import.
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
|
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave
|
||||||
from app.api.routes import settings as settings_routes
|
from app.api.routes import settings as settings_routes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
@@ -57,6 +57,7 @@ app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
|||||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||||
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||||
|
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
|
||||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
|
|
||||||
class EdgeResponse(EdgeBase):
|
class EdgeResponse(EdgeBase):
|
||||||
id: str
|
id: str
|
||||||
|
design_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class NodeResponse(NodeBase):
|
|||||||
design_id: str | None = None
|
design_id: str | None = None
|
||||||
ieee_address: str | None = None
|
ieee_address: str | None = None
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
|
last_scan: datetime | None = None
|
||||||
response_time_ms: int | None = None
|
response_time_ms: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
vendor: str | None = None
|
vendor: str | None = None
|
||||||
lqi: int | None = None
|
lqi: int | None = None
|
||||||
discovered_at: datetime
|
discovered_at: datetime
|
||||||
|
# Number of distinct canvases (designs) this device already appears on,
|
||||||
|
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
||||||
|
canvas_count: int = 0
|
||||||
|
# Timestamps from the linked canvas node(s), correlated by ip / ieee_address.
|
||||||
|
# Null when the device is not on any canvas yet. Aggregated across matches:
|
||||||
|
# created_at = oldest; last_scan / last_modified / last_seen = newest.
|
||||||
|
node_created_at: datetime | None = None
|
||||||
|
node_last_scan: datetime | None = None
|
||||||
|
node_last_modified: datetime | None = None
|
||||||
|
node_last_seen: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Pydantic v2 schemas for Z-Wave JS UI (zwavejs2mqtt) import."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveImportRequest(BaseModel):
|
||||||
|
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
||||||
|
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
||||||
|
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
||||||
|
prefix: str = Field("zwave", description="Z-Wave JS UI MQTT prefix")
|
||||||
|
gateway_name: str = Field("zwavejs2mqtt", description="Z-Wave JS UI gateway name")
|
||||||
|
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
||||||
|
mqtt_tls_insecure: bool = Field(
|
||||||
|
False, description="Skip TLS certificate verification (self-signed only)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _insecure_requires_tls(self) -> "ZwaveImportRequest":
|
||||||
|
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||||
|
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveTestConnectionRequest(BaseModel):
|
||||||
|
mqtt_host: str
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||||
|
mqtt_username: str | None = None
|
||||||
|
mqtt_password: str | None = None
|
||||||
|
mqtt_tls: bool = False
|
||||||
|
mqtt_tls_insecure: bool = False
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _insecure_requires_tls(self) -> "ZwaveTestConnectionRequest":
|
||||||
|
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||||
|
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveNodeOut(BaseModel):
|
||||||
|
"""A homelable-ready node representation of a Z-Wave device."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: str # zwave_coordinator | zwave_router | zwave_enddevice
|
||||||
|
ieee_address: str
|
||||||
|
friendly_name: str
|
||||||
|
device_type: str
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveEdgeOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveImportResponse(BaseModel):
|
||||||
|
nodes: list[ZwaveNodeOut]
|
||||||
|
edges: list[ZwaveEdgeOut]
|
||||||
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveTestConnectionResponse(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveCoordinatorOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
ieee_address: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveImportPendingResponse(BaseModel):
|
||||||
|
"""Result of importing a Z-Wave network into the pending section."""
|
||||||
|
|
||||||
|
pending_created: int
|
||||||
|
pending_updated: int
|
||||||
|
coordinator: ZwaveCoordinatorOut | None = None
|
||||||
|
coordinator_already_existed: bool = False
|
||||||
|
links_recorded: int
|
||||||
|
device_count: int
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SIGNATURES: list[dict[str, Any]] | None = None
|
_SIGNATURES: list[dict[str, Any]] | None = None
|
||||||
|
_OUI_MAP: dict[str, str] | None = None
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -26,25 +27,124 @@ def _load() -> list[dict[str, Any]]:
|
|||||||
return _SIGNATURES
|
return _SIGNATURES
|
||||||
|
|
||||||
|
|
||||||
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
|
def _load_oui() -> dict[str, str]:
|
||||||
"""Return the first signature matching port+protocol, optionally banner."""
|
"""Load OUI database and flatten to {prefix: node_type}."""
|
||||||
for sig in _load():
|
global _OUI_MAP
|
||||||
if sig["port"] != port or sig["protocol"] != protocol:
|
if _OUI_MAP is None:
|
||||||
continue
|
with _LOCK:
|
||||||
if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)):
|
if _OUI_MAP is None:
|
||||||
continue
|
path = Path(__file__).parent.parent / "data" / "oui_database.json"
|
||||||
return sig
|
try:
|
||||||
return None
|
with open(path) as f:
|
||||||
|
entries = json.load(f)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"oui_database.json not found at {path}. "
|
||||||
|
"This file should be bundled with the application."
|
||||||
|
) from err
|
||||||
|
_OUI_MAP = {
|
||||||
|
prefix.lower(): entry["type"]
|
||||||
|
for entry in entries
|
||||||
|
for prefix in entry["prefixes"]
|
||||||
|
}
|
||||||
|
return _OUI_MAP
|
||||||
|
|
||||||
|
|
||||||
def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool:
|
||||||
|
"""True when the signature's http_regex matches the probe's title/headers."""
|
||||||
|
rx = sig.get("http_regex")
|
||||||
|
if not rx or not http_signals:
|
||||||
|
return False
|
||||||
|
headers = http_signals.get("headers") or {}
|
||||||
|
haystack = " ".join(
|
||||||
|
s for s in (
|
||||||
|
http_signals.get("title"),
|
||||||
|
headers.get("Server"),
|
||||||
|
headers.get("X-Powered-By"),
|
||||||
|
) if s
|
||||||
|
)
|
||||||
|
return bool(haystack and re.search(rx, haystack, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
def _service_tier(
|
||||||
|
sig: dict[str, Any],
|
||||||
|
port: int,
|
||||||
|
protocol: str,
|
||||||
|
banner: str | None,
|
||||||
|
http_signals: dict[str, Any] | None,
|
||||||
|
) -> int | None:
|
||||||
"""
|
"""
|
||||||
Given a list of {port, protocol, banner?} dicts, return matched services.
|
Rank how well a signature matches (lower = stronger). None = not a match.
|
||||||
Unknown ports are included as unknown_service.
|
|
||||||
|
Tier 1: port match + http_regex confirmed
|
||||||
|
Tier 2: port match + banner_regex confirmed
|
||||||
|
Tier 3: port-agnostic (port: null) + http_regex confirmed
|
||||||
|
Tier 4: port match only (no regex, or http_regex with probe disabled)
|
||||||
|
|
||||||
|
When http_signals is None (probe not run) an http_regex entry degrades to
|
||||||
|
a port-only match — identical to pre-probe behaviour, no regression.
|
||||||
|
When http_signals is provided, http_regex is strict: a miss disqualifies.
|
||||||
|
"""
|
||||||
|
probe_ran = http_signals is not None
|
||||||
|
has_http = bool(sig.get("http_regex"))
|
||||||
|
|
||||||
|
# Port-agnostic entries (port: null) match purely on HTTP signals.
|
||||||
|
if sig.get("port") is None:
|
||||||
|
if has_http and _http_regex_hit(sig, http_signals):
|
||||||
|
return 3
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sig["port"] != port or sig["protocol"] != protocol:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# http_regex is authoritative once a probe has run.
|
||||||
|
if has_http and probe_ran:
|
||||||
|
return 1 if _http_regex_hit(sig, http_signals) else None
|
||||||
|
|
||||||
|
if sig.get("banner_regex"):
|
||||||
|
if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE):
|
||||||
|
return 2
|
||||||
|
return None
|
||||||
|
|
||||||
|
# No regex constraint (or http_regex but probe disabled) → port-only guess.
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def match_service(
|
||||||
|
port: int,
|
||||||
|
protocol: str,
|
||||||
|
banner: str | None = None,
|
||||||
|
http_signals: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return the best signature for a port, walking tiers most-specific first."""
|
||||||
|
best: dict[str, Any] | None = None
|
||||||
|
best_tier = 99
|
||||||
|
for sig in _load():
|
||||||
|
tier = _service_tier(sig, port, protocol, banner, http_signals)
|
||||||
|
if tier is not None and tier < best_tier:
|
||||||
|
best, best_tier = sig, tier
|
||||||
|
if best_tier == 1:
|
||||||
|
break # strongest possible — stop early
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Back-compat alias: match without HTTP-probe signals."""
|
||||||
|
return match_service(port, protocol, banner)
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint_ports(
|
||||||
|
open_ports: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Given a list of {port, protocol, banner?, http_signals?} dicts, return
|
||||||
|
matched services. Unknown ports are included as unknown_service.
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for p in open_ports:
|
for p in open_ports:
|
||||||
sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
|
sig = match_service(
|
||||||
|
p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals")
|
||||||
|
)
|
||||||
if sig:
|
if sig:
|
||||||
results.append({
|
results.append({
|
||||||
"port": p["port"],
|
"port": p["port"],
|
||||||
@@ -65,55 +165,12 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
# Known OUI prefixes — lowercase, colon-separated, first 3 octets
|
|
||||||
_MAC_OUI_TYPES: dict[str, str] = {
|
|
||||||
# Hypervisors / VMs
|
|
||||||
"52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
|
|
||||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
|
|
||||||
"00:50:56": "vm", # VMware
|
|
||||||
"00:0c:29": "vm", # VMware Workstation / Fusion
|
|
||||||
"08:00:27": "vm", # VirtualBox
|
|
||||||
"00:15:5d": "vm", # Hyper-V
|
|
||||||
# Shelly
|
|
||||||
"34:94:54": "iot",
|
|
||||||
"84:f3:eb": "iot",
|
|
||||||
"ec:fa:bc": "iot",
|
|
||||||
"30:c6:f7": "iot",
|
|
||||||
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
|
|
||||||
"a0:20:a6": "iot",
|
|
||||||
"24:62:ab": "iot",
|
|
||||||
"30:ae:a4": "iot",
|
|
||||||
"cc:50:e3": "iot",
|
|
||||||
"ac:67:b2": "iot",
|
|
||||||
"b4:e6:2d": "iot",
|
|
||||||
"3c:71:bf": "iot",
|
|
||||||
"8c:aa:b5": "iot",
|
|
||||||
# Sonoff / ITEAD
|
|
||||||
"dc:4f:22": "iot",
|
|
||||||
"e8:db:84": "iot",
|
|
||||||
# Tapo / TP-Link smart home
|
|
||||||
"b0:a7:b9": "iot",
|
|
||||||
"50:c7:bf": "iot",
|
|
||||||
"1c:3b:f3": "iot",
|
|
||||||
"10:27:f5": "iot",
|
|
||||||
# Philips Hue
|
|
||||||
"00:17:88": "iot",
|
|
||||||
"ec:b5:fa": "iot",
|
|
||||||
# IKEA Tradfri
|
|
||||||
"34:13:e8": "iot",
|
|
||||||
"00:21:2e": "iot",
|
|
||||||
# Tuya / Smart Life (widely used chip in many brands)
|
|
||||||
"d8:f1:5b": "iot",
|
|
||||||
"68:57:2d": "iot",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def suggest_type_from_mac(mac: str | None) -> str | None:
|
def suggest_type_from_mac(mac: str | None) -> str | None:
|
||||||
"""Return a suggested node type from MAC OUI, or None if unknown."""
|
"""Return a suggested node type from MAC OUI, or None if unknown."""
|
||||||
if not mac:
|
if not mac:
|
||||||
return None
|
return None
|
||||||
prefix = mac.lower()[:8]
|
prefix = mac.lower()[:8]
|
||||||
return _MAC_OUI_TYPES.get(prefix)
|
return _load_oui().get(prefix)
|
||||||
|
|
||||||
|
|
||||||
_PORT_TYPE_HINTS: dict[int, str] = {
|
_PORT_TYPE_HINTS: dict[int, str] = {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""HTTP probe: GET a discovered port and extract identifying signals.
|
||||||
|
|
||||||
|
Used by the optional deep-scan mode to confirm what service sits behind an
|
||||||
|
open port, regardless of port number. Returns the page <title> plus a small
|
||||||
|
set of identifying response headers, which fingerprint.match_service() then
|
||||||
|
matches against signature http_regex fields.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Headers that commonly carry the application name.
|
||||||
|
_SIGNAL_HEADERS = ("Server", "X-Powered-By")
|
||||||
|
# Cap how much body we read when hunting for <title> — avoids large downloads.
|
||||||
|
_MAX_BODY_BYTES = 64 * 1024
|
||||||
|
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||||
|
_PROBE_TIMEOUT = 3.0
|
||||||
|
# Ports we never bother probing over HTTP (not web services).
|
||||||
|
_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(body: str) -> str | None:
|
||||||
|
m = _TITLE_RE.search(body)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
||||||
|
return title or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
resp = await client.get(url, follow_redirects=True)
|
||||||
|
except (httpx.HTTPError, OSError):
|
||||||
|
return None
|
||||||
|
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
|
||||||
|
body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
|
||||||
|
title = _extract_title(body)
|
||||||
|
if not title and not headers:
|
||||||
|
return None
|
||||||
|
return {"title": title, "headers": headers}
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_port(
|
||||||
|
ip: str, port: int, verify_tls: bool = False
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
GET https:// then http:// for a port and return {title, headers} or None.
|
||||||
|
|
||||||
|
None means the port did not answer HTTP or yielded no usable signal.
|
||||||
|
"""
|
||||||
|
if port in _NON_HTTP_PORTS:
|
||||||
|
return None
|
||||||
|
async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
|
||||||
|
for scheme in ("https", "http"):
|
||||||
|
result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_open_ports(
|
||||||
|
ip: str,
|
||||||
|
open_ports: list[dict[str, Any]],
|
||||||
|
verify_tls: bool = False,
|
||||||
|
concurrency: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Probe every open port for HTTP signals (option 2: probe all, match after).
|
||||||
|
|
||||||
|
Returns the same port dicts, each enriched with an http_signals key
|
||||||
|
(None when the port gave no HTTP signal).
|
||||||
|
"""
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
|
async def _one(p: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
signals = await probe_port(ip, p["port"], verify_tls)
|
||||||
|
return {**p, "http_signals": signals}
|
||||||
|
|
||||||
|
return await asyncio.gather(*(_one(p) for p in open_ports))
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Shared MQTT helpers for the Zigbee and Z-Wave import services.
|
||||||
|
|
||||||
|
Holds the credential-safe error sanitizer, the TLS context builder, and a
|
||||||
|
generic request/response round-trip over MQTT used by gateway-style APIs
|
||||||
|
(publish a request topic, wait for a single response topic message).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiomqtt
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
aiomqtt = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||||
|
_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are slow)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||||
|
"""Return a generic, credential-free message for an MQTT error.
|
||||||
|
|
||||||
|
The raw aiomqtt/paho error string can include the broker URI with
|
||||||
|
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||||
|
detail that should not leak to API clients. Map known patterns to
|
||||||
|
coarse categories; default to a generic failure message. The original
|
||||||
|
exception is logged at WARNING level for operator debugging.
|
||||||
|
"""
|
||||||
|
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||||
|
raw = str(exc).lower()
|
||||||
|
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||||
|
return "Authentication failed"
|
||||||
|
if "refused" in raw:
|
||||||
|
return "Connection refused by broker"
|
||||||
|
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||||
|
return "Broker hostname could not be resolved"
|
||||||
|
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||||
|
return "TLS handshake failed"
|
||||||
|
if "timed out" in raw or "timeout" in raw:
|
||||||
|
return "Connection to broker timed out"
|
||||||
|
return "MQTT connection failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||||
|
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
if insecure:
|
||||||
|
logger.warning(
|
||||||
|
"MQTT TLS certificate verification is DISABLED — "
|
||||||
|
"use only with self-signed brokers on trusted networks."
|
||||||
|
)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
async def request_response(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
request_topic: str,
|
||||||
|
response_topic: str,
|
||||||
|
request_payload: dict[str, Any],
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
response_timeout: float = _RESPONSE_TIMEOUT,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Publish ``request_payload`` to ``request_topic`` and return the first
|
||||||
|
JSON message received on ``response_topic`` as a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: if aiomqtt is not installed.
|
||||||
|
TimeoutError: if no response arrives in time.
|
||||||
|
ConnectionError: if the broker cannot be reached.
|
||||||
|
ValueError: if the response payload is not valid JSON / is empty.
|
||||||
|
"""
|
||||||
|
if aiomqtt is None: # pragma: no cover
|
||||||
|
raise ImportError(
|
||||||
|
"aiomqtt is required for MQTT import. "
|
||||||
|
"Install it with: pip install aiomqtt"
|
||||||
|
)
|
||||||
|
|
||||||
|
response_payload: dict[str, Any] = {}
|
||||||
|
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
tls_context=tls_context,
|
||||||
|
) as client:
|
||||||
|
await client.subscribe(response_topic)
|
||||||
|
# Give the broker a brief window to register the subscription
|
||||||
|
# before we publish the request. Without this, brokers that
|
||||||
|
# race SUBACK with our PUBLISH may deliver the response before
|
||||||
|
# the subscription is active and we'd hang until timeout.
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
await client.publish(request_topic, json.dumps(request_payload))
|
||||||
|
|
||||||
|
async def _wait_for_response() -> None:
|
||||||
|
async for message in client.messages:
|
||||||
|
if str(message.topic) != response_topic:
|
||||||
|
continue
|
||||||
|
raw = message.payload
|
||||||
|
try:
|
||||||
|
payload_str = (
|
||||||
|
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||||
|
)
|
||||||
|
response_payload.update(json.loads(payload_str))
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
raise ValueError(f"Malformed MQTT response: {exc}") from exc
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.wait_for(_wait_for_response(), timeout=response_timeout)
|
||||||
|
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("Timed out waiting for MQTT response") from exc
|
||||||
|
|
||||||
|
if not response_payload:
|
||||||
|
raise ValueError("Empty MQTT response received")
|
||||||
|
|
||||||
|
return response_payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||||
|
|
||||||
|
Returns True on success, raises ConnectionError/TimeoutError on failure.
|
||||||
|
"""
|
||||||
|
if aiomqtt is None: # pragma: no cover
|
||||||
|
raise ImportError("aiomqtt is required")
|
||||||
|
|
||||||
|
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
tls_context=tls_context,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("Connection to broker timed out") from exc
|
||||||
+165
-45
@@ -7,14 +7,16 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||||
|
from app.services.http_probe import probe_open_ports
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +36,37 @@ _EXTRA_PORTS = (
|
|||||||
"16686,34567,37777,51413,64738"
|
"16686,34567,37777,51413,64738"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# nmap -p accepts "N" or "N-M"; user ranges are validated against this.
|
||||||
|
_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeepScanOptions:
|
||||||
|
"""Per-scan deep-scan settings (None/empty → standard scan, today's behaviour)."""
|
||||||
|
|
||||||
|
http_ranges: list[str] = field(default_factory=list)
|
||||||
|
http_probe_enabled: bool = False
|
||||||
|
verify_tls: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_port_range(spec: str) -> bool:
|
||||||
|
if not _PORT_RANGE_RE.match(spec):
|
||||||
|
return False
|
||||||
|
parts = [int(p) for p in spec.split("-")]
|
||||||
|
if any(p < 1 or p > 65535 for p in parts):
|
||||||
|
return False
|
||||||
|
return len(parts) == 1 or parts[0] <= parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_port_spec(http_ranges: list[str] | None) -> str:
|
||||||
|
"""Combine the default port list with validated user ranges for nmap -p."""
|
||||||
|
if not http_ranges:
|
||||||
|
return _EXTRA_PORTS
|
||||||
|
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
|
||||||
|
if not extra:
|
||||||
|
return _EXTRA_PORTS
|
||||||
|
return _EXTRA_PORTS + "," + ",".join(extra)
|
||||||
|
|
||||||
_MDNS_SERVICE_TYPES = [
|
_MDNS_SERVICE_TYPES = [
|
||||||
"_http._tcp.local.",
|
"_http._tcp.local.",
|
||||||
"_shelly._tcp.local.",
|
"_shelly._tcp.local.",
|
||||||
@@ -142,7 +175,7 @@ def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Phase 1: Concurrent ICMP ping sweep + ARP cache.
|
Phase 1: Concurrent ICMP ping sweep + ARP cache.
|
||||||
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
|
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
|
||||||
@@ -172,6 +205,12 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
|||||||
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
|
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
|
||||||
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
|
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
|
||||||
|
|
||||||
|
# Cancelled during the sweep — bail before the (potentially long) Phase 2
|
||||||
|
# port scan. Returning empty makes _nmap_scan skip nmap entirely.
|
||||||
|
if run_id is not None and _is_cancelled(run_id):
|
||||||
|
logger.info("[Phase 1] %s — scan cancelled, skipping hostname/ARP enrichment", target)
|
||||||
|
return {}
|
||||||
|
|
||||||
# ARP cache: catch devices that block ICMP but were recently active,
|
# ARP cache: catch devices that block ICMP but were recently active,
|
||||||
# and enrich ping-alive hosts with their MAC addresses.
|
# and enrich ping-alive hosts with their MAC addresses.
|
||||||
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
|
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
|
||||||
@@ -195,7 +234,7 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
|||||||
return alive
|
return alive
|
||||||
|
|
||||||
|
|
||||||
def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Phase 2 — single-IP port scan with service detection.
|
Phase 2 — single-IP port scan with service detection.
|
||||||
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
||||||
@@ -210,11 +249,11 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
|||||||
is_root = os.geteuid() == 0
|
is_root = os.geteuid() == 0
|
||||||
if is_root:
|
if is_root:
|
||||||
# SYN scan + version detection (fastest, most accurate)
|
# SYN scan + version detection (fastest, most accurate)
|
||||||
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
|
||||||
else:
|
else:
|
||||||
# TCP connect scan (-sT) — no raw sockets needed, works without root.
|
# TCP connect scan (-sT) — no raw sockets needed, works without root.
|
||||||
# nmap auto-selects -sT without root but being explicit avoids edge cases.
|
# nmap auto-selects -sT without root but being explicit avoids edge cases.
|
||||||
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
|
||||||
|
|
||||||
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
||||||
nm = nmap.PortScanner()
|
nm = nmap.PortScanner()
|
||||||
@@ -252,7 +291,10 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
|
|
||||||
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
async def _nmap_port_scan(
|
||||||
|
alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Phase 2: Per-IP service detection with bounded concurrency.
|
Phase 2: Per-IP service detection with bounded concurrency.
|
||||||
Each host is scanned independently in a thread — no inter-host timeout interference.
|
Each host is scanned independently in a thread — no inter-host timeout interference.
|
||||||
@@ -266,7 +308,12 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
|
|||||||
|
|
||||||
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
|
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
return await asyncio.to_thread(_nmap_scan_single, host_dict)
|
# Once cancelled, skip the expensive nmap call for every host still
|
||||||
|
# queued behind the semaphore — return it unscanned so the gather
|
||||||
|
# unwinds fast instead of blocking the stop for minutes.
|
||||||
|
if run_id is not None and _is_cancelled(run_id):
|
||||||
|
return host_dict
|
||||||
|
return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec)
|
||||||
|
|
||||||
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
|
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
|
||||||
results = []
|
results = []
|
||||||
@@ -279,24 +326,32 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
async def _nmap_scan(
|
||||||
|
target: str, port_spec: str = _EXTRA_PORTS, run_id: str | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Two-phase scan for a CIDR range.
|
Two-phase scan for a CIDR range.
|
||||||
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
|
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
|
||||||
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
|
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
|
||||||
|
|
||||||
|
``run_id`` lets each phase poll for cancellation so a stop request takes
|
||||||
|
effect mid-range instead of only at CIDR/host boundaries in run_scan.
|
||||||
"""
|
"""
|
||||||
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
|
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
|
||||||
|
if run_id is not None and _is_cancelled(run_id):
|
||||||
|
logger.info("[Scan] %s — cancelled before start, skipping", target)
|
||||||
|
return []
|
||||||
if not _NMAP_AVAILABLE:
|
if not _NMAP_AVAILABLE:
|
||||||
logger.warning("[Scan] nmap not available — returning mock data")
|
logger.warning("[Scan] nmap not available — returning mock data")
|
||||||
return _mock_scan(target)
|
return _mock_scan(target)
|
||||||
try:
|
try:
|
||||||
alive = await _ping_sweep(target)
|
alive = await _ping_sweep(target, run_id=run_id)
|
||||||
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
|
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
|
||||||
len(alive), target, ", ".join(sorted(alive.keys())))
|
len(alive), target, ", ".join(sorted(alive.keys())))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Phase 1 ping sweep failed: %s", exc)
|
logger.error("Phase 1 ping sweep failed: %s", exc)
|
||||||
raise RuntimeError(str(exc)) from exc
|
raise RuntimeError(str(exc)) from exc
|
||||||
return await _nmap_port_scan(alive)
|
return await _nmap_port_scan(alive, port_spec, run_id=run_id)
|
||||||
|
|
||||||
|
|
||||||
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
||||||
@@ -375,10 +430,50 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||||
|
"""Collapse duplicate non-hidden inventory rows that share an IP into one.
|
||||||
|
|
||||||
|
Keeps an ``approved`` row when present (it carries canvas-link semantics),
|
||||||
|
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)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
by_ip: dict[str, list[PendingDevice]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if row.ip is None: # guarded by the query, but keeps the type checker happy
|
||||||
|
continue
|
||||||
|
by_ip.setdefault(row.ip, []).append(row)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for group in by_ip.values():
|
||||||
|
if len(group) < 2:
|
||||||
|
continue
|
||||||
|
keep = next((r for r in group if r.status == "approved"), group[0])
|
||||||
|
for dup in group:
|
||||||
|
if dup is not keep:
|
||||||
|
await db.delete(dup)
|
||||||
|
deleted += 1
|
||||||
|
if deleted:
|
||||||
|
await db.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def run_scan(
|
||||||
|
ranges: list[str],
|
||||||
|
db: AsyncSession,
|
||||||
|
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 pending_devices."""
|
||||||
from app.api.routes.status import broadcast_scan_update
|
from app.api.routes.status import broadcast_scan_update
|
||||||
|
|
||||||
|
deep_scan = deep_scan or DeepScanOptions()
|
||||||
|
port_spec = _build_port_spec(deep_scan.http_ranges)
|
||||||
|
|
||||||
devices_found = 0
|
devices_found = 0
|
||||||
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
|
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
|
||||||
try:
|
try:
|
||||||
@@ -389,25 +484,18 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
||||||
|
|
||||||
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
|
# Pre-fetch hidden IPs once — avoids N+1 queries per host.
|
||||||
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
# Devices already on a canvas are intentionally NOT suppressed: they stay
|
||||||
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
|
# in the inventory and are badged "In N canvas" via per-request correlation.
|
||||||
|
|
||||||
hidden_ips_result = await db.execute(
|
hidden_ips_result = await db.execute(
|
||||||
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
||||||
)
|
)
|
||||||
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
||||||
|
|
||||||
# Clean up stale pending devices whose IPs are already in the canvas
|
# Collapse any pre-existing duplicate inventory rows (same IP, non-hidden)
|
||||||
if canvas_ips:
|
# left over from older scans, so the device shows up exactly once even if
|
||||||
from sqlalchemy import delete as sa_delete
|
# it isn't re-discovered this run (e.g. now offline).
|
||||||
await db.execute(
|
await _dedupe_pending_by_ip(db)
|
||||||
sa_delete(PendingDevice).where(
|
|
||||||
PendingDevice.status == "pending",
|
|
||||||
PendingDevice.ip.in_(canvas_ips),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Start mDNS discovery in the background while nmap scans run
|
# Start mDNS discovery in the background while nmap scans run
|
||||||
mdns_task = asyncio.create_task(_mdns_discover())
|
mdns_task = asyncio.create_task(_mdns_discover())
|
||||||
@@ -419,30 +507,48 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
nonlocal devices_found
|
nonlocal devices_found
|
||||||
ip = host["ip"]
|
ip = host["ip"]
|
||||||
|
|
||||||
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
|
# Skip only user-hidden devices. On-canvas devices are kept so they
|
||||||
if ip in canvas_ips:
|
# surface in the inventory with a canvas-presence badge.
|
||||||
logger.debug("Skipping %s — already in canvas", ip)
|
|
||||||
return
|
|
||||||
if ip in hidden_ips:
|
if ip in hidden_ips:
|
||||||
logger.debug("Skipping %s — hidden by user", ip)
|
logger.debug("Skipping %s — hidden by user", ip)
|
||||||
return
|
return
|
||||||
|
|
||||||
services = fingerprint_ports(host["open_ports"])
|
open_ports = host["open_ports"]
|
||||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
# Deep-scan HTTP probe: enrich open ports with title/header signals so
|
||||||
|
# fingerprint can confirm services on custom ports. No-op when disabled
|
||||||
existing_result = await db.execute(
|
# or when the host has no open ports (e.g. mDNS-only discovery).
|
||||||
select(PendingDevice).where(
|
if deep_scan.http_probe_enabled and open_ports:
|
||||||
PendingDevice.ip == ip,
|
open_ports = await probe_open_ports(
|
||||||
PendingDevice.status == "pending",
|
ip, open_ports, verify_tls=deep_scan.verify_tls
|
||||||
)
|
)
|
||||||
)
|
|
||||||
existing = existing_result.scalar_one_or_none()
|
services = fingerprint_ports(open_ports)
|
||||||
if existing:
|
suggested_type = suggest_node_type(open_ports, host.get("mac"))
|
||||||
existing.mac = host.get("mac") or existing.mac
|
|
||||||
existing.hostname = host.get("hostname") or existing.hostname
|
# One inventory row per device (by IP). Match across pending AND
|
||||||
existing.os = host.get("os") or existing.os
|
# approved so a re-scan of an already-approved device refreshes its
|
||||||
existing.services = services
|
# row instead of spawning a fresh "pending" duplicate. Hidden rows
|
||||||
existing.suggested_type = suggested_type
|
# are already skipped above.
|
||||||
|
existing_rows = (await db.execute(
|
||||||
|
select(PendingDevice)
|
||||||
|
.where(PendingDevice.ip == ip, PendingDevice.status != "hidden")
|
||||||
|
.order_by(PendingDevice.discovered_at)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
if existing_rows:
|
||||||
|
# Prefer an approved row (it owns the canvas link semantics),
|
||||||
|
# otherwise the oldest. Collapse any leftover duplicates created
|
||||||
|
# by earlier scans.
|
||||||
|
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
|
||||||
|
for dup in existing_rows:
|
||||||
|
if dup is not keep:
|
||||||
|
await db.delete(dup)
|
||||||
|
keep.mac = host.get("mac") or keep.mac
|
||||||
|
keep.hostname = host.get("hostname") or keep.hostname
|
||||||
|
keep.os = host.get("os") or keep.os
|
||||||
|
keep.services = services
|
||||||
|
keep.suggested_type = suggested_type
|
||||||
|
# status preserved — an approved device stays approved.
|
||||||
else:
|
else:
|
||||||
db.add(PendingDevice(
|
db.add(PendingDevice(
|
||||||
ip=ip,
|
ip=ip,
|
||||||
@@ -456,6 +562,20 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
))
|
))
|
||||||
devices_found += 1
|
devices_found += 1
|
||||||
|
|
||||||
|
# Stamp last_scan on any canvas node that matches this device by IP
|
||||||
|
# (or MAC, when known) so the inventory shows when the scanner last
|
||||||
|
# observed it. Matches across designs.
|
||||||
|
host_mac = host.get("mac")
|
||||||
|
node_match = [Node.ip == ip]
|
||||||
|
if host_mac:
|
||||||
|
node_match.append(Node.mac == host_mac)
|
||||||
|
matching_nodes = (await db.execute(
|
||||||
|
select(Node).where(or_(*node_match))
|
||||||
|
)).scalars().all()
|
||||||
|
scanned_at = datetime.now(timezone.utc)
|
||||||
|
for node in matching_nodes:
|
||||||
|
node.last_scan = scanned_at
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||||
|
|
||||||
@@ -463,7 +583,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
for cidr in ranges:
|
for cidr in ranges:
|
||||||
if _is_cancelled(run_id):
|
if _is_cancelled(run_id):
|
||||||
break
|
break
|
||||||
hosts = await _nmap_scan(cidr)
|
hosts = await _nmap_scan(cidr, port_spec, run_id=run_id)
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
if _is_cancelled(run_id):
|
if _is_cancelled(run_id):
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
return {"status": "offline", "response_time_ms": None}
|
return {"status": "offline", "response_time_ms": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ipv6(host: str) -> bool:
|
||||||
|
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||||
|
try:
|
||||||
|
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
# ping(8) -W flag units differ by OS:
|
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||||
# Linux: seconds (-W 1 = 1s)
|
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||||
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
#
|
||||||
# Windows: -w in ms (-w 1000 = 1s)
|
# -W flag units differ by OS:
|
||||||
|
# Linux: seconds (-W 2 = 2s)
|
||||||
|
# macOS: milliseconds (-W 2000 = 2s)
|
||||||
|
# Windows: -w in ms (-w 2000 = 2s)
|
||||||
|
#
|
||||||
|
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||||
|
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||||
|
ipv6 = _is_ipv6(host)
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
family = ["-6"] if ipv6 else ["-4"]
|
||||||
|
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||||
elif sys.platform == "darwin":
|
elif sys.platform == "darwin":
|
||||||
args = ["ping", "-c", "1", "-W", "1000", host]
|
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||||
else:
|
else:
|
||||||
args = ["ping", "-c", "1", "-W", "1", host]
|
family = ["-6"] if ipv6 else []
|
||||||
|
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
@@ -100,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
except (TimeoutError, OSError, socket.gaierror):
|
except (TimeoutError, OSError, socket.gaierror):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Per-service status checks ---
|
||||||
|
|
||||||
|
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
||||||
|
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
||||||
|
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
||||||
|
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
||||||
|
_NON_HTTP_PORTS = frozenset({
|
||||||
|
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||||
|
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||||
|
})
|
||||||
|
_HTTPS_PORTS = frozenset({443, 8443})
|
||||||
|
|
||||||
|
|
||||||
|
def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||||
|
"""Bracket bare IPv6 literals for use in a URL."""
|
||||||
|
return f"[{host}]" if _is_ipv6(host) else host
|
||||||
|
|
||||||
|
|
||||||
|
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||||
|
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||||
|
|
||||||
|
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
||||||
|
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
||||||
|
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||||
|
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||||
|
"""
|
||||||
|
if not host or host.startswith("-"):
|
||||||
|
return "unknown"
|
||||||
|
if str(svc.get("protocol", "")).lower() == "udp":
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
port = svc.get("port")
|
||||||
|
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||||
|
|
||||||
|
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||||
|
if port is not None and port in _NON_HTTP_PORTS:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
name = str(svc.get("service_name", "")).lower()
|
||||||
|
is_web = port is not None or "http" in name
|
||||||
|
if not is_web:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheme = "https" if (
|
||||||
|
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||||
|
) else "http"
|
||||||
|
url_host = _service_host(svc, host)
|
||||||
|
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||||
|
return "online" if await _http_get(url, verify=False) else "offline"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||||
|
return "offline"
|
||||||
|
|
||||||
|
|
||||||
|
async def check_services(
|
||||||
|
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Check every service against host concurrently (bounded).
|
||||||
|
|
||||||
|
Returns a list of {port, protocol, status} dicts, one per input service.
|
||||||
|
"""
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
|
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
status = await check_service(svc, host)
|
||||||
|
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||||
|
|
||||||
|
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import ssl
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -20,42 +21,8 @@ _NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
|||||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||||
|
|
||||||
|
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||||
"""Return a generic, credential-free message for an MQTT error.
|
|
||||||
|
|
||||||
The raw aiomqtt/paho error string can include the broker URI with
|
|
||||||
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
|
||||||
detail that should not leak to API clients. Map known patterns to
|
|
||||||
coarse categories; default to a generic failure message. The original
|
|
||||||
exception is logged at WARNING level for operator debugging.
|
|
||||||
"""
|
|
||||||
logger.warning("MQTT error (sanitized for client): %r", exc)
|
|
||||||
raw = str(exc).lower()
|
|
||||||
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
|
||||||
return "Authentication failed"
|
|
||||||
if "refused" in raw:
|
|
||||||
return "Connection refused by broker"
|
|
||||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
|
||||||
return "Broker hostname could not be resolved"
|
|
||||||
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
|
||||||
return "TLS handshake failed"
|
|
||||||
if "timed out" in raw or "timeout" in raw:
|
|
||||||
return "Connection to broker timed out"
|
|
||||||
return "MQTT connection failed"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
|
||||||
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
|
||||||
ctx = ssl.create_default_context()
|
|
||||||
if insecure:
|
|
||||||
logger.warning(
|
|
||||||
"MQTT TLS certificate verification is DISABLED — "
|
|
||||||
"use only with self-signed brokers on trusted networks."
|
|
||||||
)
|
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
def build_zigbee_properties(
|
def build_zigbee_properties(
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
|
||||||
|
|
||||||
|
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
|
||||||
|
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
||||||
|
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.mqtt_common import request_response, test_connection
|
||||||
|
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
|
||||||
|
merge_zwave_properties = merge_zigbee_properties
|
||||||
|
|
||||||
|
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
|
||||||
|
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
|
||||||
|
|
||||||
|
|
||||||
|
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
|
||||||
|
"""Map a Z-Wave node's role flags to a homelable node type.
|
||||||
|
|
||||||
|
Controller → coordinator. Mains-powered / routing nodes → router.
|
||||||
|
Everything else (battery sensors, etc.) → end device.
|
||||||
|
"""
|
||||||
|
if raw.get("isControllerNode"):
|
||||||
|
return "zwave_coordinator"
|
||||||
|
if raw.get("isRouting"):
|
||||||
|
return "zwave_router"
|
||||||
|
return "zwave_enddevice"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_label(node_type: str) -> str:
|
||||||
|
"""Human role string stored as ``device_subtype`` / ``device_type``."""
|
||||||
|
return {
|
||||||
|
"zwave_coordinator": "Controller",
|
||||||
|
"zwave_router": "Router",
|
||||||
|
"zwave_enddevice": "EndDevice",
|
||||||
|
}.get(node_type, "EndDevice")
|
||||||
|
|
||||||
|
|
||||||
|
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
|
||||||
|
node_id = raw.get("id")
|
||||||
|
if node_id is None:
|
||||||
|
return None
|
||||||
|
ieee = f"zwave-{home_id}-{node_id}"
|
||||||
|
node_type = _zwave_type_to_homelable(raw)
|
||||||
|
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
|
||||||
|
model = raw.get("productLabel") or raw.get("productDescription") or None
|
||||||
|
vendor = raw.get("manufacturer") or None
|
||||||
|
return {
|
||||||
|
"id": ieee,
|
||||||
|
"label": name,
|
||||||
|
"type": node_type,
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"friendly_name": name,
|
||||||
|
"device_type": _role_label(node_type),
|
||||||
|
"node_id": node_id,
|
||||||
|
"model": model,
|
||||||
|
"vendor": vendor,
|
||||||
|
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
|
||||||
|
"parent_id": None,
|
||||||
|
"neighbors": raw.get("neighbors") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
|
||||||
|
"""Pick a home id for the network: prefer the controller's, else any node's."""
|
||||||
|
controller_home = None
|
||||||
|
for entry in raw_nodes:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
home = entry.get("homeId")
|
||||||
|
if home is None:
|
||||||
|
continue
|
||||||
|
if entry.get("isControllerNode"):
|
||||||
|
return str(home)
|
||||||
|
if controller_home is None:
|
||||||
|
controller_home = str(home)
|
||||||
|
return controller_home or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_zwave_nodes(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
|
||||||
|
|
||||||
|
Expected shape::
|
||||||
|
|
||||||
|
{"success": true, "result": [ {<node>}, ... ]}
|
||||||
|
|
||||||
|
Edges are a strict coordinator → router → end-device tree, derived from
|
||||||
|
each node's ``neighbors`` list (same approach as the Zigbee parser).
|
||||||
|
"""
|
||||||
|
if payload.get("success") is False:
|
||||||
|
raise ValueError("Z-Wave gateway reported failure")
|
||||||
|
|
||||||
|
result = payload.get("result")
|
||||||
|
if result is None:
|
||||||
|
result = []
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise ValueError("Malformed getNodes response: 'result' is not a list")
|
||||||
|
|
||||||
|
home_id = _resolve_home_id(result)
|
||||||
|
|
||||||
|
nodes_list: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
coordinator_id: str | None = None
|
||||||
|
# Map nodeId (int) → identity string, to translate neighbors → edges.
|
||||||
|
id_by_node_id: dict[Any, str] = {}
|
||||||
|
|
||||||
|
for entry in result:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
node = _node_from_zwave(entry, home_id)
|
||||||
|
if node is None or node["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(node["id"])
|
||||||
|
id_by_node_id[node["node_id"]] = node["id"]
|
||||||
|
nodes_list.append(node)
|
||||||
|
if node["type"] == "zwave_coordinator":
|
||||||
|
coordinator_id = node["id"]
|
||||||
|
|
||||||
|
# Translate neighbor lists into candidate edges (only between known nodes).
|
||||||
|
raw_edges: list[dict[str, Any]] = []
|
||||||
|
for node in nodes_list:
|
||||||
|
src = node["id"]
|
||||||
|
for neighbor in node.get("neighbors") or []:
|
||||||
|
tgt = id_by_node_id.get(neighbor)
|
||||||
|
if tgt and tgt != src:
|
||||||
|
raw_edges.append({"source": src, "target": tgt})
|
||||||
|
|
||||||
|
# Build parent_id hierarchy: coordinator → routers → end devices.
|
||||||
|
if coordinator_id:
|
||||||
|
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["type"] == "zwave_router":
|
||||||
|
node["parent_id"] = coordinator_id
|
||||||
|
elif node["type"] == "zwave_enddevice":
|
||||||
|
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||||
|
node["parent_id"] = parent or coordinator_id
|
||||||
|
|
||||||
|
# Final edges = strict parent → child tree (one edge per non-coordinator).
|
||||||
|
edges_list: list[dict[str, Any]] = [
|
||||||
|
{"source": node["parent_id"], "target": node["id"]}
|
||||||
|
for node in nodes_list
|
||||||
|
if node.get("parent_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Drop transient helper keys before returning.
|
||||||
|
for node in nodes_list:
|
||||||
|
node.pop("neighbors", None)
|
||||||
|
node.pop("node_id", None)
|
||||||
|
|
||||||
|
return nodes_list, edges_list
|
||||||
|
|
||||||
|
|
||||||
|
def build_zwave_properties(
|
||||||
|
ieee: str | None,
|
||||||
|
vendor: str | None,
|
||||||
|
model: str | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
|
||||||
|
|
||||||
|
Z-Wave has no LQI, so that row is omitted. New props default to
|
||||||
|
``visible=False`` — users opt in from the right panel.
|
||||||
|
"""
|
||||||
|
props: list[dict[str, Any]] = []
|
||||||
|
if ieee:
|
||||||
|
props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
|
||||||
|
if vendor:
|
||||||
|
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||||
|
if model:
|
||||||
|
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_zwave_network(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
prefix: str = "zwave",
|
||||||
|
gateway_name: str = "zwavejs2mqtt",
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Connect to the broker, request the Z-Wave node list, return (nodes, edges).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: if the gateway does not respond in time.
|
||||||
|
ConnectionError: if the broker cannot be reached.
|
||||||
|
ValueError: if the response payload is malformed.
|
||||||
|
"""
|
||||||
|
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||||
|
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||||
|
|
||||||
|
payload = await request_response(
|
||||||
|
mqtt_host=mqtt_host,
|
||||||
|
mqtt_port=mqtt_port,
|
||||||
|
request_topic=request_topic,
|
||||||
|
response_topic=response_topic,
|
||||||
|
request_payload={"args": []},
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
tls=tls,
|
||||||
|
tls_insecure=tls_insecure,
|
||||||
|
)
|
||||||
|
|
||||||
|
return parse_zwave_nodes(payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zwave_connection(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Quick MQTT reachability check for the Z-Wave broker."""
|
||||||
|
return await test_connection(
|
||||||
|
mqtt_host=mqtt_host,
|
||||||
|
mqtt_port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
tls=tls,
|
||||||
|
tls_insecure=tls_insecure,
|
||||||
|
)
|
||||||
@@ -8,14 +8,14 @@ pydantic==2.9.2
|
|||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
bcrypt==4.2.1
|
bcrypt==4.2.1
|
||||||
python-multipart==0.0.27
|
python-multipart==0.0.31
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.149.7
|
zeroconf==0.149.16
|
||||||
aiomqtt==2.3.0
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
|
|||||||
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(label="Router", type="router")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||||
|
# created_at / updated_at always set; last_seen / last_scan null until observed.
|
||||||
|
assert node["created_at"] is not None
|
||||||
|
assert node["updated_at"] is not None
|
||||||
|
assert "last_seen" in node
|
||||||
|
assert node["last_scan"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
|
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(label="Old Label")
|
n1 = node_payload(label="Old Label")
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|||||||
@@ -97,6 +97,37 @@ async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
|||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_edge_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict, two_nodes):
|
||||||
|
# Regression for #225: MCP create_edge sent no design_id, so edges were
|
||||||
|
# persisted with design_id=null and never rendered until a restart.
|
||||||
|
src, tgt = two_nodes
|
||||||
|
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
||||||
|
design_id = design.json()["id"]
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/edges",
|
||||||
|
json={"source": src, "target": tgt, "type": "ethernet"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["design_id"] == design_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_edge_respects_explicit_design_id(client: AsyncClient, headers: dict, two_nodes):
|
||||||
|
src, tgt = two_nodes
|
||||||
|
await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
||||||
|
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
||||||
|
second_id = second.json()["id"]
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/edges",
|
||||||
|
json={"source": src, "target": tgt, "type": "ethernet", "design_id": second_id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["design_id"] == second_id
|
||||||
|
|
||||||
|
|
||||||
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
|
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
|
||||||
src, tgt = two_nodes
|
src, tgt = two_nodes
|
||||||
res = await client.post(
|
res = await client.post(
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type
|
from app.services.fingerprint import (
|
||||||
|
fingerprint_ports,
|
||||||
|
match_port,
|
||||||
|
match_service,
|
||||||
|
suggest_node_type,
|
||||||
|
suggest_type_from_mac,
|
||||||
|
)
|
||||||
|
|
||||||
MOCK_SIGNATURES = [
|
MOCK_SIGNATURES = [
|
||||||
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
||||||
@@ -173,3 +179,157 @@ def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
|
|||||||
{"port": 1883, "protocol": "tcp"},
|
{"port": 1883, "protocol": "tcp"},
|
||||||
])
|
])
|
||||||
assert result == "iot"
|
assert result == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
# ── OUI vendor detection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_mikrotik_returns_router():
|
||||||
|
# The motivating case: MikroTik MAC should be recognized as a router
|
||||||
|
assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router"
|
||||||
|
assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_ubiquiti_returns_ap():
|
||||||
|
# Ubiquiti makes routers, switches, APs, cameras — most homelab gear is UniFi APs,
|
||||||
|
# so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open.
|
||||||
|
assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap"
|
||||||
|
assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_synology_returns_nas():
|
||||||
|
assert suggest_type_from_mac("00:11:32:11:22:33") == "nas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_qnap_returns_nas():
|
||||||
|
assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_hikvision_returns_camera():
|
||||||
|
assert suggest_type_from_mac("28:57:be:11:22:33") == "camera"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_dahua_returns_camera():
|
||||||
|
assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_cisco_returns_switch():
|
||||||
|
assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_raspberry_pi_returns_server():
|
||||||
|
assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_handles_uppercase():
|
||||||
|
# MACs may arrive in any case; lookup must be case-insensitive
|
||||||
|
assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_type_from_mac_unknown_oui_returns_none():
|
||||||
|
assert suggest_type_from_mac("00:00:01:11:22:33") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_mikrotik_mac_returns_router_no_ports():
|
||||||
|
# MikroTik device with no scanned ports should still be classified as router via MAC
|
||||||
|
assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_synology_mac_with_http_returns_nas():
|
||||||
|
# NAS priority beats server, so a Synology MAC + open HTTP → nas
|
||||||
|
result = suggest_node_type(
|
||||||
|
[{"port": 80, "protocol": "tcp"}],
|
||||||
|
mac="00:11:32:11:22:33",
|
||||||
|
)
|
||||||
|
assert result == "nas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
|
||||||
|
# Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority)
|
||||||
|
result = suggest_node_type(
|
||||||
|
[{"port": 179, "protocol": "tcp"}],
|
||||||
|
mac="24:a4:3c:11:22:33",
|
||||||
|
)
|
||||||
|
assert result == "router"
|
||||||
|
|
||||||
|
|
||||||
|
# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
|
||||||
|
|
||||||
|
HTTP_SIGNATURES = [
|
||||||
|
# Generic web fallback on 8096 (port-only guess)
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
|
||||||
|
"service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
||||||
|
# Same port, but confirmed by HTML title → should win when probe confirms
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
|
||||||
|
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
|
||||||
|
# Port-agnostic: matches on HTTP content regardless of port
|
||||||
|
{"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
|
||||||
|
"service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
|
||||||
|
# Banner-based entry, no http
|
||||||
|
{"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
|
||||||
|
"service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def http_signatures():
|
||||||
|
with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_regex_confirmed_beats_port_only(http_signatures):
|
||||||
|
# Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
|
||||||
|
sig = match_service(8096, "tcp", banner=None,
|
||||||
|
http_signals={"title": "Jellyfin", "headers": {}})
|
||||||
|
assert sig["service_name"] == "Jellyfin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_regex_matches_on_header(http_signatures):
|
||||||
|
sig = match_service(8096, "tcp", banner=None,
|
||||||
|
http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
|
||||||
|
assert sig["service_name"] == "Jellyfin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_regex_miss_falls_back_to_port_only(http_signatures):
|
||||||
|
# Probe ran but nothing matched the http_regex → generic port-only entry wins
|
||||||
|
sig = match_service(8096, "tcp", banner=None,
|
||||||
|
http_signals={"title": "Some Other App", "headers": {}})
|
||||||
|
assert sig["service_name"] == "HTTP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_disabled_ignores_http_regex(http_signatures):
|
||||||
|
# http_signals=None (deep scan off) → http_regex entry degrades to port-only,
|
||||||
|
# generic entry (listed first) wins — identical to pre-probe behaviour.
|
||||||
|
sig = match_service(8096, "tcp", banner=None, http_signals=None)
|
||||||
|
assert sig["service_name"] == "HTTP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_agnostic_match_on_custom_port(http_signatures):
|
||||||
|
# Portainer found on a non-standard port, recognised purely by HTTP content
|
||||||
|
sig = match_service(54321, "tcp", banner=None,
|
||||||
|
http_signals={"title": "Portainer", "headers": {}})
|
||||||
|
assert sig["service_name"] == "Portainer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_agnostic_requires_probe(http_signatures):
|
||||||
|
# Same custom port, probe off → no signal → no match
|
||||||
|
assert match_service(54321, "tcp", banner=None, http_signals=None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_banner_match_still_works_with_probe(http_signatures):
|
||||||
|
sig = match_service(9090, "tcp", banner="prometheus 2.x",
|
||||||
|
http_signals={"title": "x", "headers": {}})
|
||||||
|
assert sig["service_name"] == "Prometheus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_port_alias_has_no_http(http_signatures):
|
||||||
|
# match_port() is the probe-less alias → http_regex entry degrades to port-only
|
||||||
|
sig = match_port(8096, "tcp")
|
||||||
|
assert sig["service_name"] == "HTTP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_ports_uses_http_signals(http_signatures):
|
||||||
|
results = fingerprint_ports([
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner": None,
|
||||||
|
"http_signals": {"title": "Jellyfin", "headers": {}}},
|
||||||
|
])
|
||||||
|
assert results[0]["service_name"] == "Jellyfin"
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Tests for the HTTP probe used by deep-scan service identification."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.http_probe import (
|
||||||
|
_extract_title,
|
||||||
|
probe_open_ports,
|
||||||
|
probe_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code=status, text=text, headers=headers or {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── _extract_title ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_extract_title_basic():
|
||||||
|
assert _extract_title("<html><title>Jellyfin</title></html>") == "Jellyfin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_title_collapses_whitespace():
|
||||||
|
assert _extract_title("<title>\n My App\n</title>") == "My App"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_title_missing():
|
||||||
|
assert _extract_title("<html><body>no title</body></html>") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_title_case_insensitive():
|
||||||
|
assert _extract_title("<TITLE>Portainer</TITLE>") == "Portainer"
|
||||||
|
|
||||||
|
|
||||||
|
# ── probe_port ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_reads_title():
|
||||||
|
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
|
||||||
|
result = await probe_port("10.0.0.5", 8096)
|
||||||
|
assert result == {"title": "Jellyfin", "headers": {}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_reads_headers():
|
||||||
|
resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
|
||||||
|
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
|
||||||
|
result = await probe_port("10.0.0.5", 3000)
|
||||||
|
assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_falls_back_to_http():
|
||||||
|
# https raises, http succeeds
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def fake_get(self, url, **kw):
|
||||||
|
calls["n"] += 1
|
||||||
|
if url.startswith("https"):
|
||||||
|
raise httpx.ConnectError("tls fail")
|
||||||
|
return _response("<title>HTTP App</title>")
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||||
|
result = await probe_port("10.0.0.5", 8080)
|
||||||
|
assert result["title"] == "HTTP App"
|
||||||
|
assert calls["n"] == 2 # tried https then http
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_no_signal_returns_none():
|
||||||
|
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
|
||||||
|
result = await probe_port("10.0.0.5", 8080)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_timeout_returns_none():
|
||||||
|
with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
|
||||||
|
result = await probe_port("10.0.0.5", 8080)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_skips_non_http_ports():
|
||||||
|
# SSH should never trigger an HTTP request
|
||||||
|
get = AsyncMock()
|
||||||
|
with patch("httpx.AsyncClient.get", new=get):
|
||||||
|
result = await probe_port("10.0.0.5", 22)
|
||||||
|
assert result is None
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_port_verify_tls_flag_passed():
|
||||||
|
with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
|
||||||
|
instance = client_cls.return_value.__aenter__.return_value
|
||||||
|
instance.get = AsyncMock(return_value=_response("<title>X</title>"))
|
||||||
|
await probe_port("10.0.0.5", 8443, verify_tls=True)
|
||||||
|
assert client_cls.call_args.kwargs["verify"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── probe_open_ports ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_open_ports_enriches_each_port():
|
||||||
|
async def fake_get(self, url, **kw):
|
||||||
|
if ":8096" in url:
|
||||||
|
return _response("<title>Jellyfin</title>")
|
||||||
|
return _response("")
|
||||||
|
|
||||||
|
ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
|
||||||
|
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||||
|
result = await probe_open_ports("10.0.0.5", ports)
|
||||||
|
|
||||||
|
by_port = {p["port"]: p for p in result}
|
||||||
|
assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
|
||||||
|
assert by_port[9999]["http_signals"] is None
|
||||||
@@ -105,6 +105,28 @@ async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_nodes_gain_last_scan_column(legacy_engine):
|
||||||
|
"""A legacy nodes table (no last_scan) gains the column after init_db."""
|
||||||
|
db_path, engine = legacy_engine
|
||||||
|
await _build_legacy_schema(engine)
|
||||||
|
|
||||||
|
await database.init_db()
|
||||||
|
|
||||||
|
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
async with check.begin() as conn:
|
||||||
|
cols = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||||
|
assert "last_scan" in {c[1] for c in cols}
|
||||||
|
# Existing rows backfill to NULL (never scanned yet).
|
||||||
|
last_scan = (await conn.exec_driver_sql(
|
||||||
|
"SELECT last_scan FROM nodes WHERE id='n1'"
|
||||||
|
)).fetchone()
|
||||||
|
assert last_scan[0] is None
|
||||||
|
finally:
|
||||||
|
await check.dispose()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_migration_is_idempotent(legacy_engine):
|
async def test_migration_is_idempotent(legacy_engine):
|
||||||
"""Running init_db twice must not duplicate the design or drop any data."""
|
"""Running init_db twice must not duplicate the design or drop any data."""
|
||||||
db_path, engine = legacy_engine
|
db_path, engine = legacy_engine
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Unit tests for the shared MQTT helpers in mqtt_common."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.mqtt_common import (
|
||||||
|
_build_tls_context,
|
||||||
|
_sanitize_mqtt_error,
|
||||||
|
request_response,
|
||||||
|
)
|
||||||
|
from app.services.mqtt_common import test_connection as _test_connection
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _sanitize_mqtt_error — never leak credentials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_auth_error() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
|
||||||
|
assert msg == "Authentication failed"
|
||||||
|
assert "secret" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_refused() -> None:
|
||||||
|
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_dns() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
|
||||||
|
assert msg == "Broker hostname could not be resolved"
|
||||||
|
assert "broker.lan" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_tls() -> None:
|
||||||
|
assert _sanitize_mqtt_error(
|
||||||
|
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
||||||
|
) == "TLS handshake failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_timeout() -> None:
|
||||||
|
assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_unknown_falls_back() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
|
||||||
|
assert msg == "MQTT connection failed"
|
||||||
|
assert "hunter2" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_tls_context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_secure_verifies() -> None:
|
||||||
|
ctx = _build_tls_context(insecure=False)
|
||||||
|
assert ctx.check_hostname is True
|
||||||
|
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_insecure_disables_verification() -> None:
|
||||||
|
ctx = _build_tls_context(insecure=True)
|
||||||
|
assert ctx.check_hostname is False
|
||||||
|
assert ctx.verify_mode == ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# request_response (mocked aiomqtt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE = {"success": True, "result": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_client_factory(topic: str, payload: dict):
|
||||||
|
class _FakeMessage:
|
||||||
|
_yielded = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.topic = topic
|
||||||
|
self.payload = json.dumps(payload).encode()
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if self._yielded:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
self._yielded = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def subscribe(self, _topic: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish(self, _topic: str, _payload: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self):
|
||||||
|
return _FakeMessage()
|
||||||
|
|
||||||
|
return _FakeClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_response_success() -> None:
|
||||||
|
topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
out = await request_response(
|
||||||
|
"localhost", 1883, "req/topic", topic, {"args": []}
|
||||||
|
)
|
||||||
|
assert out == _SAMPLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_response_connection_error() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Connection refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await request_response("bad", 1883, "req", "resp", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_response_passes_tls_context() -> None:
|
||||||
|
topic = "resp"
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
|
||||||
|
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
||||||
|
assert ctx.verify_mode == ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_success() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
assert await _test_connection("localhost", 1883) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_failure() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await _test_connection("bad", 1883)
|
||||||
@@ -67,6 +67,50 @@ async def test_update_node_not_found(client: AsyncClient, headers: dict):
|
|||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_node_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict):
|
||||||
|
# Regression for #225: MCP create_node sent no design_id, so nodes were
|
||||||
|
# persisted with design_id=null and never rendered on the canvas until a
|
||||||
|
# container restart reconciled them. They must attach to a design on create.
|
||||||
|
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
||||||
|
design_id = design.json()["id"]
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "generic", "label": "mcp-node", "ip": "192.168.18.99"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["design_id"] == design_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_node_respects_explicit_design_id(client: AsyncClient, headers: dict):
|
||||||
|
# When a design_id is supplied it must win over the first-design fallback.
|
||||||
|
first = await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
||||||
|
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
||||||
|
second_id = second.json()["id"]
|
||||||
|
assert first.json()["id"] != second_id
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "generic", "label": "n", "design_id": second_id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["design_id"] == second_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_node_without_any_design_stays_null(client: AsyncClient, headers: dict):
|
||||||
|
# No designs exist yet: fallback can't invent one, so design_id stays null
|
||||||
|
# rather than erroring.
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/nodes",
|
||||||
|
json={"type": "generic", "label": "orphan"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["design_id"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_node_not_found(client: AsyncClient, headers: dict):
|
async def test_delete_node_not_found(client: AsyncClient, headers: dict):
|
||||||
res = await client.delete("/api/v1/nodes/nonexistent", headers=headers)
|
res = await client.delete("/api/v1/nodes/nonexistent", headers=headers)
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|||||||
+445
-13
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
|
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -7,7 +8,7 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Design, Node, PendingDevice, ScanRun
|
||||||
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@ async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
|||||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||||
):
|
):
|
||||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
from app.services.scanner import DeepScanOptions
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
|
||||||
mock_run_scan.assert_awaited_once()
|
mock_run_scan.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +168,116 @@ async def test_list_pending_returns_device(client: AsyncClient, headers, pending
|
|||||||
assert len(data) == 1
|
assert len(data) == 1
|
||||||
assert data[0]["ip"] == "192.168.1.100"
|
assert data[0]["ip"] == "192.168.1.100"
|
||||||
assert data[0]["hostname"] == "my-server"
|
assert data[0]["hostname"] == "my-server"
|
||||||
|
# No matching node → not on any canvas.
|
||||||
|
assert data[0]["canvas_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Canvas-presence correlation (canvas_count) ---
|
||||||
|
|
||||||
|
async def _add_design(db_session, name: str) -> str:
|
||||||
|
design = Design(id=str(uuid.uuid4()), name=name)
|
||||||
|
db_session.add(design)
|
||||||
|
await db_session.commit()
|
||||||
|
return design.id
|
||||||
|
|
||||||
|
|
||||||
|
def _node(design_id: str, *, ip=None, ieee=None) -> Node:
|
||||||
|
return Node(
|
||||||
|
id=str(uuid.uuid4()), label="n", type="server", status="online",
|
||||||
|
ip=ip, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
|
||||||
|
design_id=design_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
|
||||||
|
# Same IP placed on two different canvases → canvas_count == 2.
|
||||||
|
d1 = await _add_design(db_session, "Home")
|
||||||
|
d2 = await _add_design(db_session, "Lab")
|
||||||
|
db_session.add(_node(d1, ip="192.168.1.100"))
|
||||||
|
db_session.add(_node(d2, ip="192.168.1.100"))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
data = res.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["canvas_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
||||||
|
device = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
|
||||||
|
suggested_type="zigbee_enddevice", services=[], status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(device)
|
||||||
|
d1 = await _add_design(db_session, "Zigbee")
|
||||||
|
db_session.add(_node(d1, ieee="0x00124b001"))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
by_id = {d["id"]: d for d in res.json()}
|
||||||
|
assert by_id[device.id]["canvas_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
|
||||||
|
# A node with no design_id is not "on a canvas".
|
||||||
|
db_session.add(_node(None, ip="192.168.1.100"))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert res.json()[0]["canvas_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Linked-node timestamps on the inventory response ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
|
||||||
|
# No matching canvas node → node_* timestamps are all null; the device still
|
||||||
|
# carries its own discovered_at for the "Discovered" fallback on the tile.
|
||||||
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||||
|
assert data["discovered_at"] is not None
|
||||||
|
assert data["node_created_at"] is None
|
||||||
|
assert data["node_last_scan"] is None
|
||||||
|
assert data["node_last_modified"] is None
|
||||||
|
assert data["node_last_seen"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
|
||||||
|
d1 = await _add_design(db_session, "Home")
|
||||||
|
node = _node(d1, ip="192.168.1.100")
|
||||||
|
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
||||||
|
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
||||||
|
db_session.add(node)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||||
|
assert data["node_created_at"] is not None # defaulted on insert
|
||||||
|
assert data["node_last_modified"] is not None # updated_at defaulted on insert
|
||||||
|
assert data["node_last_scan"].startswith("2026-06-01")
|
||||||
|
assert data["node_last_seen"].startswith("2026-06-25")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
|
||||||
|
# Two canvas nodes share the device IP: created_at takes the OLDEST,
|
||||||
|
# last_scan takes the NEWEST.
|
||||||
|
d1 = await _add_design(db_session, "Home")
|
||||||
|
d2 = await _add_design(db_session, "Lab")
|
||||||
|
older = _node(d1, ip="192.168.1.100")
|
||||||
|
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
newer = _node(d2, ip="192.168.1.100")
|
||||||
|
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
db_session.add_all([older, newer])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||||
|
assert data["node_created_at"].startswith("2026-01-01") # oldest
|
||||||
|
assert data["node_last_scan"].startswith("2026-06-01") # newest
|
||||||
|
|
||||||
|
|
||||||
# --- Approve device ---
|
# --- Approve device ---
|
||||||
@@ -190,9 +302,13 @@ async def test_approve_device(client: AsyncClient, headers, pending_device):
|
|||||||
assert data["approved"] is True
|
assert data["approved"] is True
|
||||||
assert "node_id" in data
|
assert "node_id" in data
|
||||||
|
|
||||||
# Device should no longer appear in pending list
|
# Approved devices stay in the inventory (status != "hidden") so they keep
|
||||||
|
# showing with an "In N canvas" badge — they are no longer dropped.
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
assert pending_res.json() == []
|
inventory = pending_res.json()
|
||||||
|
assert len(inventory) == 1
|
||||||
|
assert inventory[0]["id"] == pending_device.id
|
||||||
|
assert inventory[0]["status"] == "approved"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -331,8 +447,9 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
||||||
"""Pending devices that were already in canvas before scan starts must be removed."""
|
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
|
||||||
|
in the inventory and are surfaced with an "In N canvas" badge."""
|
||||||
node = Node(
|
node = Node(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
label="Existing Server",
|
label="Existing Server",
|
||||||
@@ -371,12 +488,13 @@ async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncS
|
|||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
)
|
)
|
||||||
assert result.scalar_one_or_none() is None
|
assert result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
|
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||||
"""Devices whose IP already exists as a canvas Node must not appear in pending."""
|
"""A scanned IP that already exists as a canvas Node still produces a pending
|
||||||
|
device (no longer suppressed)."""
|
||||||
node = Node(
|
node = Node(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
label="Existing Server",
|
label="Existing Server",
|
||||||
@@ -404,7 +522,62 @@ async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
|
|||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
)
|
)
|
||||||
assert result.scalar_one_or_none() is None
|
device = result.scalar_one_or_none()
|
||||||
|
assert device is not None
|
||||||
|
assert device.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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(
|
||||||
|
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
|
||||||
|
os=None, services=[], suggested_type="server", status="approved",
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
rows = (await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].status == "approved"
|
||||||
|
assert rows[0].hostname == "myhost.lan" # refreshed from the scan
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
|
||||||
|
"""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(
|
||||||
|
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
|
||||||
|
os=None, services=[], suggested_type="server", status=status,
|
||||||
|
))
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
rows = (await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].status == "approved" # approved row is the one kept
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -479,6 +652,10 @@ async def test_stop_scan_success(client: AsyncClient, headers, db_session: Async
|
|||||||
assert res.json() == {"stopping": True}
|
assert res.json() == {"stopping": True}
|
||||||
# run_id added to cancel set
|
# run_id added to cancel set
|
||||||
assert run.id in _cancelled_runs
|
assert run.id in _cancelled_runs
|
||||||
|
# status flipped eagerly so the UI reacts without waiting for a checkpoint
|
||||||
|
await db_session.refresh(run)
|
||||||
|
assert run.status == "cancelled"
|
||||||
|
assert run.finished_at is not None
|
||||||
# cleanup for other tests
|
# cleanup for other tests
|
||||||
_cancelled_runs.discard(run.id)
|
_cancelled_runs.discard(run.id)
|
||||||
|
|
||||||
@@ -518,7 +695,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
|
|||||||
|
|
||||||
call_count = 0
|
call_count = 0
|
||||||
|
|
||||||
def nmap_side_effect(target: str):
|
def nmap_side_effect(target: str, port_spec: str | None = None, run_id: str | None = None):
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
# Signal cancellation after the first CIDR scan completes
|
# Signal cancellation after the first CIDR scan completes
|
||||||
@@ -612,9 +789,80 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
|
|||||||
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
||||||
assert len(data["device_ids"]) == 2
|
assert len(data["device_ids"]) == 2
|
||||||
assert data["skipped"] == 0
|
assert data["skipped"] == 0
|
||||||
# Pending list should now be empty
|
# Approved devices stay in the inventory, now marked "approved".
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
assert pending_res.json() == []
|
inventory = pending_res.json()
|
||||||
|
assert len(inventory) == 2
|
||||||
|
assert all(d["status"] == "approved" for d in inventory)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||||
|
client: AsyncClient, headers, db_session, two_pending_devices
|
||||||
|
):
|
||||||
|
"""Regression: a device already approved (status='approved', e.g. placed on
|
||||||
|
another canvas) must still get a node on the design being approved onto.
|
||||||
|
|
||||||
|
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]
|
||||||
|
design_a = await _add_design(db_session, "Canvas A")
|
||||||
|
design_b = await _add_design(db_session, "Canvas B")
|
||||||
|
|
||||||
|
# Approve both onto design A.
|
||||||
|
res_a = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": ids, "design_id": design_a},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res_a.json()["approved"] == 2
|
||||||
|
|
||||||
|
# Re-approve the same (now status='approved') devices onto design B.
|
||||||
|
res_b = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": ids, "design_id": design_b},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
data_b = res_b.json()
|
||||||
|
assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
|
||||||
|
assert data_b["skipped"] == 0
|
||||||
|
|
||||||
|
# Two nodes now exist on each design.
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
nodes_b = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
|
||||||
|
).scalars().all()
|
||||||
|
assert len(nodes_b) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_skips_device_already_on_target_design(
|
||||||
|
client: AsyncClient, headers, db_session, two_pending_devices
|
||||||
|
):
|
||||||
|
"""A device already on the target canvas (same ip) is not placed twice."""
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
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"))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": ids, "design_id": design},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
data = res.json()
|
||||||
|
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
||||||
|
assert data["skipped"] == 1
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
nodes = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
|
||||||
|
).scalars().all()
|
||||||
|
# The pre-existing node plus the one newly approved — no duplicate for .10.
|
||||||
|
assert len(nodes) == 2
|
||||||
|
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -1122,3 +1370,187 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
|||||||
assert len(edges) == 1
|
assert len(edges) == 1
|
||||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||||
assert links == [] # consumed
|
assert links == [] # consumed
|
||||||
|
|
||||||
|
|
||||||
|
# --- Deep scan: trigger overrides + config persistence ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_deep_scan_falls_back_to_settings():
|
||||||
|
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
||||||
|
|
||||||
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||||
|
mock_settings.scanner_http_ranges = ["7000-7100"]
|
||||||
|
mock_settings.scanner_http_probe_enabled = True
|
||||||
|
mock_settings.scanner_http_verify_tls = False
|
||||||
|
# Empty payload → all values come from settings defaults
|
||||||
|
ds = _resolve_deep_scan(TriggerScanRequest())
|
||||||
|
assert ds.http_ranges == ["7000-7100"]
|
||||||
|
assert ds.http_probe_enabled is True
|
||||||
|
assert ds.verify_tls is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_deep_scan_override_wins():
|
||||||
|
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
||||||
|
|
||||||
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||||
|
mock_settings.scanner_http_ranges = []
|
||||||
|
mock_settings.scanner_http_probe_enabled = False
|
||||||
|
mock_settings.scanner_http_verify_tls = False
|
||||||
|
ds = _resolve_deep_scan(
|
||||||
|
TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
|
||||||
|
)
|
||||||
|
assert ds.http_ranges == ["9000"]
|
||||||
|
assert ds.http_probe_enabled is True
|
||||||
|
assert ds.verify_tls is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_bg(run_id, ranges, deep_scan):
|
||||||
|
captured["deep_scan"] = deep_scan
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan._background_scan", new=fake_bg),
|
||||||
|
patch("app.api.routes.scan.settings") as mock_settings,
|
||||||
|
):
|
||||||
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||||
|
mock_settings.scanner_http_ranges = []
|
||||||
|
mock_settings.scanner_http_probe_enabled = False
|
||||||
|
mock_settings.scanner_http_verify_tls = False
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/trigger",
|
||||||
|
json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert captured["deep_scan"].http_probe_enabled is True
|
||||||
|
assert captured["deep_scan"].http_ranges == ["8000-8100"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
|
||||||
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||||
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/trigger",
|
||||||
|
json={"http_ranges": ["70000-80000"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
|
||||||
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||||
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||||
|
mock_settings.scanner_http_ranges = ["8000-8100"]
|
||||||
|
mock_settings.scanner_http_probe_enabled = True
|
||||||
|
mock_settings.scanner_http_verify_tls = False
|
||||||
|
res = await client.get("/api/v1/scan/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["http_ranges"] == ["8000-8100"]
|
||||||
|
assert data["http_probe_enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
|
||||||
|
saved = {}
|
||||||
|
|
||||||
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||||
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||||
|
mock_settings.scanner_http_ranges = []
|
||||||
|
mock_settings.scanner_http_probe_enabled = False
|
||||||
|
mock_settings.scanner_http_verify_tls = False
|
||||||
|
mock_settings.save_overrides = lambda: saved.update(
|
||||||
|
http_ranges=mock_settings.scanner_http_ranges,
|
||||||
|
probe=mock_settings.scanner_http_probe_enabled,
|
||||||
|
)
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/config",
|
||||||
|
json={
|
||||||
|
"ranges": ["192.168.1.0/24"],
|
||||||
|
"http_ranges": ["9000-9100"],
|
||||||
|
"http_probe_enabled": True,
|
||||||
|
"verify_tls": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Z-Wave approve: active design targeting + wireless props (regression) ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_targets_requested_design(client, headers, db_session):
|
||||||
|
"""bulk-approve must place nodes on the design_id sent by the UI, not the
|
||||||
|
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(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ieee_address="zwave-H-2",
|
||||||
|
friendly_name="Living Room Plug",
|
||||||
|
suggested_type="zwave_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
vendor="Aeotec",
|
||||||
|
model="ZW096",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
db_session.add(dev)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": [dev.id], "design_id": active},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["approved"] == 1
|
||||||
|
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
|
||||||
|
).scalar_one()
|
||||||
|
assert node.design_id == active
|
||||||
|
assert node.design_id != first
|
||||||
|
# Z-Wave device → online + Z-Wave property rows, no ICMP check.
|
||||||
|
assert node.status == "online"
|
||||||
|
assert node.check_method == "none"
|
||||||
|
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ieee_address="zwave-H-9",
|
||||||
|
friendly_name="Door Sensor",
|
||||||
|
suggested_type="zwave_enddevice",
|
||||||
|
vendor="Aeotec",
|
||||||
|
model="ZW120",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
db_session.add(dev)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{dev.id}/approve",
|
||||||
|
json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
|
||||||
|
).scalar_one()
|
||||||
|
assert node.design_id == active
|
||||||
|
assert node.status == "online"
|
||||||
|
assert node.check_method == "none"
|
||||||
|
assert any(p["key"] == "Z-Wave ID" for p in node.properties)
|
||||||
|
|||||||
@@ -277,6 +277,74 @@ async def test_nmap_scan_raises_on_sweep_error():
|
|||||||
await _nmap_scan("192.168.1.0/24")
|
await _nmap_scan("192.168.1.0/24")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cancellation responsiveness (issue #218)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_scan_cancelled_before_start_skips_phases():
|
||||||
|
"""A run already cancelled returns immediately without touching the network."""
|
||||||
|
from app.services.scanner import _cancelled_runs, _nmap_scan, request_cancel
|
||||||
|
|
||||||
|
run_id = "cancel-before-start"
|
||||||
|
request_cancel(run_id)
|
||||||
|
try:
|
||||||
|
with patch("app.services.scanner._ping_sweep", new_callable=AsyncMock) as mock_sweep, \
|
||||||
|
patch("app.services.scanner._nmap_port_scan", new_callable=AsyncMock) as mock_port:
|
||||||
|
result = await _nmap_scan("192.168.1.0/24", run_id=run_id)
|
||||||
|
assert result == []
|
||||||
|
mock_sweep.assert_not_called()
|
||||||
|
mock_port.assert_not_called()
|
||||||
|
finally:
|
||||||
|
_cancelled_runs.discard(run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_sweep_cancelled_mid_sweep_returns_empty():
|
||||||
|
"""Cancelling during Phase 1 bails before Phase 2 — no alive hosts returned."""
|
||||||
|
from app.services.scanner import _cancelled_runs, _ping_sweep, request_cancel
|
||||||
|
|
||||||
|
run_id = "cancel-during-sweep"
|
||||||
|
|
||||||
|
async def _fake_subprocess(*args, **kwargs):
|
||||||
|
proc = AsyncMock()
|
||||||
|
proc.wait = AsyncMock(return_value=1)
|
||||||
|
proc.returncode = 1
|
||||||
|
return proc
|
||||||
|
|
||||||
|
request_cancel(run_id)
|
||||||
|
try:
|
||||||
|
with patch("app.services.scanner.asyncio.create_subprocess_exec", new=_fake_subprocess), \
|
||||||
|
patch("app.services.scanner._arp_table_hosts", return_value={}):
|
||||||
|
result = await _ping_sweep("192.168.1.0/30", run_id=run_id)
|
||||||
|
assert result == {}
|
||||||
|
finally:
|
||||||
|
_cancelled_runs.discard(run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nmap_port_scan_skips_queued_hosts_when_cancelled():
|
||||||
|
"""Once cancelled, queued hosts return unscanned instead of invoking nmap."""
|
||||||
|
from app.services.scanner import _cancelled_runs, _nmap_port_scan, request_cancel
|
||||||
|
|
||||||
|
run_id = "cancel-port-scan"
|
||||||
|
alive = {
|
||||||
|
"192.168.1.10": {
|
||||||
|
"ip": "192.168.1.10", "mac": None, "hostname": None,
|
||||||
|
"os": None, "open_ports": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
request_cancel(run_id)
|
||||||
|
try:
|
||||||
|
with patch("app.services.scanner._nmap_scan_single") as mock_single:
|
||||||
|
result = await _nmap_port_scan(alive, run_id=run_id)
|
||||||
|
mock_single.assert_not_called()
|
||||||
|
assert result[0]["ip"] == "192.168.1.10"
|
||||||
|
assert result[0]["open_ports"] == []
|
||||||
|
finally:
|
||||||
|
_cancelled_runs.discard(run_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _mdns_discover
|
# _mdns_discover
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -359,7 +427,7 @@ async def test_nmap_port_scan_tolerates_single_host_exception():
|
|||||||
|
|
||||||
call_count = 0
|
call_count = 0
|
||||||
|
|
||||||
def _flaky_scan(host_dict):
|
def _flaky_scan(host_dict, port_spec=None):
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if host_dict["ip"] == "192.168.1.1":
|
if host_dict["ip"] == "192.168.1.1":
|
||||||
@@ -404,6 +472,84 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
|||||||
assert any(d.ip == "192.168.1.5" for d in devices)
|
assert any(d.ip == "192.168.1.5" for d in devices)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
||||||
|
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
node = await session.get(Node, "n1")
|
||||||
|
|
||||||
|
assert node is not None
|
||||||
|
assert node.last_scan is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
||||||
|
"""A node with no IP but a matching MAC still gets last_scan stamped."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
node = await session.get(Node, "n2")
|
||||||
|
|
||||||
|
assert node is not None
|
||||||
|
assert node.last_scan is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||||
|
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
node = await session.get(Node, "n3")
|
||||||
|
|
||||||
|
assert node is not None
|
||||||
|
assert node.last_scan is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_mdns_only_device_added(mem_db):
|
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 pending_devices."""
|
||||||
@@ -457,8 +603,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_skips_canvas_nodes(mem_db):
|
async def test_run_scan_keeps_canvas_nodes(mem_db):
|
||||||
"""Hosts already approved onto the canvas must be skipped."""
|
"""Hosts already on a canvas are NOT suppressed — they stay in the inventory
|
||||||
|
(badged "In N canvas" via correlation), so a re-scan still records them."""
|
||||||
from app.services.scanner import run_scan
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
run_id = _make_run_id()
|
run_id = _make_run_id()
|
||||||
@@ -481,7 +628,9 @@ async def test_run_scan_skips_canvas_nodes(mem_db):
|
|||||||
|
|
||||||
async with mem_db() as session:
|
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(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
|
||||||
assert result.scalar_one_or_none() is None
|
device = result.scalar_one_or_none()
|
||||||
|
assert device is not None
|
||||||
|
assert device.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -533,3 +682,135 @@ async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
|
|||||||
run = await session.get(ScanRun, run_id)
|
run = await session.get(ScanRun, run_id)
|
||||||
assert run is not None
|
assert run is not None
|
||||||
assert run.status == "cancelled"
|
assert run.status == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deep scan: port-range plumbing + HTTP probe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_valid_port_range():
|
||||||
|
from app.services.scanner import _valid_port_range
|
||||||
|
|
||||||
|
assert _valid_port_range("8080")
|
||||||
|
assert _valid_port_range("8000-8100")
|
||||||
|
assert not _valid_port_range("8100-8000") # reversed
|
||||||
|
assert not _valid_port_range("0") # below 1
|
||||||
|
assert not _valid_port_range("70000") # above 65535
|
||||||
|
assert not _valid_port_range("abc")
|
||||||
|
assert not _valid_port_range("80,443") # not a single range
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_port_spec_default_when_empty():
|
||||||
|
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
||||||
|
|
||||||
|
assert _build_port_spec([]) == _EXTRA_PORTS
|
||||||
|
assert _build_port_spec(None) == _EXTRA_PORTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_port_spec_appends_valid_ranges():
|
||||||
|
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
||||||
|
|
||||||
|
spec = _build_port_spec(["8000-8100", "9000"])
|
||||||
|
assert spec == _EXTRA_PORTS + ",8000-8100,9000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_port_spec_drops_invalid_ranges():
|
||||||
|
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
||||||
|
|
||||||
|
# invalid entries silently dropped; only valid kept
|
||||||
|
assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS
|
||||||
|
assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db):
|
||||||
|
from app.services.scanner import DeepScanOptions, run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_nmap(target, port_spec, run_id=None):
|
||||||
|
captured["port_spec"] = port_spec
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", new=fake_nmap), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(
|
||||||
|
["192.168.1.0/24"], session, run_id,
|
||||||
|
deep_scan=DeepScanOptions(http_ranges=["8000-8100"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "8000-8100" in captured["port_spec"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_probe_enriches_services(mem_db):
|
||||||
|
"""With probe enabled, a custom-port service is identified via HTTP signals."""
|
||||||
|
from app.services.scanner import DeepScanOptions, run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{
|
||||||
|
"ip": "192.168.1.50", "hostname": None, "mac": None, "os": None,
|
||||||
|
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
|
||||||
|
}]
|
||||||
|
jellyfin_sig = [{
|
||||||
|
"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
|
||||||
|
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server",
|
||||||
|
}]
|
||||||
|
|
||||||
|
async def fake_probe(ip, ports, verify_tls=False, concurrency=50):
|
||||||
|
return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports]
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.services.scanner.probe_open_ports", new=fake_probe), \
|
||||||
|
patch("app.services.fingerprint._load", return_value=jellyfin_sig), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(
|
||||||
|
["192.168.1.0/24"], session, run_id,
|
||||||
|
deep_scan=DeepScanOptions(http_probe_enabled=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
|
||||||
|
device = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert device is not None
|
||||||
|
assert any(s["service_name"] == "Jellyfin" for s in device.services)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_no_probe_when_disabled(mem_db):
|
||||||
|
"""Probe must not be called on a standard (non-deep) scan."""
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
run_id = _make_run_id()
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_scan_run(run_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
nmap_hosts = [{
|
||||||
|
"ip": "192.168.1.51", "hostname": None, "mac": None, "os": None,
|
||||||
|
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
|
||||||
|
}]
|
||||||
|
probe = AsyncMock()
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
|
||||||
|
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||||
|
patch("app.services.scanner.probe_open_ports", new=probe), \
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||||
|
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||||
|
|
||||||
|
probe.assert_not_called()
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
from app.core.scheduler import (
|
||||||
|
_run_service_checks,
|
||||||
|
_run_status_checks,
|
||||||
|
set_service_checks_enabled,
|
||||||
|
start_scheduler,
|
||||||
|
stop_scheduler,
|
||||||
|
)
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
|
|
||||||
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
|
|||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
mock_settings.status_checker_interval = 45
|
mock_settings.status_checker_interval = 45
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
_, kwargs = mock_sched.add_job.call_args
|
_, kwargs = mock_sched.add_job.call_args
|
||||||
assert kwargs["seconds"] == 45
|
assert kwargs["seconds"] == 45
|
||||||
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
|
|||||||
mock_sched.add_job.assert_called_once()
|
mock_sched.add_job.assert_called_once()
|
||||||
mock_sched.start.assert_called_once()
|
mock_sched.start.assert_called_once()
|
||||||
mock_sched.shutdown.assert_called_once()
|
mock_sched.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
|
await _run_service_checks()
|
||||||
|
mock_cs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
node = _make_node(
|
||||||
|
ip="10.0.0.5",
|
||||||
|
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||||
|
)
|
||||||
|
session.add(node)
|
||||||
|
await session.commit()
|
||||||
|
node_id = node.id
|
||||||
|
|
||||||
|
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
|
||||||
|
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
await _run_service_checks()
|
||||||
|
|
||||||
|
mock_bcast.assert_awaited_once()
|
||||||
|
_, kwargs = mock_bcast.call_args
|
||||||
|
assert kwargs["node_id"] == node_id
|
||||||
|
assert kwargs["services"] == statuses
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
await _run_service_checks()
|
||||||
|
mock_cs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_service_checks_enabled_adds_and_removes_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.service_check_interval = 300
|
||||||
|
# Enable: no existing job -> add
|
||||||
|
mock_sched.get_job.return_value = None
|
||||||
|
set_service_checks_enabled(True)
|
||||||
|
mock_sched.add_job.assert_called_once()
|
||||||
|
# Disable: existing job -> remove
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
set_service_checks_enabled(False)
|
||||||
|
mock_sched.remove_job.assert_called_once_with("service_checks")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_scheduler_adds_service_job_when_enabled():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
|
mock_settings.status_checker_interval = 60
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
mock_settings.service_check_interval = 300
|
||||||
|
start_scheduler()
|
||||||
|
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||||
|
assert "status_checks" in job_ids
|
||||||
|
assert "service_checks" in job_ids
|
||||||
|
|||||||
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
|||||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
||||||
|
res = await client.get("/api/v1/settings", headers=headers)
|
||||||
|
data = res.json()
|
||||||
|
assert "service_check_enabled" in data
|
||||||
|
assert "service_check_interval" in data
|
||||||
|
assert isinstance(data["service_check_enabled"], bool)
|
||||||
|
assert isinstance(data["service_check_interval"], int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
||||||
|
with patch("app.api.routes.settings.settings") as mock_settings:
|
||||||
|
mock_settings.save_overrides = lambda: None
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={
|
||||||
|
"interval_seconds": 60,
|
||||||
|
"service_check_enabled": True,
|
||||||
|
"service_check_interval": 600,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["service_check_enabled"] is True
|
||||||
|
assert body["service_check_interval"] == 600
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Integrity + matching tests against the real service_signatures.json."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.fingerprint import _load, match_service
|
||||||
|
|
||||||
|
_NODE_TYPES = {
|
||||||
|
"isp", "router", "switch", "server", "proxmox", "vm", "lxc",
|
||||||
|
"nas", "iot", "ap", "camera", "generic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def signatures():
|
||||||
|
return _load()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_entries_well_formed(signatures):
|
||||||
|
for sig in signatures:
|
||||||
|
# port is an int or explicitly null (port-agnostic)
|
||||||
|
assert sig.get("port") is None or isinstance(sig["port"], int)
|
||||||
|
assert isinstance(sig["service_name"], str) and sig["service_name"]
|
||||||
|
assert sig["suggested_node_type"] in _NODE_TYPES
|
||||||
|
if sig.get("banner_regex"):
|
||||||
|
re.compile(sig["banner_regex"])
|
||||||
|
if sig.get("http_regex"):
|
||||||
|
re.compile(sig["http_regex"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_agnostic_entries_require_http_regex(signatures):
|
||||||
|
for sig in signatures:
|
||||||
|
if sig.get("port") is None:
|
||||||
|
assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_popular_apps_have_port_agnostic_signatures(signatures):
|
||||||
|
names = {s["service_name"] for s in signatures if s.get("port") is None}
|
||||||
|
for expected in {
|
||||||
|
"Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole",
|
||||||
|
"AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr",
|
||||||
|
}:
|
||||||
|
assert expected in names, f"missing port-agnostic signature for {expected}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("title", "expected"), [
|
||||||
|
("Jellyfin", "Jellyfin"),
|
||||||
|
("Home Assistant", "Home Assistant"),
|
||||||
|
("Portainer", "Portainer"),
|
||||||
|
("Vaultwarden Web Vault", "Vaultwarden"),
|
||||||
|
("Pi-hole - Dashboard", "Pi-hole"),
|
||||||
|
("Audiobookshelf", "Audiobookshelf"),
|
||||||
|
])
|
||||||
|
def test_custom_port_identified_via_http_title(title, expected):
|
||||||
|
# A service on a non-standard port, recognised purely by its HTML title.
|
||||||
|
sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}})
|
||||||
|
assert sig is not None
|
||||||
|
assert sig["service_name"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_port_without_probe_is_unknown():
|
||||||
|
# Same custom port, deep scan off → no signal → no port-agnostic match.
|
||||||
|
assert match_service(58000, "tcp", banner=None, http_signals=None) is None
|
||||||
@@ -5,7 +5,13 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
from app.api.routes.status import (
|
||||||
|
_connections,
|
||||||
|
_drop,
|
||||||
|
broadcast_scan_update,
|
||||||
|
broadcast_service_status,
|
||||||
|
broadcast_status,
|
||||||
|
)
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
|
|||||||
assert len(_connections) == 0
|
assert len(_connections) == 0
|
||||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# broadcast_service_status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_service_status_payload():
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_text(self, text: str) -> None:
|
||||||
|
received.append(text)
|
||||||
|
|
||||||
|
fake = FakeWS()
|
||||||
|
_connections.append(fake)
|
||||||
|
try:
|
||||||
|
await broadcast_service_status(
|
||||||
|
node_id="node-7",
|
||||||
|
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
||||||
|
checked_at="2024-01-01T00:00:00",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_drop(fake)
|
||||||
|
|
||||||
|
msg = json.loads(received[0])
|
||||||
|
assert msg["type"] == "service_status"
|
||||||
|
assert msg["node_id"] == "node-7"
|
||||||
|
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _drop — idempotent connection removal (regression for double-remove crash)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_drop_is_idempotent():
|
||||||
|
"""Dropping a connection twice must not raise (was a ValueError crash)."""
|
||||||
|
class FakeWS:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = FakeWS()
|
||||||
|
_connections.append(fake)
|
||||||
|
_drop(fake)
|
||||||
|
_drop(fake) # second drop must be a no-op
|
||||||
|
assert fake not in _connections
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_dead_connection_dropped_once_safely():
|
||||||
|
"""A send failure removes the dead socket without a double-remove crash."""
|
||||||
|
class DeadWS:
|
||||||
|
async def send_text(self, _: str) -> None:
|
||||||
|
raise RuntimeError("disconnected")
|
||||||
|
|
||||||
|
dead = DeadWS()
|
||||||
|
_connections.append(dead)
|
||||||
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
|
# A second broadcast must not raise even though dead is already gone.
|
||||||
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
|
assert dead not in _connections
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _ping, _tcp_connect, check_node
|
from app.services.status_checker import (
|
||||||
|
_ping,
|
||||||
|
_tcp_connect,
|
||||||
|
check_node,
|
||||||
|
check_service,
|
||||||
|
check_services,
|
||||||
|
)
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -169,9 +175,14 @@ async def test_ping_uses_unix_args_on_non_windows():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
assert "-n" not in captured["args"]
|
assert "-n" not in captured["args"]
|
||||||
# Linux: -W is in seconds; 1s is the intended timeout
|
# 2 probes so a single dropped packet doesn't flap the node offline
|
||||||
|
c_idx = captured["args"].index("-c")
|
||||||
|
assert captured["args"][c_idx + 1] == "2"
|
||||||
|
# Linux: -W is in seconds; 2s is the intended timeout
|
||||||
w_idx = captured["args"].index("-W")
|
w_idx = captured["args"].index("-W")
|
||||||
assert captured["args"][w_idx + 1] == "1"
|
assert captured["args"][w_idx + 1] == "2"
|
||||||
|
# IPv4 target → no -6 flag
|
||||||
|
assert "-6" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -193,7 +204,7 @@ async def test_ping_uses_macos_millisecond_timeout():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
w_idx = captured["args"].index("-W")
|
w_idx = captured["args"].index("-W")
|
||||||
assert captured["args"][w_idx + 1] == "1000"
|
assert captured["args"][w_idx + 1] == "2000"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -216,6 +227,75 @@ async def test_ping_uses_windows_args_on_win32():
|
|||||||
assert "-c" not in captured["args"]
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping IPv6 support ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_linux_uses_dash6():
|
||||||
|
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("fe80::1")
|
||||||
|
|
||||||
|
assert "-6" in captured["args"]
|
||||||
|
assert captured["args"][-1] == "fe80::1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_macos_uses_ping6():
|
||||||
|
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("2001:db8::1")
|
||||||
|
|
||||||
|
assert captured["args"][0] == "ping6"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_windows_uses_dash6():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("2001:db8::1")
|
||||||
|
|
||||||
|
assert "-6" in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ipv6_detection():
|
||||||
|
from app.services.status_checker import _is_ipv6
|
||||||
|
|
||||||
|
assert _is_ipv6("fe80::1") is True
|
||||||
|
assert _is_ipv6("2001:db8::1") is True
|
||||||
|
assert _is_ipv6("[2001:db8::1]") is True
|
||||||
|
assert _is_ipv6("192.168.1.1") is False
|
||||||
|
assert _is_ipv6("example.local") is False
|
||||||
|
|
||||||
|
|
||||||
# --- check_node target validation ---
|
# --- check_node target validation ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -268,3 +348,117 @@ async def test_tcp_connect_os_error():
|
|||||||
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
||||||
result = await _tcp_connect("192.168.1.1", 9999)
|
result = await _tcp_connect("192.168.1.1", 9999)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- check_service ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_no_host_is_unknown():
|
||||||
|
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_flag_host_is_unknown():
|
||||||
|
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_udp_is_unknown():
|
||||||
|
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_portless_non_web_is_unknown():
|
||||||
|
svc = {"protocol": "tcp", "service_name": "thing"}
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_web_uses_http_get():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "online"
|
||||||
|
assert captured["url"] == "http://10.0.0.1:8080"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_https_port_uses_https_scheme():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
await check_service(svc, "10.0.0.1")
|
||||||
|
assert captured["url"].startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_web_offline_when_http_fails():
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_non_http_port_is_unknown():
|
||||||
|
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
||||||
|
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||||
|
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
mock_http.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ssh_port_22_is_unknown():
|
||||||
|
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||||
|
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ipv6_brackets_url_host():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
await check_service(svc, "2001:db8::1")
|
||||||
|
assert captured["url"] == "http://[2001:db8::1]:80"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_services_returns_status_per_service():
|
||||||
|
services = [
|
||||||
|
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
||||||
|
]
|
||||||
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||||
|
results = await check_services("10.0.0.1", services)
|
||||||
|
assert results == [
|
||||||
|
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_services_empty_list():
|
||||||
|
assert await check_services("10.0.0.1", []) == []
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""API endpoint tests for /api/v1/zwave/*."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zwave/test-connection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is True
|
||||||
|
assert "success" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
||||||
|
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is False
|
||||||
|
assert "refused" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zwave/import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_NODES = [
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-1",
|
||||||
|
"label": "Controller",
|
||||||
|
"type": "zwave_coordinator",
|
||||||
|
"ieee_address": "zwave-0xh-1",
|
||||||
|
"friendly_name": "Controller",
|
||||||
|
"device_type": "Controller",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-2",
|
||||||
|
"label": "Wall Plug",
|
||||||
|
"type": "zwave_router",
|
||||||
|
"ieee_address": "zwave-0xh-2",
|
||||||
|
"friendly_name": "Wall Plug",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "ZW100",
|
||||||
|
"vendor": "Aeotec",
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": "zwave-0xh-1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 2
|
||||||
|
assert len(data["edges"]) == 1
|
||||||
|
coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
|
||||||
|
assert coordinator["ieee_address"] == "zwave-0xh-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"prefix": "myzwave",
|
||||||
|
"gateway_name": "gw1",
|
||||||
|
"mqtt_username": "admin",
|
||||||
|
"mqtt_password": "secret",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
mock_fetch.assert_called_once_with(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
prefix="myzwave",
|
||||||
|
gateway_name="gw1",
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
tls=False,
|
||||||
|
tls_insecure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 502
|
||||||
|
assert "broker unreachable" in res.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = TimeoutError("timed out")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ValueError("malformed response")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = RuntimeError("boom")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_tls": False,
|
||||||
|
"mqtt_tls_insecure": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zwave/import-pending
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PENDING_NODES = [
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-1",
|
||||||
|
"label": "Controller",
|
||||||
|
"type": "zwave_coordinator",
|
||||||
|
"ieee_address": "zwave-0xh-1",
|
||||||
|
"friendly_name": "Controller",
|
||||||
|
"device_type": "Controller",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-2",
|
||||||
|
"label": "Wall Plug",
|
||||||
|
"type": "zwave_router",
|
||||||
|
"ieee_address": "zwave-0xh-2",
|
||||||
|
"friendly_name": "Wall Plug",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "ZW100",
|
||||||
|
"vendor": "Aeotec",
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": "zwave-0xh-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zwave-0xh-3",
|
||||||
|
"label": "Door Sensor",
|
||||||
|
"type": "zwave_enddevice",
|
||||||
|
"ieee_address": "zwave-0xh-3",
|
||||||
|
"friendly_name": "Door Sensor",
|
||||||
|
"device_type": "EndDevice",
|
||||||
|
"model": "ZW120",
|
||||||
|
"vendor": "Aeotec",
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": "zwave-0xh-2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_PENDING_EDGES = [
|
||||||
|
{"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
|
||||||
|
{"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
run = res.json()
|
||||||
|
assert run["kind"] == "zwave"
|
||||||
|
assert run["status"] == "running"
|
||||||
|
assert run["ranges"] == ["localhost:1883"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zwave/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_creates_coordinator_and_pending(db_session) -> None:
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
assert result.device_count == 3
|
||||||
|
assert result.pending_created == 2
|
||||||
|
assert result.pending_updated == 0
|
||||||
|
assert result.coordinator is not None
|
||||||
|
assert result.coordinator.ieee_address == "zwave-0xh-1"
|
||||||
|
assert result.coordinator_already_existed is False
|
||||||
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["model"] = "ZW111"
|
||||||
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
assert result.pending_created == 0
|
||||||
|
assert result.pending_updated == 2
|
||||||
|
assert result.coordinator_already_existed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_sets_coordinator_properties(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
nodes = [dict(n) for n in _PENDING_NODES]
|
||||||
|
nodes[0]["vendor"] = "Aeotec"
|
||||||
|
nodes[0]["model"] = "ZW090"
|
||||||
|
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
||||||
|
coord = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
|
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
|
||||||
|
assert all(p["visible"] is False for p in coord.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_skips_pending_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
|
||||||
|
|
||||||
|
approved = Node(
|
||||||
|
label="Wall Plug",
|
||||||
|
type="zwave_router",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address="zwave-0xh-2",
|
||||||
|
services=[],
|
||||||
|
properties=[],
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
pendings = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert pendings == []
|
||||||
|
refreshed = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||||
|
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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
|
||||||
|
|
||||||
|
orphan = PendingDevice(
|
||||||
|
ieee_address="zwave-0xh-2",
|
||||||
|
friendly_name="Wall Plug",
|
||||||
|
suggested_type="zwave_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="approved",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
db_session.add(orphan)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert revived.status == "pending"
|
||||||
|
assert result.pending_created == 1
|
||||||
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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
|
||||||
|
|
||||||
|
hidden = PendingDevice(
|
||||||
|
ieee_address="zwave-0xh-2",
|
||||||
|
friendly_name="Wall Plug",
|
||||||
|
suggested_type="zwave_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="hidden",
|
||||||
|
discovery_source="zwave",
|
||||||
|
)
|
||||||
|
db_session.add(hidden)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert still_hidden.status == "hidden"
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Unit tests for zwave_service: parser, role mapping, hierarchy builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.zwave_service import (
|
||||||
|
build_zwave_properties,
|
||||||
|
fetch_zwave_network,
|
||||||
|
parse_zwave_nodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — real zwavejs2mqtt getNodes shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _node(
|
||||||
|
node_id: int,
|
||||||
|
*,
|
||||||
|
controller: bool = False,
|
||||||
|
routing: bool = False,
|
||||||
|
name: str | None = None,
|
||||||
|
neighbors: list[int] | None = None,
|
||||||
|
manufacturer: str | None = None,
|
||||||
|
product_label: str | None = None,
|
||||||
|
home_id: str = "0xabcd1234",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": node_id,
|
||||||
|
"homeId": home_id,
|
||||||
|
"isControllerNode": controller,
|
||||||
|
"isRouting": routing,
|
||||||
|
"name": name,
|
||||||
|
"neighbors": neighbors or [],
|
||||||
|
"manufacturer": manufacturer,
|
||||||
|
"productLabel": product_label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(nodes: list[dict[str, Any]], success: bool = True) -> dict[str, Any]:
|
||||||
|
return {"success": success, "result": nodes}
|
||||||
|
|
||||||
|
|
||||||
|
HOME = "0xabcd1234"
|
||||||
|
|
||||||
|
|
||||||
|
def _ieee(node_id: int) -> str:
|
||||||
|
return f"zwave-{HOME}-{node_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Role mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoleMapping:
|
||||||
|
def test_controller_is_coordinator(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_coordinator"
|
||||||
|
assert nodes[0]["device_type"] == "Controller"
|
||||||
|
|
||||||
|
def test_routing_is_router(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(2, routing=True)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_router"
|
||||||
|
assert nodes[0]["device_type"] == "Router"
|
||||||
|
|
||||||
|
def test_default_is_enddevice(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(3)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_enddevice"
|
||||||
|
assert nodes[0]["device_type"] == "EndDevice"
|
||||||
|
|
||||||
|
def test_controller_wins_over_routing(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True, routing=True)]))
|
||||||
|
assert nodes[0]["type"] == "zwave_coordinator"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_zwave_nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParse:
|
||||||
|
def test_empty_payload(self) -> None:
|
||||||
|
nodes, edges = parse_zwave_nodes({})
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_empty_result(self) -> None:
|
||||||
|
nodes, edges = parse_zwave_nodes(_wrap([]))
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_success_false_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="failure"):
|
||||||
|
parse_zwave_nodes(_wrap([], success=False))
|
||||||
|
|
||||||
|
def test_result_not_list_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a list"):
|
||||||
|
parse_zwave_nodes({"success": True, "result": "oops"})
|
||||||
|
|
||||||
|
def test_missing_id_skipped(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([{"homeId": HOME, "isControllerNode": False}]))
|
||||||
|
assert nodes == []
|
||||||
|
|
||||||
|
def test_ieee_identity_format(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(5, controller=True)]))
|
||||||
|
assert nodes[0]["ieee_address"] == _ieee(5)
|
||||||
|
|
||||||
|
def test_name_fallback(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(7, name="Living Room")]))
|
||||||
|
assert nodes[0]["label"] == "Living Room"
|
||||||
|
assert nodes[0]["friendly_name"] == "Living Room"
|
||||||
|
|
||||||
|
def test_model_and_vendor(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(
|
||||||
|
_wrap([_node(8, manufacturer="Aeotec", product_label="ZW100")])
|
||||||
|
)
|
||||||
|
assert nodes[0]["vendor"] == "Aeotec"
|
||||||
|
assert nodes[0]["model"] == "ZW100"
|
||||||
|
|
||||||
|
def test_lqi_is_none(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(9)]))
|
||||||
|
assert nodes[0]["lqi"] is None
|
||||||
|
|
||||||
|
def test_no_duplicate_nodes(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, routing=True), _node(1, routing=True)]))
|
||||||
|
assert len(nodes) == 1
|
||||||
|
|
||||||
|
def test_helper_keys_stripped(self) -> None:
|
||||||
|
nodes, _ = parse_zwave_nodes(_wrap([_node(1, neighbors=[2])]))
|
||||||
|
assert "neighbors" not in nodes[0]
|
||||||
|
assert "node_id" not in nodes[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHierarchy:
|
||||||
|
def test_coordinator_router_enddevice_tree(self) -> None:
|
||||||
|
payload = _wrap([
|
||||||
|
_node(1, controller=True, neighbors=[2]),
|
||||||
|
_node(2, routing=True, neighbors=[1, 3]),
|
||||||
|
_node(3, neighbors=[2]),
|
||||||
|
])
|
||||||
|
nodes, edges = parse_zwave_nodes(payload)
|
||||||
|
by_id = {n["id"]: n for n in nodes}
|
||||||
|
assert by_id[_ieee(2)]["parent_id"] == _ieee(1)
|
||||||
|
assert by_id[_ieee(3)]["parent_id"] == _ieee(2)
|
||||||
|
pairs = {(e["source"], e["target"]) for e in edges}
|
||||||
|
assert pairs == {(_ieee(1), _ieee(2)), (_ieee(2), _ieee(3))}
|
||||||
|
|
||||||
|
def test_enddevice_without_router_falls_back_to_coordinator(self) -> None:
|
||||||
|
payload = _wrap([_node(1, controller=True), _node(3, neighbors=[])])
|
||||||
|
nodes, _ = parse_zwave_nodes(payload)
|
||||||
|
end = next(n for n in nodes if n["id"] == _ieee(3))
|
||||||
|
assert end["parent_id"] == _ieee(1)
|
||||||
|
|
||||||
|
def test_coordinator_has_no_incoming_edge(self) -> None:
|
||||||
|
payload = _wrap([
|
||||||
|
_node(1, controller=True, neighbors=[3]),
|
||||||
|
_node(3, neighbors=[1]),
|
||||||
|
])
|
||||||
|
_, edges = parse_zwave_nodes(payload)
|
||||||
|
assert all(e["target"] != _ieee(1) for e in edges)
|
||||||
|
|
||||||
|
def test_neighbor_to_unknown_node_dropped(self) -> None:
|
||||||
|
payload = _wrap([_node(1, controller=True, neighbors=[99])])
|
||||||
|
_, edges = parse_zwave_nodes(payload)
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_no_coordinator_means_no_edges(self) -> None:
|
||||||
|
payload = _wrap([_node(2, routing=True, neighbors=[3]), _node(3, neighbors=[2])])
|
||||||
|
_, edges = parse_zwave_nodes(payload)
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_zwave_properties
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBuildProperties:
|
||||||
|
def test_all_fields(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", "Aeotec", "ZW100")
|
||||||
|
keys = {p["key"]: p["value"] for p in props}
|
||||||
|
assert keys == {"Z-Wave ID": "zwave-x-1", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||||
|
|
||||||
|
def test_omits_empty(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", None, None)
|
||||||
|
assert [p["key"] for p in props] == ["Z-Wave ID"]
|
||||||
|
|
||||||
|
def test_defaults_hidden(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", "V", "M")
|
||||||
|
assert all(p["visible"] is False for p in props)
|
||||||
|
|
||||||
|
def test_no_lqi_row(self) -> None:
|
||||||
|
props = build_zwave_properties("zwave-x-1", "V", "M")
|
||||||
|
assert all(p["key"] != "LQI" for p in props)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_zwave_network (mocked MQTT round-trip via mqtt_common)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_RESPONSE_TOPIC = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
|
||||||
|
|
||||||
|
_SAMPLE_PAYLOAD = {
|
||||||
|
"success": True,
|
||||||
|
"result": [
|
||||||
|
{"id": 1, "homeId": HOME, "isControllerNode": True, "name": "Controller"},
|
||||||
|
{"id": 2, "homeId": HOME, "isRouting": True, "name": "Wall Plug", "neighbors": [1]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_zwave_network_success() -> None:
|
||||||
|
class _FakeMessage:
|
||||||
|
topic = _RESPONSE_TOPIC
|
||||||
|
payload = json.dumps(_SAMPLE_PAYLOAD).encode()
|
||||||
|
_yielded = False
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if self._yielded:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
self._yielded = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def subscribe(self, _t: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish(self, _t: str, _p: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self):
|
||||||
|
return _FakeMessage()
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
nodes, edges = await fetch_zwave_network(mqtt_host="localhost", mqtt_port=1883)
|
||||||
|
|
||||||
|
assert any(n["type"] == "zwave_coordinator" for n in nodes)
|
||||||
|
assert any(n["type"] == "zwave_router" for n in nodes)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_zwave_network_connection_error() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Connection refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await fetch_zwave_network(mqtt_host="bad", mqtt_port=1883)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Z-Wave (Z-Wave JS UI) Network Map Importer
|
||||||
|
|
||||||
|
This feature lets you connect Homelable to your MQTT broker, fetch the Z-Wave node list from **Z-Wave JS UI** (formerly `zwavejs2mqtt`), and drop all Z-Wave devices onto the canvas as typed nodes with proper hierarchy.
|
||||||
|
|
||||||
|
It mirrors the [Zigbee2MQTT importer](./zigbee-import.md): same MQTT request/response pattern, same node-type model, same canvas behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
- **Automatic device discovery** — Requests the node list from the Z-Wave JS UI MQTT gateway API and parses the full device list
|
||||||
|
- **Typed nodes** — Devices are mapped to three homelable node types:
|
||||||
|
- `zwave_coordinator` — The Z-Wave controller (hub)
|
||||||
|
- `zwave_router` — Mains-powered / routing nodes
|
||||||
|
- `zwave_enddevice` — Battery-powered end devices (sensors, locks, etc.)
|
||||||
|
- **Hierarchy** — `parent_id` is set automatically: controller → routers → end devices, derived from each node's neighbor list
|
||||||
|
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
|
||||||
|
- **Import targets** — Send discovered devices to the **Pending** section (review before adding) or drop them on the **Canvas** directly
|
||||||
|
|
||||||
|
> **Note:** Z-Wave does not expose an LQI value the way Zigbee does, so the LQI property is omitted. (RSSI may be surfaced in a future release.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
|
||||||
|
2. **Z-Wave JS UI** connected to the broker with the **MQTT gateway** enabled
|
||||||
|
3. The gateway must respond to `getNodes` requests on:
|
||||||
|
- **Request topic:** `<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`
|
||||||
|
- **Response topic:** `<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes`
|
||||||
|
- The default prefix is `zwave` and the default gateway name is `zwavejs2mqtt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-step Usage
|
||||||
|
|
||||||
|
### 1. Open the Z-Wave Import dialog
|
||||||
|
|
||||||
|
Click **Z-Wave Import** in the left sidebar (below "Zigbee Import").
|
||||||
|
|
||||||
|
### 2. Configure the MQTT connection
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Broker Host | — | IP or hostname of your MQTT broker |
|
||||||
|
| Port | 1883 | MQTT broker port (auto-switches to 8883 when TLS is enabled) |
|
||||||
|
| MQTT Prefix | `zwave` | Z-Wave JS UI MQTT prefix (Settings → MQTT → "Prefix") |
|
||||||
|
| Gateway Name | `zwavejs2mqtt` | Z-Wave JS UI gateway name (Settings → MQTT → "Name") |
|
||||||
|
| Username | _(optional)_ | MQTT username if authentication is enabled |
|
||||||
|
| Password | _(optional)_ | MQTT password |
|
||||||
|
| Use TLS | off | Connect over TLS (typically port 8883) |
|
||||||
|
| Skip cert verify | off | Accept self-signed certificates (TLS only) |
|
||||||
|
|
||||||
|
> The **Prefix** and **Gateway Name** together form the MQTT topic the importer talks to. They must match your Z-Wave JS UI **Settings → MQTT** configuration exactly, or the request will time out.
|
||||||
|
|
||||||
|
### 3. Test the connection (optional)
|
||||||
|
|
||||||
|
Click **Test Connection** to verify broker reachability before fetching devices.
|
||||||
|
A green indicator confirms success; red shows the error message from the broker.
|
||||||
|
|
||||||
|
### 4. Choose an import target
|
||||||
|
|
||||||
|
Pick where discovered devices should go:
|
||||||
|
|
||||||
|
- **Pending section** — Devices are queued for review in the Pending list (and tracked as a scan run in Scan History). The controller is auto-approved as a canvas node; the rest wait for you to approve, hide, or delete them.
|
||||||
|
- **Canvas directly** — Devices are fetched and shown grouped in the dialog so you can pick which ones to add immediately.
|
||||||
|
|
||||||
|
### 5. Fetch devices
|
||||||
|
|
||||||
|
Click **Import to Pending** (or **Fetch Devices** in canvas mode). Homelable will:
|
||||||
|
1. Connect to the broker
|
||||||
|
2. Subscribe to the response topic
|
||||||
|
3. Publish a `getNodes` request to the gateway request topic
|
||||||
|
4. Wait for the node-list response
|
||||||
|
5. Parse and group devices by type
|
||||||
|
|
||||||
|
### 6. Select and add to canvas
|
||||||
|
|
||||||
|
(Canvas mode) Devices are grouped by type (Controller / Router / End Device).
|
||||||
|
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
|
||||||
|
|
||||||
|
> **Tip:** All devices are selected by default. Uncheck any you don't want.
|
||||||
|
|
||||||
|
### 7. Arrange on the canvas
|
||||||
|
|
||||||
|
Devices are placed in a grid at the top-right of the canvas.
|
||||||
|
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node Type Mapping
|
||||||
|
|
||||||
|
The importer reads each Z-Wave node's role flags from the gateway and maps them as follows:
|
||||||
|
|
||||||
|
| Z-Wave JS UI flag | homelable type | Role label |
|
||||||
|
|---|---|---|
|
||||||
|
| `isControllerNode` | `zwave_coordinator` | Controller |
|
||||||
|
| `isRouting` | `zwave_router` | Router |
|
||||||
|
| _(everything else)_ | `zwave_enddevice` | EndDevice |
|
||||||
|
|
||||||
|
Each node keeps its name (`name` → `loc` → `Node <id>` fallback), vendor (`manufacturer`), and model (`productLabel` / `productDescription`) where available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MQTT Configuration Tips
|
||||||
|
|
||||||
|
### Mosquitto without authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mosquitto with password file
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
password_file /etc/mosquitto/passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a user:
|
||||||
|
```bash
|
||||||
|
mosquitto_passwd -c /etc/mosquitto/passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Z-Wave JS UI MQTT settings
|
||||||
|
|
||||||
|
In **Settings → MQTT**, make sure the gateway is enabled and note these two values — they must match the importer fields:
|
||||||
|
|
||||||
|
| Z-Wave JS UI setting | Importer field | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| Name | Gateway Name | `zwavejs2mqtt` |
|
||||||
|
| Prefix | MQTT Prefix | `zwave` |
|
||||||
|
| Host / Port | Broker Host / Port | `localhost` / `1883` |
|
||||||
|
|
||||||
|
The gateway must be in **"Named topics"** mode (the default) so the `getNodes` API topic is exposed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
The `getNodes` MQTT gateway API is provided by **Z-Wave JS UI** (and its predecessor `zwavejs2mqtt`). Any recent release with the MQTT gateway enabled is supported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
|
||||||
|
| "Timed out waiting for response" | Gateway not running, or wrong prefix/gateway name | Verify Z-Wave JS UI is connected to MQTT; match **Prefix** and **Gateway Name** to Settings → MQTT |
|
||||||
|
| 0 devices returned | No nodes included in the Z-Wave network | Include at least one device first |
|
||||||
|
| "Z-Wave gateway reported failure" | Gateway returned `success: false` | Check the Z-Wave JS UI logs |
|
||||||
|
| "Malformed getNodes response" | Gateway returned an unexpected format | Check the Z-Wave JS UI version; open an issue |
|
||||||
|
| TLS errors with a self-signed cert | Certificate not trusted | Enable **Use TLS** + **Skip cert verify** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
_(Screenshots will be added in a future release)_
|
||||||
Generated
+221
-211
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.4.0",
|
"version": "2.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.4.0",
|
"version": "2.6.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.5",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -118,12 +118,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
@@ -132,29 +132,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -171,13 +171,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -199,13 +199,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
@@ -236,9 +236,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -258,27 +258,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -339,52 +339,52 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -519,31 +519,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -551,13 +551,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -964,9 +964,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -981,9 +981,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -998,9 +998,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1015,9 +1015,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1032,9 +1032,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1049,9 +1049,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1066,9 +1066,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1083,9 +1083,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1100,9 +1100,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1117,9 +1117,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1134,9 +1134,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1151,9 +1151,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1168,9 +1168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -1185,9 +1185,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1202,9 +1202,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1219,9 +1219,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1236,9 +1236,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1253,9 +1253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1270,9 +1270,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1287,9 +1287,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1304,9 +1304,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1321,9 +1321,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1338,9 +1338,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1355,9 +1355,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1372,9 +1372,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1389,9 +1389,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5216,9 +5216,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.7",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5229,32 +5229,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.27.7",
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
"@esbuild/android-arm": "0.27.7",
|
"@esbuild/android-arm": "0.28.1",
|
||||||
"@esbuild/android-arm64": "0.27.7",
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
"@esbuild/android-x64": "0.27.7",
|
"@esbuild/android-x64": "0.28.1",
|
||||||
"@esbuild/darwin-arm64": "0.27.7",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@esbuild/darwin-x64": "0.27.7",
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
"@esbuild/freebsd-arm64": "0.27.7",
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
"@esbuild/freebsd-x64": "0.27.7",
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
"@esbuild/linux-arm": "0.27.7",
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
"@esbuild/linux-arm64": "0.27.7",
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
"@esbuild/linux-ia32": "0.27.7",
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
"@esbuild/linux-loong64": "0.27.7",
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
"@esbuild/linux-mips64el": "0.27.7",
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
"@esbuild/linux-ppc64": "0.27.7",
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
"@esbuild/linux-riscv64": "0.27.7",
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
"@esbuild/linux-s390x": "0.27.7",
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
"@esbuild/linux-x64": "0.27.7",
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
"@esbuild/netbsd-arm64": "0.27.7",
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
"@esbuild/netbsd-x64": "0.27.7",
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
"@esbuild/openbsd-arm64": "0.27.7",
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
"@esbuild/openbsd-x64": "0.27.7",
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
"@esbuild/openharmony-arm64": "0.27.7",
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
"@esbuild/sunos-x64": "0.27.7",
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
"@esbuild/win32-arm64": "0.27.7",
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
"@esbuild/win32-ia32": "0.27.7",
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
"@esbuild/win32-x64": "0.27.7"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
@@ -5877,16 +5877,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -6161,9 +6161,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -6196,9 +6196,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.18",
|
"version": "4.12.25",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -6638,9 +6638,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -9138,9 +9148,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.24.7",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9267,9 +9277,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.2",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.4.0",
|
"version": "2.6.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc -b",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"hono": "^4.12.21",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"form-data": "^4.0.6"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
@@ -58,7 +63,7 @@
|
|||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.5",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-47
@@ -4,8 +4,10 @@ import { type Node } from '@xyflow/react'
|
|||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import { ExportModal } from '@/components/modals/ExportModal'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
@@ -22,27 +24,31 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
|||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
|
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
|
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
|
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
|
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -52,7 +58,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||||
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||||
@@ -68,11 +74,14 @@ export default function App() {
|
|||||||
const [addTextOpen, setAddTextOpen] = useState(false)
|
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
|
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||||
|
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
|
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||||
// Returns true on success, false on failure — the design-switch effect relies
|
// Returns true on success, false on failure — the design-switch effect relies
|
||||||
@@ -81,7 +90,8 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const saveDesignId = designIdOverride ?? activeDesignId
|
const saveDesignId = designIdOverride ?? activeDesignId
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
if (!saveDesignId) return false
|
||||||
|
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
@@ -126,8 +136,30 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [loadCanvas, setTheme, setCustomStyle])
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (saved.theme_id) setTheme(saved.theme_id)
|
||||||
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
|
loadCanvas(saved.nodes, saved.edges)
|
||||||
|
} else {
|
||||||
|
loadCanvas(demoNodes, demoEdges)
|
||||||
|
}
|
||||||
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
const loadDesignsAndCanvas = useCallback(async () => {
|
const loadDesignsAndCanvas = useCallback(async () => {
|
||||||
if (STANDALONE) return
|
if (STANDALONE) {
|
||||||
|
const designs = standaloneStorage.ensureSeed()
|
||||||
|
setDesigns(designs)
|
||||||
|
const targetId = activeDesignId ?? designs[0]?.id
|
||||||
|
if (targetId) {
|
||||||
|
setActiveDesign(targetId)
|
||||||
|
loadStandaloneCanvas(targetId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await designsApi.list()
|
const res = await designsApi.list()
|
||||||
const loadedDesigns = res.data
|
const loadedDesigns = res.data
|
||||||
@@ -141,29 +173,23 @@ export default function App() {
|
|||||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas])
|
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
|
||||||
|
|
||||||
// Load canvas on auth (or immediately in standalone mode)
|
// 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])
|
||||||
|
|
||||||
|
// Load designs + canvas on auth (or immediately in standalone mode, which has
|
||||||
|
// no auth gate).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
try {
|
loadDesignsAndCanvasRef.current()
|
||||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
|
||||||
if (saved) {
|
|
||||||
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
|
|
||||||
if (theme_id) setTheme(theme_id)
|
|
||||||
if (custom_style) setCustomStyle(custom_style)
|
|
||||||
loadCanvas(savedNodes, savedEdges)
|
|
||||||
} else {
|
|
||||||
loadCanvas(demoNodes, demoEdges)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
loadCanvas(demoNodes, demoEdges)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) return
|
||||||
loadDesignsAndCanvas()
|
loadDesignsAndCanvasRef.current()
|
||||||
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
|
}, [isAuthenticated]) // only on auth change, not design change
|
||||||
|
|
||||||
// Reload canvas when active design changes (after initial load)
|
// Reload canvas when active design changes (after initial load)
|
||||||
const initialLoadDone = useRef(false)
|
const initialLoadDone = useRef(false)
|
||||||
@@ -177,7 +203,10 @@ export default function App() {
|
|||||||
prevDesignRef.current = activeDesignId
|
prevDesignRef.current = activeDesignId
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
|
// Standalone has no auth gate; backed mode requires authentication.
|
||||||
|
const ready = STANDALONE || isAuthenticated
|
||||||
|
const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi
|
||||||
|
if (ready && activeDesignId && initialLoadDone.current) {
|
||||||
const oldId = prevDesignRef.current
|
const oldId = prevDesignRef.current
|
||||||
// If the previous design was deleted (no longer in the list), don't try to
|
// If the previous design was deleted (no longer in the list), don't try to
|
||||||
// save into it — just load the newly-selected design.
|
// save into it — just load the newly-selected design.
|
||||||
@@ -190,7 +219,7 @@ export default function App() {
|
|||||||
const targetId = activeDesignId
|
const targetId = activeDesignId
|
||||||
handleSave(oldId).then((ok) => {
|
handleSave(oldId).then((ok) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
loadCanvasFromApi(targetId)
|
loadForDesign(targetId)
|
||||||
} else {
|
} else {
|
||||||
// Save failed: don't load the new design — that would overwrite the
|
// Save failed: don't load the new design — that would overwrite the
|
||||||
// unsaved in-memory canvas. Revert the selection back to the old
|
// unsaved in-memory canvas. Revert the selection back to the old
|
||||||
@@ -201,7 +230,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
loadCanvasFromApi(activeDesignId)
|
loadForDesign(activeDesignId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeDesignId) {
|
if (activeDesignId) {
|
||||||
@@ -241,17 +270,23 @@ export default function App() {
|
|||||||
const id = generateUUID()
|
const id = generateUUID()
|
||||||
const isContainerNode = data.container_mode === true
|
const isContainerNode = data.container_mode === true
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
// Children position is relative to parent; place near top-left with padding
|
// Only nest when the parent is an actual container. For a non-container
|
||||||
const position = parentNode
|
// parent the LXC/VM stays a free node (linked by a virtual edge) — setting
|
||||||
? { x: 20, y: 50 }
|
// extent:'parent' on a non-container would trap it inside the parent's tiny
|
||||||
: { x: 300, y: 300 }
|
// bounding box with no way to drag it out (issue #205 follow-up).
|
||||||
|
const nestInParent = !!parentNode?.data.container_mode
|
||||||
|
// Seed an ABSOLUTE position near the container's top-left; addNode converts
|
||||||
|
// it to container-relative. addNode is the single authority for parentId /
|
||||||
|
// extent, so we don't set them here.
|
||||||
|
const position = nestInParent && parentNode
|
||||||
|
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 }
|
||||||
|
: getCenteredPosition(isContainerNode ? 300 : 0, isContainerNode ? 200 : 0)
|
||||||
|
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: data.type ?? 'generic',
|
type: data.type ?? 'generic',
|
||||||
position,
|
position,
|
||||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
|
||||||
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
@@ -264,7 +299,7 @@ export default function App() {
|
|||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: 'groupRect',
|
type: 'groupRect',
|
||||||
position: { x: 200, y: 200 },
|
position: getCenteredPosition(360, 240),
|
||||||
data: {
|
data: {
|
||||||
label: data.label,
|
label: data.label,
|
||||||
type: 'groupRect',
|
type: 'groupRect',
|
||||||
@@ -323,7 +358,7 @@ export default function App() {
|
|||||||
// node fields; text_content is not in the schema and was lost on reload.
|
// node fields; text_content is not in the schema and was lost on reload.
|
||||||
// TextNode and the edit modal both already fall back to label.
|
// TextNode and the edit modal both already fall back to label.
|
||||||
type: 'text',
|
type: 'text',
|
||||||
position: { x: 250, y: 250 },
|
position: getCenteredPosition(200, 60),
|
||||||
data: {
|
data: {
|
||||||
label: data.text,
|
label: data.text,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -432,8 +467,11 @@ export default function App() {
|
|||||||
const handleExportMd = useCallback(async () => {
|
const handleExportMd = useCallback(async () => {
|
||||||
const md = generateMarkdownTable(nodes)
|
const md = generateMarkdownTable(nodes)
|
||||||
if (!md) { toast.error('No nodes to export'); return }
|
if (!md) { toast.error('No nodes to export'); return }
|
||||||
await navigator.clipboard.writeText(md)
|
if (await copyToClipboard(md)) {
|
||||||
toast.success('Markdown table copied to clipboard')
|
toast.success('Markdown table copied to clipboard')
|
||||||
|
} else {
|
||||||
|
toast.error('Markdown copy failed')
|
||||||
|
}
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
const handleExportYaml = useCallback(() => {
|
const handleExportYaml = useCallback(() => {
|
||||||
@@ -460,7 +498,10 @@ export default function App() {
|
|||||||
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||||
const handleViewOnly = useCallback(async () => {
|
const handleViewOnly = useCallback(async () => {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
window.open('/view', '_blank', 'noopener,noreferrer')
|
// Standalone reads canvas from localStorage; pass the active design id so
|
||||||
|
// the read-only tab renders the same canvas the user is viewing.
|
||||||
|
const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view'
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -485,15 +526,18 @@ export default function App() {
|
|||||||
|
|
||||||
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
// Place nodes in a grid starting at x=500, y=100
|
// Place nodes in a grid centred on the visible canvas.
|
||||||
const COLS = 4
|
const COLS = 4
|
||||||
const SPACING_X = 170
|
const SPACING_X = 170
|
||||||
const SPACING_Y = 100
|
const SPACING_Y = 100
|
||||||
|
const cols = Math.min(COLS, zigbeeNodes.length)
|
||||||
|
const rows = Math.ceil(zigbeeNodes.length / COLS)
|
||||||
|
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||||
zigbeeNodes.forEach((zn, i) => {
|
zigbeeNodes.forEach((zn, i) => {
|
||||||
const id = zn.id
|
const id = zn.id
|
||||||
const col = i % COLS
|
const col = i % COLS
|
||||||
const row = Math.floor(i / COLS)
|
const row = Math.floor(i / COLS)
|
||||||
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
|
||||||
const newNode: import('@xyflow/react').Node<NodeData> = {
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: zn.type,
|
type: zn.type,
|
||||||
@@ -531,6 +575,53 @@ export default function App() {
|
|||||||
markUnsaved()
|
markUnsaved()
|
||||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
|
const handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const COLS = 4
|
||||||
|
const SPACING_X = 170
|
||||||
|
const SPACING_Y = 100
|
||||||
|
const cols = Math.min(COLS, zwaveNodes.length)
|
||||||
|
const rows = Math.ceil(zwaveNodes.length / COLS)
|
||||||
|
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||||
|
zwaveNodes.forEach((zn, i) => {
|
||||||
|
const id = zn.id
|
||||||
|
const col = i % COLS
|
||||||
|
const row = Math.floor(i / COLS)
|
||||||
|
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
|
||||||
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: zn.type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
label: zn.friendly_name,
|
||||||
|
type: zn.type as NodeData['type'],
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: [],
|
||||||
|
...(zn.model ? { os: zn.model } : {}),
|
||||||
|
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
})
|
||||||
|
// Add IoT edges between Z-Wave devices: parent bottom -> child top
|
||||||
|
zwaveEdges.forEach((ze) => {
|
||||||
|
onConnect({
|
||||||
|
source: ze.source,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
target: ze.target,
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
const importedIds = new Set(zwaveNodes.map((zn) => zn.id))
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||||
|
selectedNodeIds: Array.from(importedIds),
|
||||||
|
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||||
|
}))
|
||||||
|
markUnsaved()
|
||||||
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -605,9 +696,10 @@ export default function App() {
|
|||||||
onAddText={() => setAddTextOpen(true)}
|
onAddText={() => setAddTextOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
|
onZwaveImport={() => setZwaveImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
forceView={sidebarForceView}
|
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||||
onOpenPending={openPendingModal}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
@@ -631,6 +723,8 @@ export default function App() {
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
|
onRequestAddToGroup={setPendingGroupAdd}
|
||||||
|
onRequestAddToContainer={setPendingContainerAdd}
|
||||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,7 +739,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -672,7 +766,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return nodes
|
return nodes
|
||||||
.filter((n) => !descendants.has(n.id))
|
.filter((n) => !descendants.has(n.id))
|
||||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||||
})()}
|
})()}
|
||||||
currentNodeId={editNodeId ?? undefined}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
@@ -706,8 +800,6 @@ export default function App() {
|
|||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => {
|
onScanNow={() => {
|
||||||
toast.success('Network scan started — check Scan History for results')
|
toast.success('Network scan started — check Scan History for results')
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => setSidebarForceView('history'), 0)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -718,12 +810,29 @@ export default function App() {
|
|||||||
onClose={() => setZigbeeImportOpen(false)}
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
onPendingImported={() => {
|
onPendingImported={() => {
|
||||||
setSidebarForceView(undefined)
|
toast.success('Zigbee import started — check Scan History for results')
|
||||||
setTimeout(() => setSidebarForceView('history'), 0)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZwaveImportModal
|
||||||
|
open={zwaveImportOpen}
|
||||||
|
onClose={() => setZwaveImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZwaveAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
toast.success('Z-Wave import started — check Scan History for results')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ScanHistoryModal
|
||||||
|
open={scanHistoryOpen}
|
||||||
|
onClose={() => setScanHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
@@ -804,9 +913,32 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
{!STANDALONE && (
|
<ConfirmAddToGroupModal
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
open={!!pendingGroupAdd}
|
||||||
)}
|
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
||||||
|
setPendingGroupAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingGroupAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmAddToGroupModal
|
||||||
|
open={!!pendingContainerAdd}
|
||||||
|
variant="container"
|
||||||
|
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
|
||||||
|
setPendingContainerAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingContainerAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mounted in standalone too: status-check settings are hidden inside,
|
||||||
|
but canvas prefs (snap, hide-IP) still apply. */}
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
|
||||||
<PendingDevicesModal
|
<PendingDevicesModal
|
||||||
open={pendingModalOpen}
|
open={pendingModalOpen}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ describe('api/client', () => {
|
|||||||
|
|
||||||
it('scanApi endpoints route correctly', () => {
|
it('scanApi endpoints route correctly', () => {
|
||||||
mod.scanApi.trigger()
|
mod.scanApi.trigger()
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger', {})
|
||||||
mod.scanApi.pending()
|
mod.scanApi.pending()
|
||||||
expect(api.get).toHaveBeenCalledWith('/scan/pending')
|
expect(api.get).toHaveBeenCalledWith('/scan/pending')
|
||||||
mod.scanApi.hidden()
|
mod.scanApi.hidden()
|
||||||
@@ -186,7 +186,9 @@ describe('api/client', () => {
|
|||||||
mod.scanApi.ignore('d1')
|
mod.scanApi.ignore('d1')
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
||||||
mod.scanApi.bulkApprove(['a', 'b'])
|
mod.scanApi.bulkApprove(['a', 'b'])
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined })
|
||||||
|
mod.scanApi.bulkApprove(['a'], 'design-9')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' })
|
||||||
mod.scanApi.bulkHide(['a'])
|
mod.scanApi.bulkHide(['a'])
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
||||||
mod.scanApi.restore('d1')
|
mod.scanApi.restore('d1')
|
||||||
@@ -204,8 +206,8 @@ describe('api/client', () => {
|
|||||||
it('settingsApi get/save', () => {
|
it('settingsApi get/save', () => {
|
||||||
mod.settingsApi.get()
|
mod.settingsApi.get()
|
||||||
expect(api.get).toHaveBeenCalledWith('/settings')
|
expect(api.get).toHaveBeenCalledWith('/settings')
|
||||||
mod.settingsApi.save({ interval_seconds: 30 })
|
mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||||
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
|
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||||
@@ -217,4 +219,14 @@ describe('api/client', () => {
|
|||||||
mod.zigbeeApi.importToPending(cfg)
|
mod.zigbeeApi.importToPending(cfg)
|
||||||
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('zwaveApi.testConnection/importNetwork/importToPending', () => {
|
||||||
|
const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
|
||||||
|
mod.zwaveApi.testConnection(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg)
|
||||||
|
mod.zwaveApi.importNetwork(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg)
|
||||||
|
mod.zwaveApi.importToPending(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,8 +58,16 @@ export const liveviewApi = {
|
|||||||
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
|
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeepScanConfig {
|
||||||
|
http_ranges: string[]
|
||||||
|
http_probe_enabled: boolean
|
||||||
|
verify_tls: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
|
||||||
|
|
||||||
export const scanApi = {
|
export const scanApi = {
|
||||||
trigger: () => api.post('/scan/trigger'),
|
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
|
||||||
pending: () => api.get('/scan/pending'),
|
pending: () => api.get('/scan/pending'),
|
||||||
hidden: () => api.get('/scan/hidden'),
|
hidden: () => api.get('/scan/hidden'),
|
||||||
runs: () => api.get('/scan/runs'),
|
runs: () => api.get('/scan/runs'),
|
||||||
@@ -73,7 +81,7 @@ export const scanApi = {
|
|||||||
}>(`/scan/pending/${id}/approve`, nodeData),
|
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
bulkApprove: (ids: string[]) =>
|
bulkApprove: (ids: string[], designId?: string | null) =>
|
||||||
api.post<{
|
api.post<{
|
||||||
approved: number
|
approved: number
|
||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
@@ -81,18 +89,24 @@ export const scanApi = {
|
|||||||
edges_created: number
|
edges_created: number
|
||||||
edges: { id: string; source: string; target: string }[]
|
edges: { id: string; source: string; target: string }[]
|
||||||
skipped: number
|
skipped: number
|
||||||
}>('/scan/pending/bulk-approve', { device_ids: ids }),
|
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
|
||||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||||
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||||
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
|
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
|
||||||
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<ScanConfigData>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: ScanConfigData) => api.post('/scan/config', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
interval_seconds: number
|
||||||
|
service_check_enabled: boolean
|
||||||
|
service_check_interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
get: () => api.get<AppSettings>('/settings'),
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const designsApi = {
|
export const designsApi = {
|
||||||
@@ -150,3 +164,52 @@ export const zigbeeApi = {
|
|||||||
error: string | null
|
error: string | null
|
||||||
}>('/zigbee/import-pending', data),
|
}>('/zigbee/import-pending', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const zwaveApi = {
|
||||||
|
testConnection: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{ connected: boolean; message: string }>('/zwave/test-connection', data),
|
||||||
|
|
||||||
|
importNetwork: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
prefix?: string
|
||||||
|
gateway_name?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
nodes: import('@/components/zwave/types').ZwaveNode[]
|
||||||
|
edges: import('@/components/zwave/types').ZwaveEdge[]
|
||||||
|
device_count: number
|
||||||
|
}>('/zwave/import', data),
|
||||||
|
|
||||||
|
importToPending: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
prefix?: string
|
||||||
|
gateway_name?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}>('/zwave/import-pending', data),
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
|||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import type { NodeData, CustomStyleDef } from '@/types'
|
import type { NodeData, CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
|
||||||
|
|
||||||
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
||||||
|
|
||||||
@@ -55,14 +55,16 @@ function LiveViewCanvas() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
try {
|
// ?design=<id> selects which canvas to render; fall back to the first
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
// design when omitted. Standalone stores full React Flow nodes/edges, so
|
||||||
if (saved) {
|
// no API deserialization is needed.
|
||||||
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
|
const designId = new URLSearchParams(window.location.search).get('design')
|
||||||
loadCanvas(savedNodes, savedEdges)
|
?? standaloneStorage.listDesigns()[0]?.id
|
||||||
}
|
const saved = designId ? standaloneStorage.loadCanvas(designId) : null
|
||||||
} catch {
|
if (saved) {
|
||||||
// empty canvas on parse error — show empty canvas
|
if (saved.theme_id) setTheme(saved.theme_id)
|
||||||
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
|
loadCanvas(saved.nodes, saved.edges)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
|
|
||||||
// ── Standalone mode ────────────────────────────────────────────────────────
|
// ── Standalone mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Captures props from the re-imported (resetModules) ReactFlow so standalone
|
||||||
|
// tests can assert which nodes were rendered without reaching into the fresh
|
||||||
|
// canvas-store module instance.
|
||||||
|
let standaloneRfProps: Record<string, unknown> = {}
|
||||||
const XYFLOW_MOCK = {
|
const XYFLOW_MOCK = {
|
||||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
ReactFlow: () => <div data-testid="react-flow" />,
|
ReactFlow: (props: Record<string, unknown>) => { standaloneRfProps = props; return <div data-testid="react-flow" /> },
|
||||||
Background: () => null,
|
Background: () => null,
|
||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
@@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => {
|
|||||||
vi.unstubAllEnvs()
|
vi.unstubAllEnvs()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads canvas from localStorage without calling the API', async () => {
|
it('loads the active design canvas from localStorage without calling the API', async () => {
|
||||||
const stored = {
|
const design = standaloneStorage.createDesign('Main')
|
||||||
|
standaloneStorage.saveCanvas(design.id, {
|
||||||
nodes: [{
|
nodes: [{
|
||||||
id: 'ls-node', type: 'router',
|
id: 'ls-node', type: 'router',
|
||||||
position: { x: 10, y: 20 },
|
position: { x: 10, y: 20 },
|
||||||
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
|
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
|
||||||
}],
|
}],
|
||||||
edges: [],
|
edges: [],
|
||||||
}
|
})
|
||||||
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
|
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const mockLoad = vi.fn()
|
||||||
|
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||||
|
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||||
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
|
setSearch(`?design=${design.id}`)
|
||||||
|
render(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('ls-node')
|
||||||
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the first design when no ?design= param is given', async () => {
|
||||||
|
const design = standaloneStorage.createDesign('Only')
|
||||||
|
standaloneStorage.saveCanvas(design.id, {
|
||||||
|
nodes: [{
|
||||||
|
id: 'fb-node', type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Srv', type: 'server', status: 'unknown', services: [] },
|
||||||
|
}],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
|
||||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
@@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node')
|
||||||
expect(mockLoad).not.toHaveBeenCalled()
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { edgeTypes } from './edges/edgeTypes'
|
|||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
import { AlignmentGuides } from './AlignmentGuides'
|
import { AlignmentGuides } from './AlignmentGuides'
|
||||||
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||||
|
import { setViewportCenterProjector } from '@/utils/viewportCenter'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
@@ -30,10 +31,12 @@ interface CanvasContainerProps {
|
|||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
|
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||||
|
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||||
onOpenPending?: (deviceId: string) => void
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
@@ -42,7 +45,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
fitViewPending, clearFitViewPending,
|
fitViewPending, clearFitViewPending,
|
||||||
copySelectedNodes, pasteNodes,
|
copySelectedNodes, pasteNodes,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||||
|
|
||||||
// Track the last cursor position over the canvas so paste lands under it.
|
// Track the last cursor position over the canvas so paste lands under it.
|
||||||
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
@@ -50,6 +53,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
cursorRef.current = { x: e.clientX, y: e.clientY }
|
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Expose the visible-canvas centre (in flow coords) to add-node handlers that
|
||||||
|
// live outside ReactFlowProvider, so new nodes land where the user is looking.
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
setViewportCenterProjector(() => {
|
||||||
|
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||||
|
const screen = rect
|
||||||
|
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||||
|
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||||
|
return screenToFlowPosition(screen)
|
||||||
|
})
|
||||||
|
return () => setViewportCenterProjector(null)
|
||||||
|
}, [screenToFlowPosition])
|
||||||
|
|
||||||
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
||||||
// can project the cursor / viewport center into flow coordinates.
|
// can project the cursor / viewport center into flow coordinates.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,8 +142,26 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
|
|
||||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
|
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||||
|
// before the alignment snap so detection uses the dropped position.
|
||||||
|
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||||
|
if (dragNode && !dragNode.parentId &&
|
||||||
|
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||||
|
const intersecting = getIntersectingNodes(dragNode)
|
||||||
|
const group = intersecting.find((n) => n.data.type === 'group')
|
||||||
|
if (group) {
|
||||||
|
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||||
|
} else {
|
||||||
|
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||||
|
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||||
|
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeDragStop(event, dragNode, dragNodes)
|
||||||
|
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
<div ref={wrapperRef} className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={visibleNodes}
|
nodes={visibleNodes}
|
||||||
edges={visibleEdges}
|
edges={visibleEdges}
|
||||||
@@ -139,7 +174,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
onNodeDrag={onNodeDrag}
|
onNodeDrag={onNodeDrag}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={handleNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore', () => ({
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
|
||||||
|
sel({ hideIp: false, serviceStatuses: {} }),
|
||||||
|
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/themes', () => ({
|
vi.mock('@/utils/themes', () => ({
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
// Capture props passed to ReactFlow so we can test the callbacks
|
// Capture props passed to ReactFlow so we can test the callbacks
|
||||||
let rfProps: Record<string, unknown> = {}
|
let rfProps: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
// Hoisted holder so the mock factory can read the configurable intersection set.
|
||||||
|
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
|
||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
ReactFlow: (props: Record<string, unknown>) => {
|
ReactFlow: (props: Record<string, unknown>) => {
|
||||||
rfProps = props
|
rfProps = props
|
||||||
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({
|
||||||
|
fitView: vi.fn(),
|
||||||
|
screenToFlowPosition: vi.fn(),
|
||||||
|
getIntersectingNodes: () => rf.intersecting,
|
||||||
|
setNodes: vi.fn(),
|
||||||
|
getNodes: () => [],
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
|
|||||||
describe('CanvasContainer', () => {
|
describe('CanvasContainer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rfProps = {}
|
rfProps = {}
|
||||||
|
rf.intersecting = []
|
||||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||||
useThemeStore.setState({ activeTheme: 'default' })
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
})
|
})
|
||||||
@@ -154,6 +164,93 @@ describe('CanvasContainer', () => {
|
|||||||
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
|
||||||
|
|
||||||
|
function groupNode(id: string): Node<NodeData> {
|
||||||
|
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
const group = groupNode('g1')
|
||||||
|
rf.intersecting = [group]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup when no group is under the node', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup for an already-parented node', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'gOther' }
|
||||||
|
rf.intersecting = [groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = groupNode('g2')
|
||||||
|
rf.intersecting = [groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Drag onto container node → onRequestAddToContainer ────────────────────
|
||||||
|
|
||||||
|
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
|
||||||
|
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers a group over a container when both intersect', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1'), groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer for an already-parented node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'pxOther' }
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('enables snapToGrid', () => {
|
it('enables snapToGrid', () => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #183 — connection labels must support multiple lines.
|
||||||
|
*
|
||||||
|
* The label is a free-text string; newlines entered in the EdgeModal textarea
|
||||||
|
* are stored verbatim. The rendered label div must preserve those newlines
|
||||||
|
* (`whitespace-pre-line`) instead of collapsing them into a single line.
|
||||||
|
*
|
||||||
|
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
|
||||||
|
* full <ReactFlow> host, so we stub it to a passthrough to render the label
|
||||||
|
* markup directly.
|
||||||
|
*/
|
||||||
|
vi.mock('@xyflow/react', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@xyflow/react')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { HomelableEdge } = await import('../index')
|
||||||
|
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge label', () => {
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const { getByText } = renderEdge({ label: 'uplink' })
|
||||||
|
expect(getByText('uplink')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves newlines in the rendered label (issue #183)', () => {
|
||||||
|
const { container } = renderEdge({ label: 'line one\nline two' })
|
||||||
|
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
|
||||||
|
d.textContent === 'line one\nline two',
|
||||||
|
)
|
||||||
|
expect(label).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no label div when label is empty', () => {
|
||||||
|
const { container } = renderEdge({ label: undefined })
|
||||||
|
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -406,7 +406,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{data?.label && (
|
{data?.label && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded whitespace-pre-line text-center"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon'
|
|||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||||
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
@@ -31,6 +31,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
||||||
@@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
{services.map((svc, idx) => {
|
{services.map((svc, idx) => {
|
||||||
const url = getServiceUrl(svc, serviceHost)
|
const url = getServiceUrl(svc, serviceHost)
|
||||||
|
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
|
||||||
const row = (
|
const row = (
|
||||||
<div
|
<div
|
||||||
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||||
@@ -164,7 +166,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
{/* LEFT: service name */}
|
{/* LEFT: service name */}
|
||||||
<span
|
<span
|
||||||
className="font-medium truncate"
|
className="font-medium truncate"
|
||||||
style={{ minWidth: 0 }}
|
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||||
title={svc.service_name}
|
title={svc.service_name}
|
||||||
>
|
>
|
||||||
{svc.service_name}
|
{svc.service_name}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { nodeTypes } from '../nodeTypes'
|
||||||
|
|
||||||
|
describe('nodeTypes registry', () => {
|
||||||
|
it('registers a component for every wireless mesh node type', () => {
|
||||||
|
// Regression: zwave_* types were missing, so React Flow fell back to the
|
||||||
|
// default (unstyled) node — no icon, no accent. (Zigbee covered too.)
|
||||||
|
for (const t of [
|
||||||
|
'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
||||||
|
'zwave_coordinator', 'zwave_router', 'zwave_enddevice',
|
||||||
|
]) {
|
||||||
|
expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
|
||||||
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
@@ -34,6 +34,11 @@ export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Ne
|
|||||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||||
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|
||||||
|
// Z-Wave node types
|
||||||
|
export const ZwaveCoordinatorNode = (props: N) => <BaseNode {...props} icon={RadioTower} />
|
||||||
|
export const ZwaveRouterNode = (props: N) => <BaseNode {...props} icon={Share2} />
|
||||||
|
export const ZwaveEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|
||||||
// Electrical node types
|
// Electrical node types
|
||||||
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
||||||
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||||
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
||||||
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
||||||
|
ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode,
|
||||||
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
||||||
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
||||||
LightNode, MeterNode, TransformerNode, LoadNode,
|
LightNode, MeterNode, TransformerNode, LoadNode,
|
||||||
@@ -39,6 +40,9 @@ export const nodeTypes = {
|
|||||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
zigbee_router: ZigbeeRouterNode,
|
zigbee_router: ZigbeeRouterNode,
|
||||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
|
zwave_coordinator: ZwaveCoordinatorNode,
|
||||||
|
zwave_router: ZwaveRouterNode,
|
||||||
|
zwave_enddevice: ZwaveEndDeviceNode,
|
||||||
grid: GridNode,
|
grid: GridNode,
|
||||||
ups: UpsNode,
|
ups: UpsNode,
|
||||||
battery: BatteryNode,
|
battery: BatteryNode,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Layers } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ConfirmAddToGroupModalProps {
|
||||||
|
open: boolean
|
||||||
|
nodeLabel: string
|
||||||
|
/** Label of the destination group/container. */
|
||||||
|
targetLabel: string
|
||||||
|
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||||
|
variant?: 'group' | 'container'
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmAddToGroupModal({
|
||||||
|
open,
|
||||||
|
nodeLabel,
|
||||||
|
targetLabel,
|
||||||
|
variant = 'group',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmAddToGroupModalProps) {
|
||||||
|
const action = variant === 'container' ? 'Add to container' : 'Add to group'
|
||||||
|
const noun = variant === 'container' ? 'container' : 'group'
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Layers size={16} className="text-[#00d4ff]" />
|
||||||
|
{action}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||||
|
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { Fragment, useState, useEffect, useCallback } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||||
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
|
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
|
||||||
Radio, Zap, Lightbulb,
|
Radio, Zap, Lightbulb, RadioTower, Share2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
@@ -17,13 +17,16 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||||
|
|
||||||
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
|
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
|
||||||
|
|
||||||
const EDITABLE_NODE_TYPES: NodeType[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host',
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
'generic',
|
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||||
|
{ label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
|
||||||
|
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
||||||
|
{ label: 'Generic', types: ['generic'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
|
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
|
||||||
@@ -34,6 +37,7 @@ const NODE_ICONS: Record<string, LucideIcon> = {
|
|||||||
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
|
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
|
||||||
docker_host: Anchor, docker_container: Package,
|
docker_host: Anchor, docker_container: Package,
|
||||||
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
||||||
|
zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb,
|
||||||
generic: Circle,
|
generic: Circle,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
|||||||
min={0}
|
min={0}
|
||||||
step={10}
|
step={10}
|
||||||
value={style.width}
|
value={style.width}
|
||||||
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
|
onChange={(e) => set('width', parseInt(e.target.value, 10) || 0)}
|
||||||
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
|||||||
min={0}
|
min={0}
|
||||||
step={10}
|
step={10}
|
||||||
value={style.height}
|
value={style.height}
|
||||||
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
|
onChange={(e) => set('height', parseInt(e.target.value, 10) || 0)}
|
||||||
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
edges: { ...customStyle.edges },
|
edges: { ...customStyle.edges },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const handleOpen = (isOpen: boolean) => {
|
// Reset the draft to the saved customStyle whenever the modal is (re)opened.
|
||||||
if (isOpen) {
|
// The parent keeps this component mounted and only toggles `open`, so Radix's
|
||||||
// Reset draft to current saved customStyle on open
|
// onOpenChange never fires for a parent-driven open — we key off the prop edge
|
||||||
|
// instead. Without this, abandoned edits (Cancel) would leak into the next open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
} else {
|
|
||||||
onClose()
|
|
||||||
}
|
}
|
||||||
|
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
||||||
|
// clobber an in-progress edit, only a fresh open should reset.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleOpen = (isOpen: boolean) => {
|
||||||
|
if (!isOpen) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
|
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
|
||||||
@@ -363,34 +375,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
|
|
||||||
{/* Type list */}
|
{/* Type list */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
|
{tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => (
|
||||||
const Icon = NODE_ICONS[t] ?? Circle
|
<Fragment key={group.label}>
|
||||||
const style = draft.nodes[t]
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[#8b949e]/60">
|
||||||
const isSelected = selection?.kind === 'node' && selection.type === t
|
{group.label}
|
||||||
const swatchColor = style
|
</div>
|
||||||
? applyOpacity(style.borderColor, style.borderOpacity)
|
{group.types.map((t) => {
|
||||||
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
|
const Icon = NODE_ICONS[t] ?? Circle
|
||||||
|
const style = draft.nodes[t]
|
||||||
|
const isSelected = selection?.kind === 'node' && selection.type === t
|
||||||
|
const swatchColor = style
|
||||||
|
? applyOpacity(style.borderColor, style.borderOpacity)
|
||||||
|
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelection({ kind: 'node', type: t })}
|
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#21262d' : 'transparent',
|
background: isSelected ? '#21262d' : 'transparent',
|
||||||
color: isSelected ? '#e6edf3' : '#8b949e',
|
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={13} />
|
<Icon size={13} />
|
||||||
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ background: swatchColor }}
|
style={{ background: swatchColor }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
||||||
const style = draft.edges[t]
|
const style = draft.edges[t]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RotateCcw } from 'lucide-react'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
||||||
@@ -44,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
type,
|
||||||
label: label || undefined,
|
label: label.trim() || undefined,
|
||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
|
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
|
||||||
<Input
|
<Textarea
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. 1G, trunk..."
|
placeholder={'e.g. 1G, trunk...\nsecond line'}
|
||||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
rows={2}
|
||||||
|
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import { useState } from 'react'
|
|||||||
import { Download, Loader2 } from 'lucide-react'
|
import { Download, Loader2 } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export'
|
import {
|
||||||
|
exportToPng,
|
||||||
|
exportToSvg,
|
||||||
|
EXPORT_QUALITY_OPTIONS,
|
||||||
|
EXPORT_BACKGROUND_OPTIONS,
|
||||||
|
type ExportQuality,
|
||||||
|
type ExportFormat,
|
||||||
|
type ExportBackground,
|
||||||
|
} from '@/utils/export'
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +20,8 @@ interface ExportModalProps {
|
|||||||
|
|
||||||
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
||||||
const [quality, setQuality] = useState<ExportQuality>('high')
|
const [quality, setQuality] = useState<ExportQuality>('high')
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('png')
|
||||||
|
const [background, setBackground] = useState<ExportBackground>('dark')
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@@ -19,7 +29,11 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
|||||||
if (!el) return
|
if (!el) return
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
try {
|
try {
|
||||||
await exportToPng(el, quality)
|
if (format === 'svg') {
|
||||||
|
await exportToSvg(el, background)
|
||||||
|
} else {
|
||||||
|
await exportToPng(el, quality, background)
|
||||||
|
}
|
||||||
onClose()
|
onClose()
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
@@ -30,7 +44,7 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
|||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-border max-w-sm">
|
<DialogContent className="bg-[#161b22] border-border max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-foreground">Export as PNG</DialogTitle>
|
<DialogTitle className="text-foreground">Export Canvas</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-2 py-2">
|
<div className="space-y-2 py-2">
|
||||||
@@ -38,10 +52,10 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
|||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setQuality(opt.value)}
|
onClick={() => { setFormat('png'); setQuality(opt.value) }}
|
||||||
className={[
|
className={[
|
||||||
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
|
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
|
||||||
quality === opt.value
|
format === 'png' && quality === opt.value
|
||||||
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
|
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
|
||||||
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
|
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
@@ -50,6 +64,45 @@ export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
|||||||
<span className="text-xs opacity-70">{opt.hint}</span>
|
<span className="text-xs opacity-70">{opt.hint}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormat('svg')}
|
||||||
|
className={[
|
||||||
|
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
|
||||||
|
format === 'svg'
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
|
||||||
|
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="font-medium">SVG</span>
|
||||||
|
<span className="text-xs opacity-70">vector — scalable, small file</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 pb-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Background</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{EXPORT_BACKGROUND_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBackground(opt.value)}
|
||||||
|
className={[
|
||||||
|
'flex-1 flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors',
|
||||||
|
background === opt.value
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
|
||||||
|
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 rounded-sm border border-border"
|
||||||
|
style={{ background: opt.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-xs opacity-70">{opt.hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
|||||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||||
|
{ label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
|
||||||
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
||||||
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
|
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
|
||||||
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||||
@@ -26,6 +27,9 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
|||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
||||||
|
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
|
||||||
|
// Mesh radio devices aren't IP-reachable, so they default to no status check.
|
||||||
|
const MESH_TYPES: NodeType[] = [...ZIGBEE_TYPES, ...ZWAVE_TYPES]
|
||||||
|
|
||||||
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||||
none: 'None',
|
none: 'None',
|
||||||
@@ -55,6 +59,8 @@ interface ParentCandidate {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
type: NodeType
|
type: NodeType
|
||||||
|
/** True when the node has container mode on, so any node can nest inside it. */
|
||||||
|
container_mode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
@@ -71,7 +77,7 @@ interface NodeModalProps {
|
|||||||
// initial value is enough - no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
||||||
const merged = { ...DEFAULT_DATA, ...initial }
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
@@ -98,12 +104,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const selectedType = (form.type ?? 'generic') as NodeType
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||||
const validParentTypes = getValidParentTypes(selectedType)
|
const validParentTypes = getValidParentTypes(selectedType)
|
||||||
|
// A parent is valid either by the type rules (lxc/vm/docker_container) or
|
||||||
|
// because the candidate is a container-mode node (any child can nest in it).
|
||||||
|
const isValidParent = (p: ParentCandidate) =>
|
||||||
|
validParentTypes.includes(p.type) || p.container_mode === true
|
||||||
let safeParentId = form.parent_id
|
let safeParentId = form.parent_id
|
||||||
if (validParentTypes.length === 0) {
|
if (safeParentId) {
|
||||||
safeParentId = undefined
|
|
||||||
} else if (safeParentId) {
|
|
||||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
@@ -129,8 +137,13 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const t = v as NodeType
|
const t = v as NodeType
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
const next: Partial<NodeData> = { ...f, type: t }
|
const next: Partial<NodeData> = { ...f, type: t }
|
||||||
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
if (MESH_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||||
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
// Drop the parent only if it's no longer a valid target for the
|
||||||
|
// new type — keep container-mode parents (any node can nest).
|
||||||
|
const parent = parentCandidates.find((n) => n.id === f.parent_id)
|
||||||
|
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
|
||||||
|
next.parent_id = undefined
|
||||||
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
@@ -349,9 +362,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{(() => {
|
{(() => {
|
||||||
const childType = (form.type ?? 'generic') as NodeType
|
const childType = (form.type ?? 'generic') as NodeType
|
||||||
const validParentTypes = getValidParentTypes(childType)
|
const validParentTypes = getValidParentTypes(childType)
|
||||||
if (validParentTypes.length === 0) return null
|
// Candidates: type-based parents (lxc/vm/docker_container) plus any
|
||||||
|
// container-mode node. The current parent is always kept so an
|
||||||
|
// already-nested node can be re-targeted or detached here.
|
||||||
const validParents = parentCandidates.filter(
|
const validParents = parentCandidates.filter(
|
||||||
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
(n) => n.id !== currentNodeId &&
|
||||||
|
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
|
||||||
)
|
)
|
||||||
if (validParents.length === 0) return null
|
if (validParents.length === 0) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export interface PendingDevice {
|
|||||||
vendor?: string | null
|
vendor?: string | null
|
||||||
lqi?: number | null
|
lqi?: number | null
|
||||||
discovered_at: string
|
discovered_at: string
|
||||||
|
// How many canvases (designs) this device already appears on. Computed server-side.
|
||||||
|
canvas_count?: number
|
||||||
|
// Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
|
||||||
|
// Null/absent when the device is not on any canvas yet.
|
||||||
|
node_created_at?: string | null
|
||||||
|
node_last_scan?: string | null
|
||||||
|
node_last_modified?: string | null
|
||||||
|
node_last_seen?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingDeviceModalProps {
|
interface PendingDeviceModalProps {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
import type { NodeType, ServiceInfo } from '@/types'
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||||
|
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||||
import { buildMacProperty } from '@/utils/macProperty'
|
import { buildMacProperty } from '@/utils/macProperty'
|
||||||
|
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||||
|
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||||
|
|
||||||
interface PendingDevicesModalProps {
|
interface PendingDevicesModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -67,11 +71,13 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
|
|||||||
generic: Circle,
|
generic: Circle,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||||
type StatusFilter = 'pending' | 'hidden'
|
type StatusFilter = 'pending' | 'hidden'
|
||||||
|
|
||||||
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
|
||||||
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
if (d.discovery_source === 'zwave') return 'zwave'
|
||||||
|
if (d.discovery_source === 'zigbee') return 'zigbee'
|
||||||
|
if (d.ieee_address) return 'zigbee'
|
||||||
return 'ip'
|
return 'ip'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +125,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
||||||
|
// Inventory shows on-canvas devices by default; toggle off to hide them.
|
||||||
|
const [showOnCanvas, setShowOnCanvas] = useState(true)
|
||||||
|
// Optionally restrict to devices that have at least one detected service.
|
||||||
|
const [withServicesOnly, setWithServicesOnly] = useState(false)
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const activeDesignId = useDesignStore((s) => s.activeDesignId)
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -159,6 +170,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
return devices.filter((d) => {
|
return devices.filter((d) => {
|
||||||
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
||||||
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||||
|
// 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 (q) {
|
if (q) {
|
||||||
const hay = [
|
const hay = [
|
||||||
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||||
@@ -168,7 +182,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [devices, search, sourceFilter, typeFilter])
|
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightId || loading || !open) return
|
if (!highlightId || loading || !open) return
|
||||||
@@ -241,9 +255,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
|
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
|
||||||
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
|
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
|
||||||
} else {
|
} else {
|
||||||
|
// Clears only pending rows server-side; approved/on-canvas devices stay,
|
||||||
|
// so reload rather than blanking the whole inventory.
|
||||||
await scanApi.clearPending()
|
await scanApi.clearPending()
|
||||||
setDevices([])
|
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
await load()
|
||||||
toast.success('Pending devices cleared')
|
toast.success('Pending devices cleared')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -255,25 +271,32 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
try {
|
try {
|
||||||
const fallbackLabel = deviceLabel(device)
|
const fallbackLabel = deviceLabel(device)
|
||||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||||
const zigbee = isZigbeeType(type)
|
const zwave = isZwaveType(type)
|
||||||
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
|
const wireless = isZigbeeType(type) || zwave
|
||||||
|
const properties = zwave
|
||||||
|
? buildZwaveProperties(device)
|
||||||
|
: isZigbeeType(type)
|
||||||
|
? buildZigbeeProperties(device)
|
||||||
|
: buildMacProperty(device.mac)
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
label: fallbackLabel,
|
label: fallbackLabel,
|
||||||
type,
|
type,
|
||||||
ip: device.ip ?? undefined,
|
ip: device.ip ?? undefined,
|
||||||
mac: device.mac ?? undefined,
|
mac: device.mac ?? undefined,
|
||||||
hostname: device.hostname ?? undefined,
|
hostname: device.hostname ?? undefined,
|
||||||
status: zigbee ? 'online' : 'unknown',
|
status: wireless ? 'online' : 'unknown',
|
||||||
services: (device.services ?? []) as ServiceInfo[],
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
properties,
|
properties,
|
||||||
|
// Approve onto the design the user is viewing, not the first design.
|
||||||
|
design_id: activeDesignId ?? undefined,
|
||||||
}
|
}
|
||||||
const res = await scanApi.approve(device.id, nodeData)
|
const res = await scanApi.approve(device.id, nodeData)
|
||||||
const nodeId = res.data.node_id
|
const nodeId = res.data.node_id
|
||||||
addNode({
|
addNode({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: nodeData.type,
|
type: nodeData.type,
|
||||||
position: { x: 400, y: 300 },
|
position: getCenteredPosition(),
|
||||||
data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
|
data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) },
|
||||||
})
|
})
|
||||||
injectAutoEdges(res.data.edges)
|
injectAutoEdges(res.data.edges)
|
||||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
@@ -310,28 +333,36 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const ids = [...selectedIds]
|
const ids = [...selectedIds]
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
try {
|
try {
|
||||||
const res = await scanApi.bulkApprove(ids)
|
const res = await scanApi.bulkApprove(ids, activeDesignId)
|
||||||
const deviceToNode: Record<string, string> = {}
|
const deviceToNode: Record<string, string> = {}
|
||||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||||
|
const cols = Math.min(4, approvedDevices.length)
|
||||||
|
const rows = Math.ceil(approvedDevices.length / 4)
|
||||||
|
const origin = getCenteredPosition(cols * 160, rows * 100)
|
||||||
approvedDevices.forEach((d, i) => {
|
approvedDevices.forEach((d, i) => {
|
||||||
const nodeId = deviceToNode[d.id]
|
const nodeId = deviceToNode[d.id]
|
||||||
if (!nodeId) return
|
if (!nodeId) return
|
||||||
const type = (d.suggested_type ?? 'generic') as NodeType
|
const type = (d.suggested_type ?? 'generic') as NodeType
|
||||||
const zigbee = isZigbeeType(type)
|
const zwave = isZwaveType(type)
|
||||||
|
const wireless = isZigbeeType(type) || zwave
|
||||||
addNode({
|
addNode({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type,
|
type,
|
||||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
position: { x: origin.x + (i % 4) * 160, y: origin.y + Math.floor(i / 4) * 100 },
|
||||||
data: {
|
data: {
|
||||||
label: deviceLabel(d),
|
label: deviceLabel(d),
|
||||||
type,
|
type,
|
||||||
ip: d.ip ?? undefined,
|
ip: d.ip ?? undefined,
|
||||||
mac: d.mac ?? undefined,
|
mac: d.mac ?? undefined,
|
||||||
hostname: d.hostname ?? undefined,
|
hostname: d.hostname ?? undefined,
|
||||||
status: zigbee ? ('online' as const) : ('unknown' as const),
|
status: wireless ? ('online' as const) : ('unknown' as const),
|
||||||
services: (d.services ?? []) as ServiceInfo[],
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
|
properties: zwave
|
||||||
|
? buildZwaveProperties(d)
|
||||||
|
: isZigbeeType(type)
|
||||||
|
? buildZigbeeProperties(d)
|
||||||
|
: buildMacProperty(d.mac),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -398,7 +429,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
{statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
|
||||||
<span className="text-muted-foreground font-normal text-xs">
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||||
</span>
|
</span>
|
||||||
@@ -464,6 +495,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
>
|
>
|
||||||
Zigbee
|
Zigbee
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('zwave')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zwave' ? 'bg-[#ff6e00]/20 text-[#ff6e00]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Z-Wave
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
@@ -479,7 +516,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
onClick={() => setStatusFilter('pending')}
|
onClick={() => setStatusFilter('pending')}
|
||||||
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
Pending
|
Inventory
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('hidden')}
|
onClick={() => setStatusFilter('hidden')}
|
||||||
@@ -488,6 +525,26 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
Hidden
|
Hidden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOnCanvas((v) => !v)}
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${showOnCanvas ? 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground' : 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50'}`}
|
||||||
|
title="Show or hide devices already on a canvas"
|
||||||
|
aria-pressed={!showOnCanvas}
|
||||||
|
>
|
||||||
|
<Layers size={12} />
|
||||||
|
{showOnCanvas ? 'Hide on-canvas' : 'Show on-canvas'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setWithServicesOnly((v) => !v)}
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${withServicesOnly ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||||
|
title="Only show devices with at least one detected service"
|
||||||
|
aria-pressed={withServicesOnly}
|
||||||
|
>
|
||||||
|
<ServerCog size={12} />
|
||||||
|
With services
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
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'}`}
|
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'}`}
|
||||||
@@ -510,7 +567,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
{filtered.map((d) => (
|
{filtered.map((d) => (
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
key={d.id}
|
key={d.id}
|
||||||
@@ -602,12 +659,31 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
|||||||
const source = inferSource(device)
|
const source = inferSource(device)
|
||||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
const label = deviceLabel(device)
|
const label = deviceLabel(device)
|
||||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
||||||
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
const sourceLabel =
|
||||||
|
source === 'zigbee' ? 'ZIGBEE'
|
||||||
|
: source === 'zwave' ? 'Z-WAVE'
|
||||||
|
: (device.discovery_source ?? 'IP').toUpperCase()
|
||||||
const services = device.services ?? []
|
const services = device.services ?? []
|
||||||
const visibleServices = services.slice(0, 4)
|
const visibleServices = services.slice(0, 4)
|
||||||
const moreServices = services.length - visibleServices.length
|
const moreServices = services.length - visibleServices.length
|
||||||
|
|
||||||
|
// Timestamps: a device placed on a canvas shows its linked node's lifecycle
|
||||||
|
// (created / last scan / last modified / last seen). A device that is only in
|
||||||
|
// the discovery inventory has no node yet, so it falls back to when the
|
||||||
|
// scanner first saw it.
|
||||||
|
const onCanvas = (device.canvas_count ?? 0) > 0
|
||||||
|
const timestamps: { label: string; iso: string }[] = []
|
||||||
|
if (onCanvas) {
|
||||||
|
if (device.node_created_at) timestamps.push({ label: 'Created', iso: device.node_created_at })
|
||||||
|
if (device.node_last_scan) timestamps.push({ label: 'Scan', iso: device.node_last_scan })
|
||||||
|
if (device.node_last_modified) timestamps.push({ label: 'Modified', iso: device.node_last_modified })
|
||||||
|
if (device.node_last_seen) timestamps.push({ label: 'Seen', iso: device.node_last_seen })
|
||||||
|
}
|
||||||
|
if (timestamps.length === 0) {
|
||||||
|
timestamps.push({ label: 'Discovered', iso: device.discovered_at })
|
||||||
|
}
|
||||||
|
|
||||||
const borderClass = highlighted
|
const borderClass = highlighted
|
||||||
? 'border-[#e3b341] bg-[#2d3748]'
|
? 'border-[#e3b341] bg-[#2d3748]'
|
||||||
: selected
|
: selected
|
||||||
@@ -619,17 +695,29 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
|||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-testid={`pending-card-${device.id}`}
|
data-testid={`pending-card-${device.id}`}
|
||||||
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
|
className={`relative text-left rounded-lg border p-2.5 transition-all duration-150 ${borderClass}`}
|
||||||
>
|
>
|
||||||
{selectMode && selected && (
|
{selectMode && selected && (
|
||||||
<CheckCircle2
|
<CheckCircle2
|
||||||
size={18}
|
size={18}
|
||||||
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117] z-10"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!selectMode && device.status === 'hidden' && (
|
{!selectMode && device.status === 'hidden' && (
|
||||||
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
{/* Canvas-presence corner: how many canvases this device already sits on.
|
||||||
|
Hidden while a select-mode checkmark occupies the same corner. */}
|
||||||
|
{device.status !== 'hidden' && (device.canvas_count ?? 0) > 0 && !(selectMode && selected) && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 flex items-center gap-1 rounded-bl-lg rounded-tr-lg bg-[#00d4ff] text-[#0d1117] text-xs font-bold px-2 py-1 shadow-md"
|
||||||
|
title={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
|
||||||
|
aria-label={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
|
||||||
|
>
|
||||||
|
<Layers size={12} strokeWidth={2.5} />
|
||||||
|
{device.canvas_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-2 mb-2">
|
<div className="flex items-start gap-2 mb-2">
|
||||||
@@ -692,6 +780,14 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Timestamps — compact relative times, full date on hover. Kept tiny so
|
||||||
|
the tile footprint stays the same as before. */}
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/50 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
|
||||||
|
{timestamps.map((t) => (
|
||||||
|
<TimeLine key={t.label} label={t.label} iso={t.iso} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -704,3 +800,12 @@ function InfoLine({ label, value }: { label: string; value: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TimeLine({ label, iso }: { label: string; iso: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1 min-w-0" title={formatTimestamp(iso)}>
|
||||||
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
|
<span className="font-mono text-foreground/80 truncate">{formatRelative(iso)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Trash2, Settings } from 'lucide-react'
|
import { Plus, Trash2, Settings, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi, type DeepScanConfig } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface ScanConfigModalProps {
|
interface ScanConfigModalProps {
|
||||||
@@ -13,24 +13,56 @@ interface ScanConfigModalProps {
|
|||||||
onScanNow: () => void
|
onScanNow: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEEP_DEFAULTS: DeepScanConfig = { http_ranges: [], http_probe_enabled: false, verify_tls: false }
|
||||||
|
|
||||||
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
|
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
|
||||||
const [ranges, setRanges] = useState<string[]>([''])
|
const [ranges, setRanges] = useState<string[]>([''])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Deep-scan section. Pre-filled from persisted defaults; edits here are a
|
||||||
|
// per-scan override passed to trigger() — they do NOT change the saved defaults.
|
||||||
|
const [deepOpen, setDeepOpen] = useState(false)
|
||||||
|
const [deepDefaults, setDeepDefaults] = useState<DeepScanConfig>(DEEP_DEFAULTS)
|
||||||
|
const [httpProbe, setHttpProbe] = useState(false)
|
||||||
|
const [verifyTls, setVerifyTls] = useState(false)
|
||||||
|
const [httpRangesText, setHttpRangesText] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
scanApi.getConfig()
|
scanApi.getConfig()
|
||||||
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
|
.then((res) => {
|
||||||
|
const d = res.data
|
||||||
|
setRanges(d.ranges.length > 0 ? d.ranges : [''])
|
||||||
|
const deep: DeepScanConfig = {
|
||||||
|
http_ranges: d.http_ranges ?? [],
|
||||||
|
http_probe_enabled: d.http_probe_enabled ?? false,
|
||||||
|
verify_tls: d.verify_tls ?? false,
|
||||||
|
}
|
||||||
|
setDeepDefaults(deep)
|
||||||
|
setHttpProbe(deep.http_probe_enabled)
|
||||||
|
setVerifyTls(deep.verify_tls)
|
||||||
|
setHttpRangesText(deep.http_ranges.join(', '))
|
||||||
|
setDeepOpen(deep.http_probe_enabled || deep.http_ranges.length > 0)
|
||||||
|
})
|
||||||
.catch(() => {/* use defaults */})
|
.catch(() => {/* use defaults */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
const parseHttpRanges = () =>
|
||||||
|
httpRangesText.split(',').map((r) => r.trim()).filter(Boolean)
|
||||||
|
|
||||||
const handleScanNow = async () => {
|
const handleScanNow = async () => {
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await scanApi.saveConfig({ ranges: cleaned })
|
// Persist IP ranges; leave deep-scan defaults as configured in Options.
|
||||||
await scanApi.trigger()
|
await scanApi.saveConfig({ ranges: cleaned, ...deepDefaults })
|
||||||
|
// Per-scan deep-scan override from this dialog.
|
||||||
|
await scanApi.trigger({
|
||||||
|
http_ranges: parseHttpRanges(),
|
||||||
|
http_probe_enabled: httpProbe,
|
||||||
|
verify_tls: verifyTls,
|
||||||
|
})
|
||||||
onScanNow()
|
onScanNow()
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -84,6 +116,57 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Deep Scan (opt-in) */}
|
||||||
|
<div className="space-y-2 border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeepOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{deepOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
Deep Scan
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{deepOpen && (
|
||||||
|
<div className="space-y-3 pl-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scan extra ports and probe HTTP services to identify apps on custom ports.
|
||||||
|
Overrides the saved defaults for this scan only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Extra port ranges</Label>
|
||||||
|
<Input
|
||||||
|
value={httpRangesText}
|
||||||
|
onChange={(e) => setHttpRangesText(e.target.value)}
|
||||||
|
placeholder="8000-8100, 9000-9100"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={httpProbe}
|
||||||
|
onChange={(e) => setHttpProbe(e.target.checked)}
|
||||||
|
className="accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
Enable HTTP probe
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={verifyTls}
|
||||||
|
onChange={(e) => setVerifyTls(e.target.checked)}
|
||||||
|
className="accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
Verify TLS certificates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
<Settings size={11} />
|
<Settings size={11} />
|
||||||
Status check interval can be configured in the sidebar Settings.
|
Status check interval can be configured in the sidebar Settings.
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react'
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export interface ScanRun {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind?: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanHistoryModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||||
|
|
||||||
|
/** Normalise a ScanRun.kind into one of the known display kinds. */
|
||||||
|
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
|
||||||
|
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_META = {
|
||||||
|
ip: { label: 'IP', color: '#a855f7' },
|
||||||
|
zigbee: { label: 'Zigbee', color: '#00d4ff' },
|
||||||
|
zwave: { label: 'Z-Wave', color: '#ff6e00' },
|
||||||
|
} as const
|
||||||
|
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'running', label: 'Running' },
|
||||||
|
{ key: 'done', label: 'Done' },
|
||||||
|
{ key: 'error', label: 'Error' },
|
||||||
|
{ key: 'cancelled', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'ip', label: 'IP' },
|
||||||
|
{ key: 'zigbee', label: 'Zigbee' },
|
||||||
|
{ key: 'zwave', label: 'Z-Wave' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor(s: string): string {
|
||||||
|
return s === 'done' ? '#39d353'
|
||||||
|
: s === 'running' ? '#e3b341'
|
||||||
|
: s === 'error' ? '#f85149'
|
||||||
|
: s === 'cancelled' ? '#8b949e'
|
||||||
|
: '#8b949e'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtc(ts: string): number {
|
||||||
|
return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 0) ms = 0
|
||||||
|
const s = Math.floor(ms / 1000)
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const rem = s % 60
|
||||||
|
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDuration(r: ScanRun, now: number): string {
|
||||||
|
const start = parseUtc(r.started_at)
|
||||||
|
const end = r.finished_at ? parseUtc(r.finished_at) : now
|
||||||
|
return formatDuration(end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||||
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [stopping, setStopping] = useState<string | null>(null)
|
||||||
|
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const prevRunsRef = useRef<ScanRun[]>([])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await scanApi.runs()
|
||||||
|
const next: ScanRun[] = res.data
|
||||||
|
|
||||||
|
// Surface transitions and refresh dependent UI
|
||||||
|
for (const run of next) {
|
||||||
|
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||||
|
if (prev?.status === 'running' && run.status === 'error') {
|
||||||
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
|
}
|
||||||
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
|
if (run.kind === 'zigbee' || run.kind === 'zwave') {
|
||||||
|
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
|
||||||
|
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevRunsRef.current = next
|
||||||
|
setRuns(next)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load scan history')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
prevRunsRef.current = []
|
||||||
|
load()
|
||||||
|
}, [open, load])
|
||||||
|
|
||||||
|
// Auto-refresh every 3s while any run is still running (only when open)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const hasRunning = runs.some((r) => r.status === 'running')
|
||||||
|
if (!hasRunning) return
|
||||||
|
const id = setInterval(load, 3000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, runs, load])
|
||||||
|
|
||||||
|
// Tick the clock every second while a scan is running (for live elapsed duration)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const hasRunning = runs.some((r) => r.status === 'running')
|
||||||
|
if (!hasRunning) return
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, runs])
|
||||||
|
|
||||||
|
const handleStop = async (runId: string) => {
|
||||||
|
setStopping(runId)
|
||||||
|
try {
|
||||||
|
await scanApi.stop(runId)
|
||||||
|
toast.success('Scan stop requested')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to stop scan')
|
||||||
|
} finally {
|
||||||
|
setStopping(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = runs.filter((r) => {
|
||||||
|
const k = runKind(r.kind)
|
||||||
|
if (kindFilter !== 'all' && k !== kindFilter) return false
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Clock size={16} className="text-[#00d4ff]" />
|
||||||
|
Scan History
|
||||||
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
|
({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
|
||||||
|
</button>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
|
||||||
|
{KIND_FILTERS.map((f) => (
|
||||||
|
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
|
||||||
|
{f.label}
|
||||||
|
</FilterChip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
|
||||||
|
{f.label}
|
||||||
|
</FilterChip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
|
||||||
|
{loading && runs.length === 0 && (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
|
||||||
|
<Inbox size={28} className="opacity-50" />
|
||||||
|
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const kind = runKind(r.kind)
|
||||||
|
const meta = KIND_META[kind]
|
||||||
|
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||||
|
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
|
||||||
|
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${meta.color}22`, color: meta.color }}
|
||||||
|
>
|
||||||
|
<KindIcon size={10} />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||||
|
{r.devices_found} found
|
||||||
|
</span>
|
||||||
|
{r.status === 'running' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<button
|
||||||
|
aria-label="Stop scan"
|
||||||
|
onClick={() => handleStop(r.id)}
|
||||||
|
disabled={stopping === r.id}
|
||||||
|
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{stopping === r.id
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <StopCircle size={13} />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Stop scan</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta grid */}
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
||||||
|
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
|
||||||
|
<Meta
|
||||||
|
label="Finished"
|
||||||
|
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
|
||||||
|
/>
|
||||||
|
<Meta label="Duration" value={runDuration(r, now)} mono />
|
||||||
|
<Meta label="Devices" value={`${r.devices_found}`} mono />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.ranges.length > 0 && (
|
||||||
|
<div className="mt-2 text-[11px]">
|
||||||
|
<span className="text-muted-foreground">Ranges: </span>
|
||||||
|
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.error && (
|
||||||
|
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
|
||||||
|
{r.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
|
||||||
|
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
|
||||||
|
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ interface SettingsModalProps {
|
|||||||
|
|
||||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
|
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
||||||
|
const [serviceInterval, setServiceInterval] = useState(300)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
@@ -28,7 +30,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || STANDALONE) return
|
if (!open || STANDALONE) return
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
.then((res) => {
|
||||||
|
setIntervalValue(res.data.interval_seconds)
|
||||||
|
setServiceCheckEnabled(res.data.service_check_enabled)
|
||||||
|
setServiceInterval(res.data.service_check_interval)
|
||||||
|
})
|
||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
@@ -49,7 +55,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await settingsApi.save({ interval_seconds: interval })
|
await settingsApi.save({
|
||||||
|
interval_seconds: interval,
|
||||||
|
service_check_enabled: serviceCheckEnabled,
|
||||||
|
service_check_interval: serviceInterval,
|
||||||
|
})
|
||||||
toast.success('Settings saved')
|
toast.success('Settings saved')
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -85,6 +95,36 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
How often node health is polled (ping, HTTP, SSH…)
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
|
||||||
|
<span className="text-xs text-foreground">Check services individually</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={serviceCheckEnabled}
|
||||||
|
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle per-service status checks"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={3600}
|
||||||
|
value={serviceInterval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
aria-label="Service check interval"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Probes each service port. Offline services turn red. Default 300s (5 min).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
|
||||||
|
|
||||||
|
describe('ConfirmAddToGroupModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows node and group labels when open', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Router')).toBeDefined()
|
||||||
|
expect(screen.getByText('DMZ')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm when the confirm button is clicked', () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onCancel when the cancel button is clicked', () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses container wording when variant is container', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -37,6 +37,16 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('groups node types under category headers (incl. Zigbee and Z-Wave)', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
expect(screen.getByText('Zigbee')).toBeDefined()
|
||||||
|
expect(screen.getByText('Z-Wave')).toBeDefined()
|
||||||
|
// A Z-Wave node type is selectable from its category.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ }))
|
||||||
|
expect(screen.getByText(/Apply to existing Z-Wave Controller/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('selecting a node type opens the node editor', () => {
|
it('selecting a node type opens the node editor', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
@@ -124,4 +134,23 @@ describe('CustomStyleModal', () => {
|
|||||||
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
||||||
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
||||||
|
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
||||||
|
// happen on the open-prop edge, not via Radix onOpenChange.
|
||||||
|
const { rerender } = render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
const widthInput = screen.getAllByRole('spinbutton')[0]
|
||||||
|
fireEvent.change(widthInput, { target: { value: '250' } })
|
||||||
|
expect((widthInput as HTMLInputElement).value).toBe('250')
|
||||||
|
|
||||||
|
// Cancel = parent flips open → false, then later → true again.
|
||||||
|
rerender(<CustomStyleModal open={false} onClose={vi.fn()} />)
|
||||||
|
rerender(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
const reopenedWidth = screen.getAllByRole('spinbutton')[0]
|
||||||
|
// Draft was reset to saved style (default width 0) — edit did not leak.
|
||||||
|
expect((reopenedWidth as HTMLInputElement).value).toBe('0')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
|
|||||||
it('calls onSubmit with label when filled', () => {
|
it('calls onSubmit with label when filled', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'uplink' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves newlines in label so it can span multiple lines (issue #183)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'line one\nline two' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].label).toBe('line one\nline two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims surrounding whitespace/blank lines from label on submit', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: ' a\nb\n\n' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].label).toBe('a\nb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the label field as a textarea (multi-line input)', () => {
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText(/e\.g\. 1G, trunk/).tagName).toBe('TEXTAREA')
|
||||||
|
})
|
||||||
|
|
||||||
it('omits label from payload when empty', () => {
|
it('omits label from payload when empty', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
|
|||||||
|
|
||||||
it('pre-fills label from initial prop', () => {
|
it('pre-fills label from initial prop', () => {
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
||||||
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
|
const input = screen.getByPlaceholderText(/e\.g\. 1G, trunk/) as HTMLTextAreaElement
|
||||||
expect(input.value).toBe('trunk')
|
expect(input.value).toBe('trunk')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|||||||
import { ExportModal } from '../ExportModal'
|
import { ExportModal } from '../ExportModal'
|
||||||
|
|
||||||
const mockExportToPng = vi.fn()
|
const mockExportToPng = vi.fn()
|
||||||
|
const mockExportToSvg = vi.fn()
|
||||||
vi.mock('@/utils/export', () => ({
|
vi.mock('@/utils/export', () => ({
|
||||||
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
|
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
|
||||||
|
exportToSvg: (...args: unknown[]) => mockExportToSvg(...args),
|
||||||
EXPORT_QUALITY_OPTIONS: [
|
EXPORT_QUALITY_OPTIONS: [
|
||||||
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
|
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
|
||||||
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
|
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
|
||||||
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
|
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
|
||||||
],
|
],
|
||||||
|
EXPORT_BACKGROUND_OPTIONS: [
|
||||||
|
{ value: 'dark', label: 'Dark', color: '#0d1117', hint: 'screen / docs' },
|
||||||
|
{ value: 'white', label: 'White', color: '#ffffff', hint: 'printing' },
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
@@ -20,6 +26,7 @@ describe('ExportModal', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockExportToPng.mockResolvedValue(undefined)
|
mockExportToPng.mockResolvedValue(undefined)
|
||||||
|
mockExportToSvg.mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders all three quality options', () => {
|
it('renders all three quality options', () => {
|
||||||
@@ -46,7 +53,51 @@ describe('ExportModal', () => {
|
|||||||
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
fireEvent.click(screen.getByText('Standard').closest('button')!)
|
fireEvent.click(screen.getByText('Standard').closest('button')!)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
|
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard', 'dark'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an SVG option under the quality options', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
expect(screen.getByText('SVG')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects SVG and calls exportToSvg on Download click', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('SVG').closest('button')!)
|
||||||
|
expect(screen.getByText('SVG').closest('button')!.className).toContain('border-[#00d4ff]')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToSvg).toHaveBeenCalledWith(el, 'dark'))
|
||||||
|
expect(mockExportToPng).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches back to PNG when a quality option is clicked after SVG', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('SVG').closest('button')!)
|
||||||
|
fireEvent.click(screen.getByText('Ultra').closest('button')!)
|
||||||
|
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
|
||||||
|
expect(screen.getByText('SVG').closest('button')!.className).not.toContain('border-[#00d4ff]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dark and white background options, dark selected by default', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
expect(screen.getByText('Dark')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('White')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Dark').closest('button')!.className).toContain('border-[#00d4ff]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports with white background when White is selected (printing)', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('White').closest('button')!)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'high', 'white'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the background choice to SVG export too', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('SVG').closest('button')!)
|
||||||
|
fireEvent.click(screen.getByText('White').closest('button')!)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToSvg).toHaveBeenCalledWith(el, 'white'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes after successful export', async () => {
|
it('closes after successful export', async () => {
|
||||||
@@ -69,6 +120,6 @@ describe('ExportModal', () => {
|
|||||||
|
|
||||||
it('does not render when closed', () => {
|
it('does not render when closed', () => {
|
||||||
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
|
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
|
||||||
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
|
expect(screen.queryByText('Export Canvas')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -188,6 +188,20 @@ describe('NodeModal', () => {
|
|||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers Z-Wave node types and submits one', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(selects()[0], { target: { value: 'zwave_enddevice' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('zwave_enddevice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forces check_method to none when a Z-Wave type is selected', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: { ...BASE, check_method: 'ping' } })
|
||||||
|
fireEvent.change(selects()[0], { target: { value: 'zwave_coordinator' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
// ── Check method ──────────────────────────────────────────────────────
|
// ── Check method ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('pre-fills check_method from initial', () => {
|
it('pre-fills check_method from initial', () => {
|
||||||
@@ -353,6 +367,48 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'server' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'server' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a container-mode parent_id on submit for a plain node', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a parent_id that is not a valid container on submit', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||||
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Appearance ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
||||||
import { PendingDevicesModal } from '../PendingDevicesModal'
|
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
@@ -67,6 +67,23 @@ const DEVICE_ZIGBEE = {
|
|||||||
discovered_at: '2026-01-02T00:00:00Z',
|
discovered_at: '2026-01-02T00:00:00Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_ZWAVE = {
|
||||||
|
id: 'dev-c',
|
||||||
|
ip: null,
|
||||||
|
hostname: null,
|
||||||
|
mac: null,
|
||||||
|
os: null,
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'zwave_router',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'zwave',
|
||||||
|
ieee_address: 'zwave-0xh-2',
|
||||||
|
friendly_name: 'wall-plug',
|
||||||
|
vendor: 'Aeotec',
|
||||||
|
model: 'ZW100',
|
||||||
|
discovered_at: '2026-01-03T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
@@ -129,6 +146,22 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows source chip Z-WAVE for zwave device', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by source (zwave only)', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||||
|
render(<PendingDevicesModal {...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()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('filters by suggested type', async () => {
|
it('filters by suggested type', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
@@ -180,7 +213,7 @@ describe('PendingDevicesModal', () => {
|
|||||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
||||||
@@ -257,7 +290,7 @@ describe('PendingDevicesModal', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
fireEvent.keyDown(window, { key: 'Enter' })
|
fireEvent.keyDown(window, { key: 'Enter' })
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
|
||||||
expect(mockBulkRestore).not.toHaveBeenCalled()
|
expect(mockBulkRestore).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -271,4 +304,97 @@ describe('PendingDevicesModal', () => {
|
|||||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||||
expect(mockBulkApprove).not.toHaveBeenCalled()
|
expect(mockBulkApprove).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
|
||||||
|
|
||||||
|
it('titles the pending view "Device Inventory"', async () => {
|
||||||
|
render(<PendingDevicesModal {...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" />)
|
||||||
|
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} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
const corner = screen.getByLabelText('On 2 canvases')
|
||||||
|
expect(corner).toHaveTextContent('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses singular "canvas" for a single canvas', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
|
||||||
|
render(<PendingDevicesModal {...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} />)
|
||||||
|
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} />)
|
||||||
|
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()
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows on-canvas devices by default and hides them when toggled off', async () => {
|
||||||
|
mockPending.mockResolvedValue({
|
||||||
|
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
|
||||||
|
})
|
||||||
|
render(<PendingDevicesModal {...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()
|
||||||
|
// Toggle off → the on-canvas device (dev-b) disappears.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Hide on-canvas/ }))
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tile timestamps', () => {
|
||||||
|
it('shows the linked node lifecycle timestamps for on-canvas devices', async () => {
|
||||||
|
mockPending.mockResolvedValue({
|
||||||
|
data: [{
|
||||||
|
...DEVICE_IP,
|
||||||
|
canvas_count: 1,
|
||||||
|
node_created_at: '2026-01-02T10:00:00Z',
|
||||||
|
node_last_scan: '2026-06-01T08:30:00Z',
|
||||||
|
node_last_modified: '2026-06-20T12:00:00Z',
|
||||||
|
node_last_seen: '2026-06-25T09:15:00Z',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
const card = await screen.findByTestId('pending-card-dev-a')
|
||||||
|
const inCard = within(card)
|
||||||
|
expect(inCard.getByText('Created')).toBeInTheDocument()
|
||||||
|
expect(inCard.getByText('Scan')).toBeInTheDocument()
|
||||||
|
expect(inCard.getByText('Modified')).toBeInTheDocument()
|
||||||
|
expect(inCard.getByText('Seen')).toBeInTheDocument()
|
||||||
|
// Discovered fallback is not shown once node timestamps are present.
|
||||||
|
expect(inCard.queryByText('Discovered')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Discovered for devices not on any canvas', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP] })
|
||||||
|
render(<PendingDevicesModal {...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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ describe('ScanConfigModal', () => {
|
|||||||
await screen.findByDisplayValue('192.168.1.0/24')
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
http_ranges: [],
|
||||||
|
http_probe_enabled: false,
|
||||||
|
verify_tls: false,
|
||||||
|
})
|
||||||
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||||
expect(onScanNow).toHaveBeenCalledOnce()
|
expect(onScanNow).toHaveBeenCalledOnce()
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
@@ -108,4 +113,59 @@ describe('ScanConfigModal', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Deep scan ---
|
||||||
|
|
||||||
|
it('reveals deep-scan fields when the section is toggled', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
expect(screen.queryByText('Enable HTTP probe')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByText('Deep Scan'))
|
||||||
|
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes deep-scan overrides to trigger() as a per-scan override', async () => {
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.click(screen.getByText('Deep Scan'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('8000-8100, 9000-9100'), {
|
||||||
|
target: { value: '8000-8100, 9000' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByLabelText('Enable HTTP probe'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.trigger).toHaveBeenCalledWith({
|
||||||
|
http_ranges: ['8000-8100', '9000'],
|
||||||
|
http_probe_enabled: true,
|
||||||
|
verify_tls: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-opens deep-scan section when a default probe is enabled', async () => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue({
|
||||||
|
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: false },
|
||||||
|
} as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
|
||||||
|
expect(screen.getByDisplayValue('7000-7100')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saving keeps deep-scan defaults untouched (modal only overrides per-scan)', async () => {
|
||||||
|
vi.mocked(scanApi.getConfig).mockResolvedValue({
|
||||||
|
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: true },
|
||||||
|
} as never)
|
||||||
|
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||||
|
await screen.findByDisplayValue('192.168.1.0/24')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
http_ranges: ['7000-7100'],
|
||||||
|
http_probe_enabled: true,
|
||||||
|
verify_tls: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ScanHistoryModal } from '../ScanHistoryModal'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
|
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
|
||||||
|
}))
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
stop: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const RUNNING_RUN = {
|
||||||
|
id: 'run-1',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 2,
|
||||||
|
started_at: new Date(Date.now() - 5000).toISOString(),
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONE_RUN = {
|
||||||
|
id: 'run-2',
|
||||||
|
status: 'done',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 3,
|
||||||
|
started_at: new Date(Date.now() - 60000).toISOString(),
|
||||||
|
finished_at: new Date(Date.now() - 30000).toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANCELLED_RUN = {
|
||||||
|
id: 'run-3',
|
||||||
|
status: 'cancelled',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 1,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZIGBEE_RUN = {
|
||||||
|
id: 'run-4',
|
||||||
|
status: 'done',
|
||||||
|
kind: 'zigbee',
|
||||||
|
ranges: [],
|
||||||
|
devices_found: 7,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZWAVE_RUN = {
|
||||||
|
id: 'run-5',
|
||||||
|
status: 'done',
|
||||||
|
kind: 'zwave',
|
||||||
|
ranges: [],
|
||||||
|
devices_found: 5,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal() {
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<ScanHistoryModal open onClose={vi.fn()} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScanHistoryModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
vi.mocked(scanApi.stop).mockReset()
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads runs when opened', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
||||||
|
expect(await screen.findByText('done')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no scans', async () => {
|
||||||
|
renderModal()
|
||||||
|
expect(await screen.findByText('No scans yet')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows stop button only for running scans', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Stop scan' })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls scanApi.stop with the correct run ID', async () => {
|
||||||
|
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
|
fireEvent.click(stopBtn)
|
||||||
|
await waitFor(() => expect(scanApi.stop).toHaveBeenCalledWith('run-1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success toast when stop succeeds', async () => {
|
||||||
|
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
|
fireEvent.click(stopBtn)
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Scan stop requested'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when stop fails', async () => {
|
||||||
|
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
|
fireEvent.click(stopBtn)
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to stop scan'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cancelled status without a stop button', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
|
||||||
|
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows duration for a finished run', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
// DONE_RUN ran 30s
|
||||||
|
expect(await screen.findByText('30s')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by status', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Running' }))
|
||||||
|
expect(screen.queryByText('done')).toBeNull()
|
||||||
|
expect(screen.getByText('running')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by kind', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZIGBEE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||||
|
// Only the zigbee run (7 found) remains
|
||||||
|
expect(screen.getByText('7 found')).toBeDefined()
|
||||||
|
expect(screen.queryByText('3 found')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by zwave kind', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZWAVE_RUN] } as never)
|
||||||
|
renderModal()
|
||||||
|
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||||
|
// Only the zwave run (5 found) remains
|
||||||
|
expect(screen.getByText('5 found')).toBeDefined()
|
||||||
|
expect(screen.queryByText('3 found')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,8 +17,8 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
describe('SettingsModal', () => {
|
describe('SettingsModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
})
|
})
|
||||||
@@ -47,7 +47,7 @@ describe('SettingsModal', () => {
|
|||||||
fireEvent.change(input, { target: { value: '180' } })
|
fireEvent.change(input, { target: { value: '180' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
|
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 })
|
||||||
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -76,6 +76,22 @@ describe('SettingsModal', () => {
|
|||||||
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads and toggles the per-service check setting, saving its interval', async () => {
|
||||||
|
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never)
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement
|
||||||
|
// The toggle label renders immediately; its checked state only flips once
|
||||||
|
// settingsApi.get() resolves, so wait for that before asserting.
|
||||||
|
await waitFor(() => expect(toggle.checked).toBe(true))
|
||||||
|
expect(await screen.findByDisplayValue('600')).toBeDefined()
|
||||||
|
|
||||||
|
fireEvent.click(toggle) // disable
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onClose on Cancel', async () => {
|
it('calls onClose on Cancel', async () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SettingsModal open onClose={onClose} />)
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { createElement, useState } from 'react'
|
import { createElement, useRef, useState } from 'react'
|
||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
import { splitIps } from '@/utils/maskIp'
|
import { splitIps } from '@/utils/maskIp'
|
||||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
|
import { formatTimestamp } from '@/utils/timeFormat'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
@@ -21,7 +22,8 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
|
|||||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
|
||||||
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||||
|
|
||||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||||
@@ -64,6 +66,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
node={node}
|
node={node}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
onUngroup={() => { ungroup(node.id) }}
|
onUngroup={() => { ungroup(node.id) }}
|
||||||
|
onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }}
|
||||||
|
onChangeDescription={(value) => updateNode(node.id, { notes: value })}
|
||||||
|
onSnapshotBeforeEdit={snapshotHistory}
|
||||||
onToggleBorder={() => {
|
onToggleBorder={() => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
updateNode(node.id, {
|
updateNode(node.id, {
|
||||||
@@ -248,9 +253,19 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
{data.last_seen && <DetailRow label="Last Seen" value={formatTimestamp(data.last_seen)} />}
|
||||||
|
{data.last_scan && <DetailRow label="Last Scan" value={formatTimestamp(data.last_scan)} />}
|
||||||
|
{data.created_at && <DetailRow label="Created" value={formatTimestamp(data.created_at)} />}
|
||||||
|
{data.updated_at && <DetailRow label="Last Modified" value={formatTimestamp(data.updated_at)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Size section — manual width/height entry for pixel-exact sizing */}
|
||||||
|
<SizeFields
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
onCommit={(size) => { snapshotHistory(); setNodeSize(node.id, size) }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Properties section */}
|
{/* Properties section */}
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -314,7 +329,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
editingIndex === i ? (
|
editingIndex === i ? (
|
||||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||||
) : (
|
) : (
|
||||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,6 +356,79 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Size fields (manual width/height entry) ---
|
||||||
|
|
||||||
|
const round = (v: number | undefined): string => (v != null ? String(Math.round(v)) : '')
|
||||||
|
|
||||||
|
function SizeFields({ node, onCommit }: { node: Node<NodeData>; onCommit: (size: { width?: number; height?: number }) => void }) {
|
||||||
|
// Live size from the explicit dimension, falling back to the DOM-measured
|
||||||
|
// size so the field shows the node's current footprint even before resize.
|
||||||
|
const liveWidth = round(node.width ?? node.measured?.width)
|
||||||
|
const liveHeight = round(node.height ?? node.measured?.height)
|
||||||
|
const [width, setWidth] = useState(liveWidth)
|
||||||
|
const [height, setHeight] = useState(liveHeight)
|
||||||
|
|
||||||
|
// Resync the fields when the node is resized by dragging its corner handle
|
||||||
|
// (which mutates node.width/height in the store). Adjusting state during
|
||||||
|
// render — the React-blessed alternative to an effect — keeps it in step
|
||||||
|
// without cascading renders. The field the user is actively editing is left
|
||||||
|
// alone so we don't clobber their keystrokes mid-type.
|
||||||
|
const [editing, setEditing] = useState<'width' | 'height' | null>(null)
|
||||||
|
const [prev, setPrev] = useState({ w: liveWidth, h: liveHeight })
|
||||||
|
if (prev.w !== liveWidth || prev.h !== liveHeight) {
|
||||||
|
setPrev({ w: liveWidth, h: liveHeight })
|
||||||
|
if (editing !== 'width') setWidth(liveWidth)
|
||||||
|
if (editing !== 'height') setHeight(liveHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = (raw: string, axis: 'width' | 'height') => {
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!raw.trim() || Number.isNaN(n) || n <= 0) {
|
||||||
|
// Reset the field to the live value on invalid input — no mutation.
|
||||||
|
if (axis === 'width') setWidth(round(node.width ?? node.measured?.width))
|
||||||
|
else setHeight(round(node.height ?? node.measured?.height))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onCommit({ [axis]: n })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 border-t border-border">
|
||||||
|
<span className="text-xs text-muted-foreground">Size</span>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<label className="flex flex-1 items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 w-3">W</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={140}
|
||||||
|
aria-label="Width"
|
||||||
|
value={width}
|
||||||
|
onFocus={() => setEditing('width')}
|
||||||
|
onChange={(e) => setWidth(e.target.value)}
|
||||||
|
onBlur={() => { setEditing(null); commit(width, 'width') }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||||
|
className="h-7 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-1 items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 w-3">H</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
aria-label="Height"
|
||||||
|
value={height}
|
||||||
|
onFocus={() => setEditing('height')}
|
||||||
|
onChange={(e) => setHeight(e.target.value)}
|
||||||
|
onBlur={() => { setEditing(null); commit(height, 'height') }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||||
|
className="h-7 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Multi-select panel ---
|
// --- Multi-select panel ---
|
||||||
|
|
||||||
interface MultiSelectPanelProps {
|
interface MultiSelectPanelProps {
|
||||||
@@ -425,17 +513,33 @@ interface GroupDetailPanelProps {
|
|||||||
node: Node<NodeData>
|
node: Node<NodeData>
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
onUngroup: () => void
|
onUngroup: () => void
|
||||||
|
onRemoveChild: (id: string) => void
|
||||||
|
onChangeDescription: (value: string) => void
|
||||||
|
onSnapshotBeforeEdit: () => void
|
||||||
onToggleBorder: () => void
|
onToggleBorder: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelectChild: (id: string) => void
|
onSelectChild: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onSnapshotBeforeEdit, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
||||||
const children = nodes.filter((n) => n.parentId === node.id)
|
const children = nodes.filter((n) => n.parentId === node.id)
|
||||||
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||||
const showBorder = node.data.custom_colors?.show_border !== false
|
const showBorder = node.data.custom_colors?.show_border !== false
|
||||||
|
|
||||||
|
// Description reuses data.notes, which already round-trips to the backend.
|
||||||
|
// Controlled + committed on every keystroke so ANY save path (incl. Ctrl+S,
|
||||||
|
// which never blurs the field) captures it. History is snapshotted once at the
|
||||||
|
// start of an edit session so the whole edit is a single undo step.
|
||||||
|
const snappedRef = useRef(false)
|
||||||
|
const handleDescriptionChange = (value: string) => {
|
||||||
|
if (!snappedRef.current) {
|
||||||
|
onSnapshotBeforeEdit()
|
||||||
|
snappedRef.current = true
|
||||||
|
}
|
||||||
|
onChangeDescription(value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleUngroup = () => {
|
const handleUngroup = () => {
|
||||||
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
|
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
|
||||||
onUngroup()
|
onUngroup()
|
||||||
@@ -461,20 +565,48 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
|||||||
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<label htmlFor="group-description" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="group-description"
|
||||||
|
value={node.data.notes ?? ''}
|
||||||
|
onFocus={() => { snappedRef.current = false }}
|
||||||
|
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Add a description for this group…"
|
||||||
|
rows={3}
|
||||||
|
className="mt-1.5 w-full resize-y rounded-md bg-[#21262d] border border-[#30363d] px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Children list */}
|
{/* Children list */}
|
||||||
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
||||||
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<button
|
<div
|
||||||
key={child.id}
|
key={child.id}
|
||||||
onClick={() => onSelectChild(child.id)}
|
className="group/member w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors"
|
||||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
|
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
<button
|
||||||
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
onClick={() => onSelectChild(child.id)}
|
||||||
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
className="flex items-center gap-2 min-w-0 flex-1 text-left cursor-pointer"
|
||||||
</button>
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
||||||
|
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveChild(child.id)}
|
||||||
|
aria-label={`Remove ${child.data.label} from group`}
|
||||||
|
title="Remove from group"
|
||||||
|
className="shrink-0 opacity-0 group-hover/member:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -673,9 +805,13 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
// 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`.
|
||||||
|
const categoryColor = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e')
|
||||||
|
// A live offline service overrides the category colour with red.
|
||||||
|
const color = status === 'offline' ? '#f85149' : categoryColor
|
||||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { designsApi, scanApi } from '@/api/client'
|
import { designsApi } from '@/api/client'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||||
import type { Design } from '@/types'
|
import type { Design } from '@/types'
|
||||||
@@ -14,40 +15,26 @@ import { useLatestRelease } from '@/hooks/useLatestRelease'
|
|||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'history'
|
|
||||||
|
|
||||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'pending', icon: ScanLine, label: 'Device Inventory' },
|
||||||
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ScanRun {
|
|
||||||
id: string
|
|
||||||
status: string
|
|
||||||
kind?: string
|
|
||||||
ranges: string[]
|
|
||||||
devices_found: number
|
|
||||||
started_at: string
|
|
||||||
finished_at: string | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
onAddText: () => void
|
onAddText: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
|
onZwaveImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
forceView?: SidebarView
|
onOpenHistory: () => void
|
||||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||||
@@ -57,11 +44,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
if (!designModal) return
|
if (!designModal) return
|
||||||
try {
|
try {
|
||||||
if (designModal.mode === 'create') {
|
if (designModal.mode === 'create') {
|
||||||
const res = await designsApi.create({ name: data.name, icon: data.icon })
|
const created = STANDALONE
|
||||||
addDesign(res.data)
|
? standaloneStorage.createDesign(data.name, data.icon)
|
||||||
|
: (await designsApi.create({ name: data.name, icon: data.icon })).data
|
||||||
|
addDesign(created)
|
||||||
} else if (designModal.design) {
|
} else if (designModal.design) {
|
||||||
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })
|
const updated = STANDALONE
|
||||||
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon })
|
? standaloneStorage.updateDesign(designModal.design.id, { name: data.name, icon: data.icon })
|
||||||
|
: (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data
|
||||||
|
if (updated) updateDesign(updated.id, { name: updated.name, icon: updated.icon })
|
||||||
}
|
}
|
||||||
setDesignModal(null)
|
setDesignModal(null)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,7 +64,11 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
||||||
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
|
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
|
||||||
try {
|
try {
|
||||||
await designsApi.delete(d.id)
|
if (STANDALONE) {
|
||||||
|
standaloneStorage.deleteDesign(d.id)
|
||||||
|
} else {
|
||||||
|
await designsApi.delete(d.id)
|
||||||
|
}
|
||||||
removeDesign(d.id)
|
removeDesign(d.id)
|
||||||
toast.success('Canvas deleted')
|
toast.success('Canvas deleted')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -81,15 +76,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
}
|
}
|
||||||
}, [designs.length, removeDesign])
|
}, [designs.length, removeDesign])
|
||||||
|
|
||||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
|
||||||
if (forceView !== prevForceView) {
|
|
||||||
setPrevForceView(forceView)
|
|
||||||
if (forceView) {
|
|
||||||
setActiveView(forceView)
|
|
||||||
setCollapsed(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
@@ -193,8 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
label="Canvas"
|
label="Canvas"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === 'canvas'}
|
active
|
||||||
onClick={() => setActiveView('canvas')}
|
|
||||||
/>
|
/>
|
||||||
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
@@ -210,26 +195,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
icon={Clock}
|
icon={Clock}
|
||||||
label="Scan History"
|
label="Scan History"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === 'history'}
|
onClick={onOpenHistory}
|
||||||
onClick={() => setActiveView('history')}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* View content (only when expanded) */}
|
{!collapsed && <div className="flex-1" />}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats (only on canvas view) */}
|
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
|
||||||
{!collapsed && activeView === 'canvas' && (
|
{!collapsed && !STANDALONE && (
|
||||||
<div className="flex-1" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats footer */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
@@ -253,6 +227,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
|
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
@@ -294,136 +269,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function ScanHistoryPanel() {
|
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const prevRunsRef = useRef<ScanRun[]>([])
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.runs()
|
|
||||||
const next: ScanRun[] = res.data
|
|
||||||
|
|
||||||
// Surface transitions and refresh dependent UI
|
|
||||||
for (const run of next) {
|
|
||||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
|
||||||
if (prev?.status === 'running' && run.status === 'error') {
|
|
||||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
|
||||||
}
|
|
||||||
if (prev?.status === 'running' && run.status === 'done') {
|
|
||||||
if (run.kind === 'zigbee') {
|
|
||||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
|
||||||
}
|
|
||||||
// Notify pending modal/canvas to refresh
|
|
||||||
useCanvasStore.getState().notifyScanDeviceFound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevRunsRef.current = next
|
|
||||||
setRuns(next)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load scan history')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
// Auto-refresh every 3s while any run is still running
|
|
||||||
useEffect(() => {
|
|
||||||
const hasRunning = runs.some((r) => r.status === 'running')
|
|
||||||
if (!hasRunning) return
|
|
||||||
const id = setInterval(load, 3000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}, [runs, load])
|
|
||||||
|
|
||||||
const [stopping, setStopping] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleStop = async (runId: string) => {
|
|
||||||
setStopping(runId)
|
|
||||||
try {
|
|
||||||
await scanApi.stop(runId)
|
|
||||||
toast.success('Scan stop requested')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to stop scan')
|
|
||||||
} finally {
|
|
||||||
setStopping(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColor = (s: string) =>
|
|
||||||
s === 'done' ? '#39d353'
|
|
||||||
: s === 'running' ? '#e3b341'
|
|
||||||
: s === 'error' ? '#f85149'
|
|
||||||
: s === 'cancelled' ? '#8b949e'
|
|
||||||
: '#8b949e'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && runs.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
|
|
||||||
)}
|
|
||||||
{runs.map((r) => (
|
|
||||||
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
|
||||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
|
||||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
|
||||||
<span
|
|
||||||
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
|
|
||||||
style={r.kind === 'zigbee'
|
|
||||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
|
||||||
: { background: '#a855f722', color: '#a855f7' }}
|
|
||||||
>
|
|
||||||
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
|
||||||
{r.status === 'running' && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<button
|
|
||||||
aria-label="Stop scan"
|
|
||||||
onClick={() => handleStop(r.id)}
|
|
||||||
disabled={stopping === r.id}
|
|
||||||
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{stopping === r.id
|
|
||||||
? <Loader2 size={11} className="animate-spin" />
|
|
||||||
: <StopCircle size={11} />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">Stop scan</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
|
||||||
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
|
|
||||||
</div>
|
|
||||||
{r.ranges.length > 0 && (
|
|
||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
|
||||||
)}
|
|
||||||
{r.error && (
|
|
||||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
|
|
||||||
{r.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VersionBadge() {
|
function VersionBadge() {
|
||||||
const current = __APP_VERSION__
|
const current = __APP_VERSION__
|
||||||
const { latest, hasUpdate } = useLatestRelease(current)
|
const { latest, hasUpdate } = useLatestRelease(current)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onAutoLayout: () => void
|
onAutoLayout: () => void
|
||||||
@@ -82,9 +84,13 @@ 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={onExportMd} title="Copy inventory as Markdown table">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
<Table2 size={14} /> MD
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
{/* Live view reads backend/localStorage canvas; pointless in standalone
|
||||||
<Eye size={14} /> View
|
where the editor already shows the only (localStorage) copy. */}
|
||||||
</Button>
|
{!STANDALONE && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore'
|
|||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore', async (importActual) => ({
|
||||||
|
...(await importActual<typeof canvasStore>()),
|
||||||
|
useCanvasStore: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||||
return {
|
return {
|
||||||
@@ -22,8 +25,8 @@ function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStore(nodeData: Partial<NodeData> = {}) {
|
function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<string, string> = {}) {
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
const state = {
|
||||||
nodes: [makeNode(nodeData)],
|
nodes: [makeNode(nodeData)],
|
||||||
selectedNodeId: 'n1',
|
selectedNodeId: 'n1',
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
@@ -33,7 +36,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
|
|||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
createGroup: vi.fn(),
|
createGroup: vi.fn(),
|
||||||
ungroup: vi.fn(),
|
ungroup: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
setNodeSize: vi.fn(),
|
||||||
|
serviceStatuses,
|
||||||
|
}
|
||||||
|
// Support both the bare destructure call and the selector-based call.
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||||
|
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('DetailPanel', () => {
|
describe('DetailPanel', () => {
|
||||||
@@ -203,6 +212,37 @@ describe('DetailPanel', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Inventory timestamps', () => {
|
||||||
|
it('renders Created, Last Scan and Last Modified rows when present', () => {
|
||||||
|
setupStore({
|
||||||
|
created_at: '2026-01-02T10:00:00Z',
|
||||||
|
last_scan: '2026-06-01T08:30:00Z',
|
||||||
|
updated_at: '2026-06-20T12:00:00Z',
|
||||||
|
last_seen: '2026-06-25T09:15:00Z',
|
||||||
|
})
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Created')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Last Scan')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Last Modified')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Last Seen')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits rows whose timestamps are absent', () => {
|
||||||
|
setupStore({ created_at: '2026-01-02T10:00:00Z' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Created')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Last Scan')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses naive (suffix-less) UTC timestamps without throwing', () => {
|
||||||
|
setupStore({ created_at: '2026-01-02 10:00:00' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
// Renders a locale string for the date (year is locale-independent).
|
||||||
|
expect(screen.getByText(/2026/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Panel actions', () => {
|
describe('Panel actions', () => {
|
||||||
it('calls setSelectedNode(null) when close button is clicked', () => {
|
it('calls setSelectedNode(null) when close button is clicked', () => {
|
||||||
const setSelectedNode = vi.fn()
|
const setSelectedNode = vi.fn()
|
||||||
@@ -502,6 +542,43 @@ describe('DetailPanel', () => {
|
|||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('colors a categoryless but reachable web service blue, not grey', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const link = screen.getByRole('link', { name: 'nginx' })
|
||||||
|
expect(link.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a categoryless unreachable service grey', () => {
|
||||||
|
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects an explicit category over the url fallback', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paints a service red when its live status is offline', () => {
|
||||||
|
setupStore(
|
||||||
|
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||||
|
{ 'n1:8080/tcp': 'offline' },
|
||||||
|
)
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the category colour when the live status is online', () => {
|
||||||
|
setupStore(
|
||||||
|
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||||
|
{ 'n1:8080/tcp': 'online' },
|
||||||
|
)
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Last Seen formatting', () => {
|
describe('Last Seen formatting', () => {
|
||||||
@@ -526,4 +603,69 @@ describe('DetailPanel', () => {
|
|||||||
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Size section', () => {
|
||||||
|
function setupSized(node: Partial<Node<NodeData>>, setNodeSize = vi.fn()) {
|
||||||
|
const state = {
|
||||||
|
nodes: [{ ...makeNode({}), ...node }],
|
||||||
|
selectedNodeId: 'n1',
|
||||||
|
selectedNodeIds: [],
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
|
setNodeSize,
|
||||||
|
serviceStatuses: {},
|
||||||
|
}
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||||
|
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
|
||||||
|
)
|
||||||
|
return setNodeSize
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the current width/height, preferring explicit over measured', () => {
|
||||||
|
setupSized({ width: 220, height: 130, measured: { width: 999, height: 999 } })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('220')
|
||||||
|
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('130')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the measured size before a resize', () => {
|
||||||
|
setupSized({ measured: { width: 187, height: 73 } })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('187')
|
||||||
|
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('73')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits a manual width on blur', () => {
|
||||||
|
const setNodeSize = setupSized({ width: 200, height: 100 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const w = screen.getByLabelText('Width')
|
||||||
|
fireEvent.change(w, { target: { value: '260' } })
|
||||||
|
fireEvent.blur(w)
|
||||||
|
expect(setNodeSize).toHaveBeenCalledWith('n1', { width: 260 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reverts invalid input without committing', () => {
|
||||||
|
const setNodeSize = setupSized({ width: 200, height: 100 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const w = screen.getByLabelText('Width') as HTMLInputElement
|
||||||
|
fireEvent.change(w, { target: { value: 'abc' } })
|
||||||
|
fireEvent.blur(w)
|
||||||
|
expect(setNodeSize).not.toHaveBeenCalled()
|
||||||
|
expect(w.value).toBe('200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resyncs the field when the node is resized by corner drag', () => {
|
||||||
|
setupSized({ width: 200, height: 100 })
|
||||||
|
const { rerender } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('200')
|
||||||
|
// Simulate a corner-drag resize updating the store dimension.
|
||||||
|
setupSized({ width: 340, height: 100 })
|
||||||
|
rerender(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('340')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const mockStore = {
|
|||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
createGroup: vi.fn(),
|
createGroup: vi.fn(),
|
||||||
ungroup: vi.fn(),
|
ungroup: vi.fn(),
|
||||||
|
removeFromGroup: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStore(overrides = {}) {
|
function setupStore(overrides = {}) {
|
||||||
@@ -230,4 +231,46 @@ describe('GroupDetailPanel', () => {
|
|||||||
fireEvent.click(screen.getByText('Child Node Alpha'))
|
fireEvent.click(screen.getByText('Child Node Alpha'))
|
||||||
expect(setSelectedNode).toHaveBeenCalledWith('c1')
|
expect(setSelectedNode).toHaveBeenCalledWith('c1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes a child from the group via the remove button', () => {
|
||||||
|
const removeFromGroup = vi.fn()
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
|
||||||
|
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], removeFromGroup, snapshotHistory })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /remove router from group/i }))
|
||||||
|
expect(removeFromGroup).toHaveBeenCalledWith('g1', 'c1')
|
||||||
|
expect(snapshotHistory).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the existing group description from notes', () => {
|
||||||
|
const group = makeGroupNode()
|
||||||
|
group.data = { ...group.data, notes: 'Critical DMZ hosts' } as typeof group.data
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe('Critical DMZ hosts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits the description to the store on each change (so Ctrl+S captures it)', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'New notes' } })
|
||||||
|
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapshots history once at the start of an edit, not on every keystroke', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode, snapshotHistory })
|
||||||
|
renderPanel()
|
||||||
|
const textarea = screen.getByLabelText('Description')
|
||||||
|
fireEvent.change(textarea, { target: { value: 'a' } })
|
||||||
|
fireEvent.change(textarea, { target: { value: 'ab' } })
|
||||||
|
fireEvent.change(textarea, { target: { value: 'abc' } })
|
||||||
|
expect(snapshotHistory).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
import { Sidebar } from '../Sidebar'
|
|
||||||
import * as canvasStore from '@/stores/canvasStore'
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
|
||||||
vi.mock('@/api/client', () => ({
|
|
||||||
scanApi: {
|
|
||||||
trigger: vi.fn(),
|
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
stop: vi.fn(),
|
|
||||||
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
|
||||||
},
|
|
||||||
settingsApi: { get: vi.fn(), save: vi.fn() },
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { scanApi } from '@/api/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
const RUNNING_RUN = {
|
|
||||||
id: 'run-1',
|
|
||||||
status: 'running',
|
|
||||||
ranges: ['192.168.1.0/24'],
|
|
||||||
devices_found: 2,
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
finished_at: null,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DONE_RUN = {
|
|
||||||
id: 'run-2',
|
|
||||||
status: 'done',
|
|
||||||
ranges: ['192.168.1.0/24'],
|
|
||||||
devices_found: 3,
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
finished_at: new Date().toISOString(),
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const CANCELLED_RUN = {
|
|
||||||
id: 'run-3',
|
|
||||||
status: 'cancelled',
|
|
||||||
ranges: ['192.168.1.0/24'],
|
|
||||||
devices_found: 1,
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
finished_at: new Date().toISOString(),
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSidebar() {
|
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
|
||||||
nodes: [],
|
|
||||||
hasUnsavedChanges: false,
|
|
||||||
hideIp: false,
|
|
||||||
toggleHideIp: vi.fn(),
|
|
||||||
addNode: vi.fn(),
|
|
||||||
scanEventTs: 0,
|
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<TooltipProvider>
|
|
||||||
<Sidebar
|
|
||||||
onAddNode={vi.fn()}
|
|
||||||
onAddGroupRect={vi.fn()}
|
|
||||||
onScan={vi.fn()}
|
|
||||||
onSave={vi.fn()}
|
|
||||||
onNodeApproved={vi.fn()}
|
|
||||||
/>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openHistory() {
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
|
|
||||||
// Wait for runs to load
|
|
||||||
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ScanHistoryPanel — stop scan', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(toast.success).mockReset()
|
|
||||||
vi.mocked(toast.error).mockReset()
|
|
||||||
vi.mocked(scanApi.stop).mockReset()
|
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows stop button only for running scans', async () => {
|
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
|
||||||
renderSidebar()
|
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
|
||||||
|
|
||||||
// Exactly one stop button rendered (for the running scan only)
|
|
||||||
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
|
|
||||||
expect(stopButtons).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls scanApi.stop with the correct run ID on click', async () => {
|
|
||||||
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
|
||||||
renderSidebar()
|
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
|
||||||
fireEvent.click(stopBtn)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows success toast when stop succeeds', async () => {
|
|
||||||
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
|
||||||
renderSidebar()
|
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
|
||||||
fireEvent.click(stopBtn)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error toast when stop fails', async () => {
|
|
||||||
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
|
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
|
||||||
renderSidebar()
|
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
|
||||||
fireEvent.click(stopBtn)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders cancelled status without stop button or spinner', async () => {
|
|
||||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
|
|
||||||
renderSidebar()
|
|
||||||
await openHistory()
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
|
|
||||||
|
|
||||||
// No stop button
|
|
||||||
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { Sidebar } from '../Sidebar'
|
import { Sidebar } from '../Sidebar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData, Design } from '@/types'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
|
|
||||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -67,10 +68,13 @@ function mockAuth() {
|
|||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
|
onAddText: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
onZigbeeImport: vi.fn(),
|
onZigbeeImport: vi.fn(),
|
||||||
|
onZwaveImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onOpenSettings: vi.fn(),
|
onOpenSettings: vi.fn(),
|
||||||
|
onOpenHistory: vi.fn(),
|
||||||
onOpenPending: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +100,7 @@ describe('Sidebar', () => {
|
|||||||
it('shows all view nav items', () => {
|
it('shows all view nav items', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
expect(screen.getByText('Canvas')).toBeInTheDocument()
|
expect(screen.getByText('Canvas')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
|
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
|
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Scan History')).toBeInTheDocument()
|
expect(screen.getByText('Scan History')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -182,6 +186,12 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calls onZwaveImport when Z-Wave Import is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Z-Wave Import'))
|
||||||
|
expect(defaultProps.onZwaveImport).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onSave when Save Canvas is clicked', () => {
|
it('calls onSave when Save Canvas is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Save Canvas'))
|
fireEvent.click(screen.getByText('Save Canvas'))
|
||||||
@@ -231,9 +241,9 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
it('calls onOpenPending with pending status when Device Inventory is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
fireEvent.click(screen.getByText('Device Inventory'))
|
||||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -243,19 +253,10 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
it('calls onOpenHistory when Scan History nav item is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan History'))
|
fireEvent.click(screen.getByText('Scan History'))
|
||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce()
|
||||||
})
|
|
||||||
|
|
||||||
// Regression: forceView must not freeze local state across rerenders.
|
|
||||||
it('allows switching views after forceView is set by parent', async () => {
|
|
||||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
|
||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
|
||||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
|
||||||
fireEvent.click(screen.getByText('Canvas'))
|
|
||||||
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onOpenSettings when Settings is clicked', () => {
|
it('calls onOpenSettings when Settings is clicked', () => {
|
||||||
@@ -277,3 +278,103 @@ describe('Sidebar', () => {
|
|||||||
expect(mockLogout).toHaveBeenCalledOnce()
|
expect(mockLogout).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Standalone mode ────────────────────────────────────────────────────────────
|
||||||
|
// VITE_STANDALONE is read at module load, so re-import Sidebar after stubbing it.
|
||||||
|
// The hoisted vi.mock auto-mocks re-apply on re-import; configure the fresh mock
|
||||||
|
// instances after the dynamic import.
|
||||||
|
describe('Sidebar (standalone)', () => {
|
||||||
|
const makeDesign = (id: string, name: string): Design => ({
|
||||||
|
id, name, design_type: 'network', icon: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-import Sidebar after stubbing VITE_STANDALONE. Returns the design-store
|
||||||
|
// instance the re-imported Sidebar uses, seeded with `designs` so we can drive
|
||||||
|
// and assert the switcher.
|
||||||
|
async function renderStandalone(nodes: Node<NodeData>[] = [], designs: Design[] = []) {
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const { useCanvasStore: cs } = await import('@/stores/canvasStore')
|
||||||
|
const { useAuthStore: as } = await import('@/stores/authStore')
|
||||||
|
const { useDesignStore: ds } = await import('@/stores/designStore')
|
||||||
|
vi.mocked(cs).mockReturnValue({
|
||||||
|
nodes, hasUnsavedChanges: false, addNode: vi.fn(), scanEventTs: 0,
|
||||||
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
vi.mocked(as).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
|
||||||
|
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
|
||||||
|
)
|
||||||
|
ds.setState({ designs, activeDesignId: designs[0]?.id ?? null, loaded: true })
|
||||||
|
const { Sidebar: SB } = await import('../Sidebar')
|
||||||
|
render(<SB {...defaultProps} />)
|
||||||
|
return ds
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hides the Total/Online/Offline stats footer', async () => {
|
||||||
|
await renderStandalone([makeNode('n1', 'online'), makeNode('n2', 'offline')])
|
||||||
|
expect(screen.queryByText('Total')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Online')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Offline')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides scan-dependent items but keeps canvas actions', async () => {
|
||||||
|
await renderStandalone()
|
||||||
|
expect(screen.queryByText('Scan Network')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Device Inventory')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Logout')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Add Node')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Save Canvas')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a canvas via localStorage (no API) and adds it to the store', async () => {
|
||||||
|
const ds = await renderStandalone([], [makeDesign('d1', 'Main')])
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Main')) // open switcher
|
||||||
|
fireEvent.click(screen.getByText('New Canvas')) // open create modal
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Garage' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||||
|
|
||||||
|
await screen.findByText('Garage')
|
||||||
|
expect(ds.getState().designs.map((d) => d.name)).toContain('Garage')
|
||||||
|
expect(standaloneStorage.listDesigns().map((d) => d.name)).toContain('Garage')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renames a canvas via localStorage (no API)', async () => {
|
||||||
|
standaloneStorage.createDesign('Old')
|
||||||
|
const seeded = standaloneStorage.listDesigns()
|
||||||
|
const ds = await renderStandalone([], seeded)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Old'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Edit Old'))
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Renamed' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
await screen.findByText('Renamed')
|
||||||
|
expect(ds.getState().designs.map((d) => d.name)).toContain('Renamed')
|
||||||
|
expect(standaloneStorage.listDesigns()[0].name).toBe('Renamed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a canvas via localStorage (no API)', async () => {
|
||||||
|
standaloneStorage.createDesign('Keep')
|
||||||
|
standaloneStorage.createDesign('Drop')
|
||||||
|
const seeded = standaloneStorage.listDesigns()
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const ds = await renderStandalone([], seeded)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Keep')) // open switcher
|
||||||
|
fireEvent.click(screen.getByLabelText('Delete Drop'))
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(standaloneStorage.listDesigns().map((d) => d.name)).toEqual(['Keep'])
|
||||||
|
expect(ds.getState().designs.map((d) => d.name)).toEqual(['Keep'])
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { Toolbar } from '../Toolbar'
|
import { Toolbar } from '../Toolbar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -56,4 +56,33 @@ describe('Toolbar', () => {
|
|||||||
fireEvent.click(screen.getByText('Save'))
|
fireEvent.click(screen.getByText('Save'))
|
||||||
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the View (live view) link in full mode', () => {
|
||||||
|
render(<Toolbar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('View')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Standalone mode ────────────────────────────────────────────────────────────
|
||||||
|
// VITE_STANDALONE is read at module load, so re-import Toolbar after stubbing it.
|
||||||
|
describe('Toolbar (standalone)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the View link (live view is pointless without a backend)', async () => {
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const { useCanvasStore: cs } = await import('@/stores/canvasStore')
|
||||||
|
vi.mocked(cs).mockReturnValue({
|
||||||
|
hasUnsavedChanges: false, past: [], future: [],
|
||||||
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
const { Toolbar: TB } = await import('../Toolbar')
|
||||||
|
|
||||||
|
render(<TB {...defaultProps} />)
|
||||||
|
expect(screen.queryByText('View')).not.toBeInTheDocument()
|
||||||
|
// Other actions remain.
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('MD')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"min-h-16 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { RadioTower, Share2, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { zwaveApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { ZwaveNode, ZwaveEdge } from './types'
|
||||||
|
|
||||||
|
interface ZwaveImportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAddToCanvas: (nodes: ZwaveNode[], edges: ZwaveEdge[]) => void
|
||||||
|
onPendingImported?: (
|
||||||
|
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportMode = 'pending' | 'canvas'
|
||||||
|
|
||||||
|
const ACCENT = '#ff6e00'
|
||||||
|
|
||||||
|
interface ConnectionForm {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: string
|
||||||
|
mqtt_username: string
|
||||||
|
mqtt_password: string
|
||||||
|
prefix: string
|
||||||
|
gateway_name: string
|
||||||
|
mqtt_tls: boolean
|
||||||
|
mqtt_tls_insecure: boolean
|
||||||
|
port_user_edited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ConnectionForm = {
|
||||||
|
mqtt_host: '',
|
||||||
|
mqtt_port: '1883',
|
||||||
|
mqtt_username: '',
|
||||||
|
mqtt_password: '',
|
||||||
|
prefix: 'zwave',
|
||||||
|
gateway_name: 'zwavejs2mqtt',
|
||||||
|
mqtt_tls: false,
|
||||||
|
mqtt_tls_insecure: false,
|
||||||
|
port_user_edited: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_TYPE_ICON = {
|
||||||
|
zwave_coordinator: RadioTower,
|
||||||
|
zwave_router: Share2,
|
||||||
|
zwave_enddevice: Cpu,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_LABEL = {
|
||||||
|
zwave_coordinator: 'Controller',
|
||||||
|
zwave_router: 'Router',
|
||||||
|
zwave_enddevice: 'End Device',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_COLOR = {
|
||||||
|
zwave_coordinator: '#ff6e00',
|
||||||
|
zwave_router: '#39d353',
|
||||||
|
zwave_enddevice: '#e3b341',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZwaveImportModalProps) {
|
||||||
|
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||||
|
const [connectionMsg, setConnectionMsg] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [devices, setDevices] = useState<ZwaveNode[]>([])
|
||||||
|
const [edges, setEdges] = useState<ZwaveEdge[]>([])
|
||||||
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||||
|
|
||||||
|
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
[field]: value,
|
||||||
|
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toggleTls = (next: boolean) =>
|
||||||
|
setForm((f) => {
|
||||||
|
const port = f.port_user_edited
|
||||||
|
? f.mqtt_port
|
||||||
|
: next
|
||||||
|
? '8883'
|
||||||
|
: '1883'
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
mqtt_tls: next,
|
||||||
|
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||||
|
mqtt_port: port,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
prefix: form.prefix.trim() || 'zwave',
|
||||||
|
gateway_name: form.gateway_name.trim() || 'zwavejs2mqtt',
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setConnectionStatus('testing')
|
||||||
|
try {
|
||||||
|
const res = await zwaveApi.testConnection({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
if (res.data.connected) {
|
||||||
|
setConnectionStatus('ok')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg('Request failed — check broker address')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractError = (err: unknown): string | undefined => {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchDevices = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (importMode === 'pending') {
|
||||||
|
await zwaveApi.importToPending(buildPayload())
|
||||||
|
toast.success('Z-Wave import started — track progress in Scan History')
|
||||||
|
onPendingImported?.(null)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
const res = await zwaveApi.importNetwork(buildPayload())
|
||||||
|
setDevices(res.data.nodes)
|
||||||
|
setEdges(res.data.edges)
|
||||||
|
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||||
|
if (res.data.device_count === 0) {
|
||||||
|
toast.info('No Z-Wave devices found')
|
||||||
|
} else {
|
||||||
|
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(extractError(err) ?? 'Failed to fetch Z-Wave devices')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCheck = (id: string) =>
|
||||||
|
setChecked((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCanvas = () => {
|
||||||
|
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||||
|
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||||
|
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||||
|
onAddToCanvas(selectedDevices, selectedEdges)
|
||||||
|
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setDevices([])
|
||||||
|
setEdges([])
|
||||||
|
setChecked(new Set())
|
||||||
|
setConnectionStatus('idle')
|
||||||
|
setConnectionMsg('')
|
||||||
|
setImportMode('pending')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedDevices = {
|
||||||
|
zwave_coordinator: devices.filter((d) => d.type === 'zwave_coordinator'),
|
||||||
|
zwave_router: devices.filter((d) => d.type === 'zwave_router'),
|
||||||
|
zwave_enddevice: devices.filter((d) => d.type === 'zwave_enddevice'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<RadioTower size={16} style={{ color: ACCENT }} />
|
||||||
|
Z-Wave Import
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||||
|
{/* Connection Form */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_host}
|
||||||
|
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||||
|
placeholder="192.168.1.x or mqtt.local"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_port}
|
||||||
|
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||||
|
placeholder="1883"
|
||||||
|
type="number"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">MQTT Prefix</Label>
|
||||||
|
<Input
|
||||||
|
value={form.prefix}
|
||||||
|
onChange={(e) => updateField('prefix', e.target.value)}
|
||||||
|
placeholder="zwave"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Gateway Name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.gateway_name}
|
||||||
|
onChange={(e) => updateField('gateway_name', e.target.value)}
|
||||||
|
placeholder="zwavejs2mqtt"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_username}
|
||||||
|
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||||
|
placeholder="mqtt_user"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_password}
|
||||||
|
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls}
|
||||||
|
onChange={(e) => toggleTls(e.target.checked)}
|
||||||
|
className="w-3 h-3 cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
Use TLS (port 8883)
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||||
|
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls_insecure}
|
||||||
|
disabled={!form.mqtt_tls}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
Skip cert verify (self-signed only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status indicator */}
|
||||||
|
{connectionStatus !== 'idle' && (
|
||||||
|
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||||
|
connectionStatus === 'ok'
|
||||||
|
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||||
|
: connectionStatus === 'fail'
|
||||||
|
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||||
|
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||||
|
}`}>
|
||||||
|
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||||
|
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||||
|
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-muted-foreground">Send devices to:</span>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zwave-import-mode"
|
||||||
|
checked={importMode === 'pending'}
|
||||||
|
onChange={() => setImportMode('pending')}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
Pending section
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zwave-import-mode"
|
||||||
|
checked={importMode === 'canvas'}
|
||||||
|
onChange={() => setImportMode('canvas')}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
Canvas directly
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={connectionStatus === 'testing' || loading}
|
||||||
|
>
|
||||||
|
{connectionStatus === 'testing'
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <CheckCircle2 size={13} />}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
style={{ background: ACCENT, color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleFetchDevices}
|
||||||
|
disabled={loading || connectionStatus === 'testing'}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <RadioTower size={13} />}
|
||||||
|
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
|
Make sure the gateway name matches your Z-Wave JS UI configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device List */}
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.size === devices.length}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="w-3 h-3 cursor-pointer"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
title="Select all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Devices ({checked.size}/{devices.length} selected)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZwaveNode[]][])
|
||||||
|
.filter(([, group]) => group.length > 0)
|
||||||
|
.map(([type, group]) => {
|
||||||
|
const Icon = DEVICE_TYPE_ICON[type]
|
||||||
|
const color = DEVICE_TYPE_COLOR[type]
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Icon size={11} style={{ color }} />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||||
|
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||||
|
checked.has(device.id)
|
||||||
|
? 'bg-[#21262d] border-[#ff6e00]/40'
|
||||||
|
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleCheck(device.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.has(device.id)}
|
||||||
|
onChange={() => toggleCheck(device.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
|
||||||
|
style={{ accentColor: ACCENT }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||||
|
{(device.model || device.vendor) && (
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddToCanvas}
|
||||||
|
disabled={checked.size === 0}
|
||||||
|
style={{ background: ACCENT, color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add {checked.size} to Canvas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ZwaveImportModal } from '../ZwaveImportModal'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
zwaveApi: {
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
importNetwork: vi.fn(),
|
||||||
|
importToPending: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
|
||||||
|
import { zwaveApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onAddToCanvas: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleNodes = [
|
||||||
|
{
|
||||||
|
id: 'zwave-0xh-1',
|
||||||
|
label: 'Controller',
|
||||||
|
type: 'zwave_coordinator' as const,
|
||||||
|
ieee_address: 'zwave-0xh-1',
|
||||||
|
friendly_name: 'Controller',
|
||||||
|
device_type: 'Controller',
|
||||||
|
model: null,
|
||||||
|
vendor: null,
|
||||||
|
lqi: null,
|
||||||
|
parent_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zwave-0xh-2',
|
||||||
|
label: 'Wall Plug',
|
||||||
|
type: 'zwave_router' as const,
|
||||||
|
ieee_address: 'zwave-0xh-2',
|
||||||
|
friendly_name: 'Wall Plug',
|
||||||
|
device_type: 'Router',
|
||||||
|
model: 'ZW100',
|
||||||
|
vendor: 'Aeotec',
|
||||||
|
lqi: null,
|
||||||
|
parent_id: 'zwave-0xh-1',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ZwaveImportModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(zwaveApi.testConnection).mockReset()
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockReset()
|
||||||
|
vi.mocked(zwaveApi.importToPending).mockReset()
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
vi.mocked(toast.info).mockReset()
|
||||||
|
defaultProps.onClose.mockReset()
|
||||||
|
defaultProps.onAddToCanvas.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ZwaveImportModal {...defaultProps} open={false} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the modal with prefix and gateway fields when open', () => {
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Z-Wave Import')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zwave')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zwavejs2mqtt')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when testing connection without a host', async () => {
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||||
|
})
|
||||||
|
expect(zwaveApi.testConnection).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success status when connection test passes', async () => {
|
||||||
|
vi.mocked(zwaveApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: true, message: 'Connection successful' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectCanvasMode = () => {
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fetches devices and renders them grouped by type', async () => {
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Controller')).toBeDefined()
|
||||||
|
expect(screen.getByText('Wall Plug')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes prefix and gateway_name to importNetwork', async () => {
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: [], edges: [], device_count: 0 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '10.0.0.5' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('zwave'), { target: { value: 'myzw' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('zwavejs2mqtt'), { target: { value: 'gw1' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(zwaveApi.importNetwork).toHaveBeenCalled())
|
||||||
|
const payload = vi.mocked(zwaveApi.importNetwork).mock.calls[0][0]
|
||||||
|
expect(payload.prefix).toBe('myzw')
|
||||||
|
expect(payload.gateway_name).toBe('gw1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imports to pending by default and notifies parent', async () => {
|
||||||
|
vi.mocked(zwaveApi.importToPending).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 'run-1',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'zwave',
|
||||||
|
ranges: ['192.168.1.100:1883'],
|
||||||
|
devices_found: 0,
|
||||||
|
started_at: '2026-01-01T00:00:00Z',
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
const onPendingImported = vi.fn()
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(zwaveApi.importToPending).toHaveBeenCalled()
|
||||||
|
expect(onPendingImported).toHaveBeenCalled()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(zwaveApi.importNetwork).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||||
|
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [{ source: 'zwave-0xh-1', target: 'zwave-0xh-2' }], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||||
|
target: { value: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Controller'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add.*canvas/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ZwaveImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Shared Z-Wave type definitions for the frontend. */
|
||||||
|
|
||||||
|
export interface ZwaveNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'zwave_coordinator' | 'zwave_router' | 'zwave_enddevice'
|
||||||
|
ieee_address: string
|
||||||
|
friendly_name: string
|
||||||
|
device_type: string
|
||||||
|
model?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
lqi?: number | null
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveImportResponse {
|
||||||
|
nodes: ZwaveNode[]
|
||||||
|
edges: ZwaveEdge[]
|
||||||
|
device_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveTestConnectionRequest {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveTestConnectionResponse {
|
||||||
|
connected: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
|
|||||||
|
|
||||||
const mockUpdateNode = vi.fn()
|
const mockUpdateNode = vi.fn()
|
||||||
const mockNotifyScanDeviceFound = vi.fn()
|
const mockNotifyScanDeviceFound = vi.fn()
|
||||||
|
const mockSetServiceStatuses = vi.fn()
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
static instances: MockWebSocket[] = []
|
static instances: MockWebSocket[] = []
|
||||||
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
|
|||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
updateNode: mockUpdateNode,
|
updateNode: mockUpdateNode,
|
||||||
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||||
|
setServiceStatuses: mockSetServiceStatuses,
|
||||||
} as ReturnType<typeof useCanvasStore>)
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
|
||||||
vi.mocked(useAuthStore).mockReturnValue({
|
vi.mocked(useAuthStore).mockReturnValue({
|
||||||
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
mockUpdateNode.mockClear()
|
mockUpdateNode.mockClear()
|
||||||
mockNotifyScanDeviceFound.mockClear()
|
mockNotifyScanDeviceFound.mockClear()
|
||||||
|
mockSetServiceStatuses.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not open WebSocket when not authenticated', () => {
|
it('does not open WebSocket when not authenticated', () => {
|
||||||
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
|
|||||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('routes service_status messages to setServiceStatuses', () => {
|
||||||
|
renderHook(() => useStatusPolling())
|
||||||
|
const ws = MockWebSocket.instances[0]
|
||||||
|
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||||
|
})
|
||||||
|
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||||
|
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores malformed JSON without throwing', () => {
|
it('ignores malformed JSON without throwing', () => {
|
||||||
renderHook(() => useStatusPolling())
|
renderHook(() => useStatusPolling())
|
||||||
const ws = MockWebSocket.instances[0]
|
const ws = MockWebSocket.instances[0]
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import type { ServiceStatus } from '@/types'
|
||||||
|
|
||||||
|
interface ServiceStatusEntry {
|
||||||
|
port?: number
|
||||||
|
protocol?: string
|
||||||
|
status: ServiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
interface StatusMessage {
|
interface StatusMessage {
|
||||||
type?: string
|
type?: string
|
||||||
@@ -10,13 +17,14 @@ interface StatusMessage {
|
|||||||
response_time_ms?: number | null
|
response_time_ms?: number | null
|
||||||
run_id?: string
|
run_id?: string
|
||||||
devices_found?: number
|
devices_found?: number
|
||||||
|
services?: ServiceStatusEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
export function useStatusPolling() {
|
export function useStatusPolling() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const { updateNode, notifyScanDeviceFound } = useCanvasStore()
|
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||||
const { isAuthenticated, token } = useAuthStore()
|
const { isAuthenticated, token } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,6 +47,8 @@ export function useStatusPolling() {
|
|||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
if (msg.type === 'scan_device_found') {
|
if (msg.type === 'scan_device_found') {
|
||||||
notifyScanDeviceFound()
|
notifyScanDeviceFound()
|
||||||
|
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||||
|
setServiceStatuses(msg.node_id, msg.services)
|
||||||
} else if (msg.node_id && msg.status) {
|
} else if (msg.node_id && msg.status) {
|
||||||
updateNode(msg.node_id, {
|
updateNode(msg.node_id, {
|
||||||
status: msg.status,
|
status: msg.status,
|
||||||
@@ -59,5 +69,5 @@ export function useStatusPolling() {
|
|||||||
ws.close()
|
ws.close()
|
||||||
wsRef.current = null
|
wsRef.current = null
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound])
|
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,36 @@ describe('canvasStore', () => {
|
|||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: { nodes: [], edges: [] },
|
clipboard: { nodes: [], edges: [] },
|
||||||
|
serviceStatuses: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setServiceStatuses stores live status keyed by node/port/protocol', () => {
|
||||||
|
const { setServiceStatuses } = useCanvasStore.getState()
|
||||||
|
setServiceStatuses('node-1', [
|
||||||
|
{ port: 80, protocol: 'tcp', status: 'offline' },
|
||||||
|
{ port: 443, protocol: 'tcp', status: 'online' },
|
||||||
|
])
|
||||||
|
const { serviceStatuses } = useCanvasStore.getState()
|
||||||
|
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
|
||||||
|
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setServiceStatuses merges without dropping other nodes', () => {
|
||||||
|
const { setServiceStatuses } = useCanvasStore.getState()
|
||||||
|
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
|
||||||
|
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
|
||||||
|
const { serviceStatuses } = useCanvasStore.getState()
|
||||||
|
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
|
||||||
|
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark canvas unsaved on a service status update', () => {
|
||||||
|
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||||
|
useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }])
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('setEditingTextId sets and clears editing text id', () => {
|
it('setEditingTextId sets and clears editing text id', () => {
|
||||||
const { setEditingTextId } = useCanvasStore.getState()
|
const { setEditingTextId } = useCanvasStore.getState()
|
||||||
setEditingTextId('t1')
|
setEditingTextId('t1')
|
||||||
@@ -123,6 +150,23 @@ describe('canvasStore', () => {
|
|||||||
expect(nested?.extent).toBe('parent')
|
expect(nested?.extent).toBe('parent')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('addNode strips parentId/extent when the parent is not a container', () => {
|
||||||
|
// Regression: a stray extent:'parent' on a non-container parent traps the
|
||||||
|
// node in the parent's tiny box with no way to drag it out (issue #205).
|
||||||
|
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
|
||||||
|
useCanvasStore.getState().addNode(parent)
|
||||||
|
const trapped: Node<NodeData> = {
|
||||||
|
...makeNode('c1', { parent_id: 'p1' }),
|
||||||
|
position: { x: 150, y: 180 },
|
||||||
|
parentId: 'p1',
|
||||||
|
extent: 'parent',
|
||||||
|
}
|
||||||
|
useCanvasStore.getState().addNode(trapped)
|
||||||
|
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
|
||||||
|
expect(child?.parentId).toBeUndefined()
|
||||||
|
expect(child?.extent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('docker_container nests under docker_host with container_mode on', () => {
|
it('docker_container nests under docker_host with container_mode on', () => {
|
||||||
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
|
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
|
||||||
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
|
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
|
||||||
@@ -487,6 +531,186 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── addToGroup ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('addToGroup nests a top-level node with parent-relative position', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(moved?.parentId).toBe('g1')
|
||||||
|
expect(moved?.extent).toBe('parent')
|
||||||
|
expect(moved?.data.parent_id).toBe('g1')
|
||||||
|
// 300-76=224, 200-52=148
|
||||||
|
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup places the group before the child in the array', () => {
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
// child first to prove reordering
|
||||||
|
useCanvasStore.setState({ nodes: [child, group] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup is a no-op when target is not a group', () => {
|
||||||
|
const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [notGroup, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('s1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup is a no-op when child already belongs to the group', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToGroup snapshots history and marks unsaved', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── addToContainer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('addToContainer nests a top-level node under a container_mode node', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(moved?.parentId).toBe('px1')
|
||||||
|
expect(moved?.extent).toBe('parent')
|
||||||
|
expect(moved?.data.parent_id).toBe('px1')
|
||||||
|
// 300-76=224, 200-52=148
|
||||||
|
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer works for any container_mode type (docker_host)', () => {
|
||||||
|
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [host, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('dh1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer places the container before the child in the array', () => {
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
useCanvasStore.setState({ nodes: [child, container] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer is a no-op when target is not in container_mode', () => {
|
||||||
|
const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [notContainer, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer is a no-op when child already belongs to the container', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer snapshots history and marks unsaved', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const released = nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(released?.parentId).toBeUndefined()
|
||||||
|
expect(released?.extent).toBeUndefined()
|
||||||
|
expect(released?.data.parent_id).toBeUndefined()
|
||||||
|
// 224+76=300, 148+52=200
|
||||||
|
expect(released?.position).toEqual({ x: 300, y: 200 })
|
||||||
|
// group survives
|
||||||
|
expect(nodes.find((n) => n.id === 'g1')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeFromGroup is a no-op when child is not in the group', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeFromGroup snapshots history and marks unsaved', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('updateEdge updates edge data and marks unsaved', () => {
|
it('updateEdge updates edge data and marks unsaved', () => {
|
||||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
@@ -1056,4 +1280,29 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
expect(e.data?.custom_color).toBe('#aabbcc')
|
expect(e.data?.custom_color).toBe('#aabbcc')
|
||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setNodeSize sets explicit width/height and marks unsaved', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1')], hasUnsavedChanges: false })
|
||||||
|
useCanvasStore.getState().setNodeSize('n1', { width: 220, height: 130 })
|
||||||
|
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
|
||||||
|
expect(n.width).toBe(220)
|
||||||
|
expect(n.height).toBe(130)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setNodeSize clamps below the minimum box', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1')] })
|
||||||
|
useCanvasStore.getState().setNodeSize('n1', { width: 10, height: 10 })
|
||||||
|
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
|
||||||
|
expect(n.width).toBe(140)
|
||||||
|
expect(n.height).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setNodeSize updates only the provided axis', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [{ ...makeNode('n1'), width: 200, height: 100 }] })
|
||||||
|
useCanvasStore.getState().setNodeSize('n1', { width: 300 })
|
||||||
|
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
|
||||||
|
expect(n.width).toBe(300)
|
||||||
|
expect(n.height).toBe(100)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
@@ -21,6 +21,10 @@ type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
|||||||
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||||
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||||
|
|
||||||
|
/** Key for the live per-service status overlay. */
|
||||||
|
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
|
||||||
|
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
@@ -28,6 +32,8 @@ interface CanvasState {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
selectedNodeIds: string[]
|
selectedNodeIds: string[]
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
|
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
|
||||||
|
serviceStatuses: Record<string, ServiceStatus>
|
||||||
|
|
||||||
// History
|
// History
|
||||||
past: HistoryEntry[]
|
past: HistoryEntry[]
|
||||||
@@ -55,6 +61,7 @@ interface CanvasState {
|
|||||||
deleteEdge: (id: string) => void
|
deleteEdge: (id: string) => void
|
||||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||||
setNodeZIndex: (id: string, zIndex: number) => void
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
|
setNodeSize: (id: string, size: { width?: number; height?: number }) => void
|
||||||
editingGroupRectId: string | null
|
editingGroupRectId: string | null
|
||||||
setEditingGroupRectId: (id: string | null) => void
|
setEditingGroupRectId: (id: string | null) => void
|
||||||
editingTextId: string | null
|
editingTextId: string | null
|
||||||
@@ -62,12 +69,16 @@ interface CanvasState {
|
|||||||
toggleNodeCollapsed: (id: string) => void
|
toggleNodeCollapsed: (id: string) => void
|
||||||
createGroup: (nodeIds: string[], name: string) => void
|
createGroup: (nodeIds: string[], name: string) => void
|
||||||
ungroup: (groupId: string) => void
|
ungroup: (groupId: string) => void
|
||||||
|
addToGroup: (groupId: string, childId: string) => void
|
||||||
|
addToContainer: (containerId: string, childId: string) => void
|
||||||
|
removeFromGroup: (groupId: string, childId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
fitViewPending: boolean
|
fitViewPending: boolean
|
||||||
clearFitViewPending: () => void
|
clearFitViewPending: () => void
|
||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
|
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
setHideIp: (value: boolean) => void
|
setHideIp: (value: boolean) => void
|
||||||
@@ -86,6 +97,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
hideIp: readHideIp(),
|
hideIp: readHideIp(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
serviceStatuses: {},
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
|
|
||||||
past: [],
|
past: [],
|
||||||
@@ -277,7 +289,9 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
y: Math.max(10, node.position.y - parent.position.y),
|
y: Math.max(10, node.position.y - parent.position.y),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: node
|
// Not nesting: strip any parentId/extent a caller may have set so a
|
||||||
|
// non-container parent can't trap the node in its bounding box.
|
||||||
|
: { ...node, parentId: undefined, extent: undefined }
|
||||||
// Parents must come before children in the array (React Flow requirement)
|
// Parents must come before children in the array (React Flow requirement)
|
||||||
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||||
if (enriched.parentId) {
|
if (enriched.parentId) {
|
||||||
@@ -455,6 +469,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
// Manual width/height entry. Lets the user type an exact size instead of
|
||||||
|
// dragging the resize handle (which lands on fractional content-fit pixels).
|
||||||
|
// A clamp matches the NodeResizer minimums so the box can't collapse.
|
||||||
|
setNodeSize: (id, size) =>
|
||||||
|
set((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => {
|
||||||
|
if (n.id !== id) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
...(size.width != null ? { width: Math.max(140, size.width) } : {}),
|
||||||
|
...(size.height != null ? { height: Math.max(50, size.height) } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
})),
|
||||||
|
|
||||||
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||||
|
|
||||||
setEditingTextId: (id) => set({ editingTextId: id }),
|
setEditingTextId: (id) => set({ editingTextId: id }),
|
||||||
@@ -575,12 +605,137 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Nest an existing top-level node inside a group. Inverse of removeFromGroup.
|
||||||
|
addToGroup: (groupId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const group = state.nodes.find((n) => n.id === groupId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!group || !child || group.data.type !== 'group') return state
|
||||||
|
if (child.id === groupId || child.parentId === groupId) return state
|
||||||
|
|
||||||
|
const updatedNodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: groupId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
// Absolute → group-relative. Clamp so the node stays inside the box.
|
||||||
|
position: {
|
||||||
|
x: Math.max(8, n.position.x - group.position.x),
|
||||||
|
y: Math.max(8, n.position.y - group.position.y),
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
data: { ...n.data, parent_id: groupId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// React Flow requires the parent to precede its children in the array.
|
||||||
|
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||||
|
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||||
|
const groupIdx = others.findIndex((n) => n.id === groupId)
|
||||||
|
const nodes = [
|
||||||
|
...others.slice(0, groupIdx + 1),
|
||||||
|
movedChild,
|
||||||
|
...others.slice(groupIdx + 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Nest an existing top-level node inside a container node (proxmox /
|
||||||
|
// docker_host / … in container_mode). Mirrors addToGroup but the target is
|
||||||
|
// any node with data.container_mode === true rather than a group.
|
||||||
|
addToContainer: (containerId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const container = state.nodes.find((n) => n.id === containerId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!container || !child || container.data.container_mode !== true) return state
|
||||||
|
if (child.id === containerId || child.parentId === containerId) return state
|
||||||
|
|
||||||
|
const updatedNodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: containerId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
// Absolute → container-relative. Clamp so the node stays inside.
|
||||||
|
position: {
|
||||||
|
x: Math.max(8, n.position.x - container.position.x),
|
||||||
|
y: Math.max(8, n.position.y - container.position.y),
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
data: { ...n.data, parent_id: containerId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// React Flow requires the parent to precede its children in the array.
|
||||||
|
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||||
|
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||||
|
const containerIdx = others.findIndex((n) => n.id === containerId)
|
||||||
|
const nodes = [
|
||||||
|
...others.slice(0, containerIdx + 1),
|
||||||
|
movedChild,
|
||||||
|
...others.slice(containerIdx + 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Release a single child from a group back to the canvas. Group stays.
|
||||||
|
removeFromGroup: (groupId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const group = state.nodes.find((n) => n.id === groupId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!group || !child || child.parentId !== groupId) return state
|
||||||
|
|
||||||
|
const nodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
position: {
|
||||||
|
x: n.position.x + group.position.x,
|
||||||
|
y: n.position.y + group.position.y,
|
||||||
|
},
|
||||||
|
data: { ...n.data, parent_id: undefined },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||||
|
|
||||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|
||||||
|
setServiceStatuses: (nodeId, statuses) =>
|
||||||
|
set((state) => {
|
||||||
|
// Live overlay only — never touches node data, so it stays out of saves.
|
||||||
|
const next = { ...state.serviceStatuses }
|
||||||
|
for (const s of statuses) {
|
||||||
|
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
|
||||||
|
}
|
||||||
|
return { serviceStatuses: next }
|
||||||
|
}),
|
||||||
|
|
||||||
toggleHideIp: () => set((s) => {
|
toggleHideIp: () => set((s) => {
|
||||||
const hideIp = !s.hideIp
|
const hideIp = !s.hideIp
|
||||||
writeHideIp(hideIp)
|
writeHideIp(hideIp)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user