Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 994ed9d77a | |||
| 4d4befa916 | |||
| 8f741691d0 | |||
| c21fbac599 | |||
| e9c66fceda | |||
| 373960f6ea | |||
| aa17edf1d0 | |||
| 5f87c64dcf | |||
| 5822a1483a | |||
| 3a303a1376 | |||
| f5e0e68806 | |||
| 8541922386 | |||
| 3ed9cb0d4f | |||
| fff11a4b6a | |||
| 890463373a | |||
| c90538b1d0 | |||
| 8110ee075d | |||
| 2994f8653a | |||
| d5f4a9f729 | |||
| 8859893e42 | |||
| 93b98f760c | |||
| 30ed78c240 | |||
| 69436c438c | |||
| 8c9f1a23e0 |
@@ -28,3 +28,9 @@ MCP_SERVICE_KEY=svc_changeme
|
|||||||
# Off by default. Set to a random secret to enable.
|
# Off by default. Set to a random secret to enable.
|
||||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# LIVEVIEW_KEY=
|
# LIVEVIEW_KEY=
|
||||||
|
|
||||||
|
# Gethomepage widget — read-only stats at /api/v1/stats/summary
|
||||||
|
# Off by default. Set to a random secret to enable; clients must send
|
||||||
|
# the same value in the `X-API-Key` header.
|
||||||
|
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
# HOMEPAGE_API_KEY=
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# Homelable
|
# Homelable
|
||||||
|
|
||||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
|
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
||||||
|
|
||||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
|
|
||||||
You can also select some pre-built design styles, or personalize each device in your diagram.
|
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
|
If you are running <img width="35" height="35" align="middle" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,8 +18,9 @@ If you just like the design, you can only run the frontend and export your desig
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
|
|
||||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
||||||
|
<img width="48%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/06caab68-6637-4dda-ab16-7e83f63d3972" />
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -131,6 +134,60 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Gethomepage Widget (read-only stats)
|
||||||
|
|
||||||
|
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
Add `HOMEPAGE_API_KEY` to your `.env`:
|
||||||
|
|
||||||
|
`HOMEPAGE_API_KEY=your-secret-key`
|
||||||
|
|
||||||
|
Restart the backend (`docker compose restart backend`).
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": 12,
|
||||||
|
"online": 9,
|
||||||
|
"offline": 2,
|
||||||
|
"unknown": 1,
|
||||||
|
"pending_devices": 3,
|
||||||
|
"zigbee_devices": 5,
|
||||||
|
"last_scan_at": "2026-05-14T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### gethomepage `services.yaml` snippet
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- Homelab:
|
||||||
|
- Homelable:
|
||||||
|
icon: mdi-lan
|
||||||
|
href: http://homelable.local:3000
|
||||||
|
widget:
|
||||||
|
type: customapi
|
||||||
|
url: http://homelable.local:8000/api/v1/stats/summary
|
||||||
|
method: GET
|
||||||
|
headers:
|
||||||
|
X-API-Key: your-secret-key
|
||||||
|
mappings:
|
||||||
|
- field: nodes ; label: Nodes
|
||||||
|
- field: online ; label: Online
|
||||||
|
- field: offline ; label: Offline
|
||||||
|
- field: pending_devices ; label: Pending
|
||||||
|
- field: zigbee_devices ; label: Zigbee
|
||||||
|
- field: last_scan_at ; label: Last scan
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend port (`8000`) must be reachable from your gethomepage container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optional)
|
## MCP Server (AI Integration) (optional)
|
||||||
|
|
||||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
|||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import request_cancel, run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
from app.services.zigbee_service import build_zigbee_properties
|
||||||
|
|
||||||
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||||
|
|
||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
class BulkActionRequest(BaseModel):
|
||||||
@@ -125,17 +128,22 @@ async def bulk_approve_devices(
|
|||||||
created_nodes: list[Node] = []
|
created_nodes: list[Node] = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
|
node_type = device.suggested_type or "generic"
|
||||||
|
is_zigbee = node_type in _ZIGBEE_TYPES
|
||||||
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=device.suggested_type or "generic",
|
type=node_type,
|
||||||
ip=device.ip,
|
ip=device.ip,
|
||||||
hostname=device.hostname,
|
hostname=device.hostname,
|
||||||
status="unknown",
|
status="online" if is_zigbee else "unknown",
|
||||||
services=device.services or [],
|
services=device.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
|
properties=build_zigbee_properties(
|
||||||
|
device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
|
) if is_zigbee else [],
|
||||||
# Default to ping so the status checker actually polls the new node.
|
# 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="ping" if device.ip else None,
|
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
created_nodes.append(node)
|
created_nodes.append(node)
|
||||||
@@ -225,18 +233,20 @@ 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
|
||||||
node = Node(
|
node = Node(
|
||||||
label=node_data.label,
|
label=node_data.label,
|
||||||
type=node_data.type,
|
type=node_data.type,
|
||||||
ip=node_data.ip,
|
ip=node_data.ip,
|
||||||
hostname=node_data.hostname,
|
hostname=node_data.hostname,
|
||||||
status=node_data.status,
|
status="online" if _is_zigbee else node_data.status,
|
||||||
services=node_data.services or [],
|
services=node_data.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
# Honour caller-supplied check_method, else default to ping when an IP exists
|
properties=build_zigbee_properties(
|
||||||
# so the scheduler doesn't silently skip the new node.
|
device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
check_method=node_data.check_method or ("ping" if node_data.ip else None),
|
) if _is_zigbee else (node_data.properties or []),
|
||||||
check_target=node_data.check_target,
|
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||||
|
check_target=None if _is_zigbee else node_data.check_target,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_key(x_api_key: str | None) -> None:
|
||||||
|
if not settings.homepage_api_key:
|
||||||
|
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
|
||||||
|
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def summary(
|
||||||
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Read-only stats payload for the gethomepage `customapi` widget.
|
||||||
|
|
||||||
|
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
|
||||||
|
value in the `X-API-Key` header.
|
||||||
|
"""
|
||||||
|
_check_key(x_api_key)
|
||||||
|
|
||||||
|
status_rows = (
|
||||||
|
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
||||||
|
).all()
|
||||||
|
counts = {row[0]: row[1] for row in status_rows}
|
||||||
|
|
||||||
|
pending = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(PendingDevice)
|
||||||
|
.where(PendingDevice.status == "pending")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
zigbee = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
last_scan_at = (
|
||||||
|
await db.execute(select(func.max(ScanRun.finished_at)))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": sum(counts.values()),
|
||||||
|
"online": counts.get("online", 0),
|
||||||
|
"offline": counts.get("offline", 0),
|
||||||
|
"unknown": counts.get("unknown", 0),
|
||||||
|
"pending_devices": pending,
|
||||||
|
"zigbee_devices": zigbee,
|
||||||
|
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
||||||
|
}
|
||||||
@@ -23,7 +23,12 @@ from app.schemas.zigbee import (
|
|||||||
ZigbeeTestConnectionRequest,
|
ZigbeeTestConnectionRequest,
|
||||||
ZigbeeTestConnectionResponse,
|
ZigbeeTestConnectionResponse,
|
||||||
)
|
)
|
||||||
from app.services.zigbee_service import fetch_networkmap, test_mqtt_connection
|
from app.services.zigbee_service import (
|
||||||
|
build_zigbee_properties,
|
||||||
|
fetch_networkmap,
|
||||||
|
merge_zigbee_properties,
|
||||||
|
test_mqtt_connection,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -142,10 +147,17 @@ async def _persist_pending_import(
|
|||||||
ieee = n.get("ieee_address")
|
ieee = n.get("ieee_address")
|
||||||
if not ieee:
|
if not ieee:
|
||||||
continue
|
continue
|
||||||
|
props = build_zigbee_properties(
|
||||||
|
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
||||||
|
)
|
||||||
|
|
||||||
if n.get("device_type") == "Coordinator":
|
if n.get("device_type") == "Coordinator":
|
||||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
||||||
existing_node = existing.scalar_one_or_none()
|
existing_node = existing.scalar_one_or_none()
|
||||||
if existing_node:
|
if existing_node:
|
||||||
|
existing_node.properties = merge_zigbee_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
coordinator_out = ZigbeeCoordinatorOut(
|
coordinator_out = ZigbeeCoordinatorOut(
|
||||||
id=existing_node.id,
|
id=existing_node.id,
|
||||||
label=existing_node.label,
|
label=existing_node.label,
|
||||||
@@ -157,9 +169,11 @@ async def _persist_pending_import(
|
|||||||
node = Node(
|
node = Node(
|
||||||
label=label,
|
label=label,
|
||||||
type=n.get("type") or "zigbee_coordinator",
|
type=n.get("type") or "zigbee_coordinator",
|
||||||
status="unknown",
|
status="online",
|
||||||
|
check_method="none",
|
||||||
ieee_address=ieee,
|
ieee_address=ieee,
|
||||||
services=[],
|
services=[],
|
||||||
|
properties=props,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -168,6 +182,19 @@ async def _persist_pending_import(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# If the device has already been approved as a canvas Node, refresh
|
||||||
|
# its properties and skip creating a pending row (keeps approved
|
||||||
|
# devices out of pending/hidden modals on re-import).
|
||||||
|
existing_node_q = await db.execute(
|
||||||
|
select(Node).where(Node.ieee_address == ieee)
|
||||||
|
)
|
||||||
|
existing_node = existing_node_q.scalar_one_or_none()
|
||||||
|
if existing_node:
|
||||||
|
existing_node.properties = merge_zigbee_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
|
|||||||
# Leave unset (or empty) to keep the feature disabled (default).
|
# Leave unset (or empty) to keep the feature disabled (default).
|
||||||
liveview_key: str | None = None
|
liveview_key: str | None = None
|
||||||
|
|
||||||
|
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
||||||
|
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
||||||
|
# Leave empty to keep the feature disabled (default).
|
||||||
|
homepage_api_key: str = ""
|
||||||
|
|
||||||
def _override_path(self) -> Path:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
if not plain or not hashed:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
return bool(pwd_context.verify(plain, hashed))
|
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||||
except ValueError:
|
except (ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return str(pwd_context.hash(password))
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str) -> str:
|
def create_access_token(subject: str) -> str:
|
||||||
|
|||||||
+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, edges, liveview, nodes, scan, status, zigbee
|
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
|
||||||
from app.api.routes import settings as settings_routes
|
from app.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
|
||||||
@@ -56,6 +56,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(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
host = target or raw_ip
|
host = target or raw_ip
|
||||||
if not host:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
|
||||||
|
# against arg-injection if a malicious admin sets target like "-O".
|
||||||
|
if host.startswith("-"):
|
||||||
|
logger.warning("Rejecting check target that starts with '-': %r", host)
|
||||||
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -58,6 +58,54 @@ def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def build_zigbee_properties(
|
||||||
|
ieee: str | None,
|
||||||
|
vendor: str | None,
|
||||||
|
model: str | None,
|
||||||
|
lqi: int | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
|
||||||
|
|
||||||
|
Only includes a row when the value is non-empty. Shape matches the
|
||||||
|
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
|
||||||
|
|
||||||
|
New props default to ``visible=False`` — users opt in to showing them on
|
||||||
|
the canvas card from the right panel.
|
||||||
|
"""
|
||||||
|
props: list[dict[str, Any]] = []
|
||||||
|
if ieee:
|
||||||
|
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
|
||||||
|
if vendor:
|
||||||
|
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||||
|
if model:
|
||||||
|
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||||
|
if lqi is not None:
|
||||||
|
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def merge_zigbee_properties(
|
||||||
|
existing: list[dict[str, Any]] | None,
|
||||||
|
new_props: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Merge fresh zigbee props into an existing property list.
|
||||||
|
|
||||||
|
For keys already present: update ``value`` but preserve the user's
|
||||||
|
``visible`` choice. New keys are appended with whatever visibility the
|
||||||
|
caller gave them (hidden by default per ``build_zigbee_properties``).
|
||||||
|
Non-zigbee custom properties are preserved untouched.
|
||||||
|
"""
|
||||||
|
out = [dict(p) for p in (existing or [])]
|
||||||
|
by_key = {p.get("key"): p for p in out}
|
||||||
|
for np in new_props:
|
||||||
|
key = np.get("key")
|
||||||
|
if key in by_key:
|
||||||
|
by_key[key]["value"] = np.get("value")
|
||||||
|
else:
|
||||||
|
out.append(dict(np))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _z2m_type_to_homelable(device_type: str) -> str:
|
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||||
"""Map a Z2M device type string to a homelable node type."""
|
"""Map a Z2M device type string to a homelable node type."""
|
||||||
mapping = {
|
mapping = {
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ alembic==1.13.3
|
|||||||
pydantic==2.9.2
|
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
|
||||||
passlib[bcrypt]==1.7.4
|
bcrypt==4.2.1
|
||||||
bcrypt==4.0.1
|
|
||||||
python-multipart==0.0.27
|
python-multipart==0.0.27
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Generate a bcrypt password hash for config.yml."""
|
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from passlib.context import CryptContext
|
import bcrypt
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("Usage: python scripts/hash_password.py <password>")
|
print("Usage: python scripts/hash_password.py <password>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
password = sys.argv[1]
|
password = sys.argv[1]
|
||||||
print(pwd_context.hash(password))
|
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
|
||||||
|
|||||||
@@ -5,23 +5,21 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from passlib.context import CryptContext
|
|
||||||
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.security import hash_password
|
||||||
from app.db.database import Base, get_db
|
from app.db.database import Base, get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def test_credentials():
|
def test_credentials():
|
||||||
"""Configure test auth credentials directly on settings."""
|
"""Configure test auth credentials directly on settings."""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
settings.auth_username = "admin"
|
settings.auth_username = "admin"
|
||||||
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
settings.auth_password_hash = hash_password("admin")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -68,3 +68,71 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
|
|||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
finally:
|
finally:
|
||||||
settings.auth_password_hash = original
|
settings.auth_password_hash = original
|
||||||
|
|
||||||
|
|
||||||
|
# --- JWT-level cases ---
|
||||||
|
|
||||||
|
async def test_expired_token_rejected(client: AsyncClient):
|
||||||
|
"""A JWT whose `exp` is in the past must be refused."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
payload = {
|
||||||
|
"sub": "admin",
|
||||||
|
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_token_rejected(client: AsyncClient):
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||||
|
"""A token signed with a different key must not be accepted."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
payload = {
|
||||||
|
"sub": "admin",
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_authorization_header_rejected(client: AsyncClient):
|
||||||
|
res = await client.get("/api/v1/nodes")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
|
||||||
|
"""No credentials configured server-side must not authenticate an empty password."""
|
||||||
|
from app.core.config import settings
|
||||||
|
original_hash = settings.auth_password_hash
|
||||||
|
settings.auth_password_hash = ""
|
||||||
|
try:
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
|
||||||
|
assert res.status_code == 401
|
||||||
|
finally:
|
||||||
|
settings.auth_password_hash = original_hash
|
||||||
|
|
||||||
|
|
||||||
|
# --- Password helper ---
|
||||||
|
|
||||||
|
def test_verify_password_handles_empty_inputs():
|
||||||
|
"""verify_password must be safe against empty plain / empty hash without raising."""
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
h = hash_password("hunter2")
|
||||||
|
assert verify_password("hunter2", h) is True
|
||||||
|
assert verify_password("", h) is False
|
||||||
|
assert verify_password("hunter2", "") is False
|
||||||
|
assert verify_password("", "") is False
|
||||||
|
|||||||
@@ -37,6 +37,95 @@ async def pending_device(db_session):
|
|||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# --- _background_scan error handling ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db():
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||||
|
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||||
|
session rollback path must execute without a follow-on exception."""
|
||||||
|
from app.api.routes.scan import _background_scan
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
run_id = run.id
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
|
patch(
|
||||||
|
"app.api.routes.scan.run_scan",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
refreshed = await session.get(ScanRun, run_id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||||
|
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||||
|
must NOT overwrite that terminal status with 'failed'."""
|
||||||
|
from app.api.routes.scan import _background_scan
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
run_id = run.id
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
|
patch(
|
||||||
|
"app.api.routes.scan.run_scan",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
refreshed = await session.get(ScanRun, run_id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.status == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
||||||
|
from app.api.routes.scan import _background_scan
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
run_id = run.id
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
|
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||||
|
):
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||||
|
mock_run_scan.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
# --- Trigger scan ---
|
# --- Trigger scan ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -528,6 +617,87 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
|
|||||||
assert pending_res.json() == []
|
assert pending_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def zigbee_pending_device(db_session):
|
||||||
|
device = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip=None,
|
||||||
|
mac=None,
|
||||||
|
hostname=None,
|
||||||
|
friendly_name="bulb_1",
|
||||||
|
services=[],
|
||||||
|
suggested_type="zigbee_enddevice",
|
||||||
|
device_subtype="EndDevice",
|
||||||
|
ieee_address="0xABCDEF",
|
||||||
|
vendor="IKEA",
|
||||||
|
model="TRADFRI",
|
||||||
|
lqi=180,
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(device)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(device)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_zigbee_device_populates_properties(
|
||||||
|
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||||
|
):
|
||||||
|
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
payload = {
|
||||||
|
"label": "bulb_1",
|
||||||
|
"type": "zigbee_enddevice",
|
||||||
|
"status": "online",
|
||||||
|
"services": [],
|
||||||
|
"check_method": "none",
|
||||||
|
}
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in node.properties}
|
||||||
|
assert keys == {
|
||||||
|
"IEEE": "0xABCDEF",
|
||||||
|
"Vendor": "IKEA",
|
||||||
|
"Model": "TRADFRI",
|
||||||
|
"LQI": "180",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_zigbee_populates_properties(
|
||||||
|
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": [zigbee_pending_device.id]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in node.properties}
|
||||||
|
assert keys["IEEE"] == "0xABCDEF"
|
||||||
|
assert keys["Vendor"] == "IKEA"
|
||||||
|
assert keys["Model"] == "TRADFRI"
|
||||||
|
assert keys["LQI"] == "180"
|
||||||
|
assert node.check_method == "none"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||||
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_homepage_key():
|
||||||
|
original = settings.homepage_api_key
|
||||||
|
settings.homepage_api_key = ""
|
||||||
|
yield
|
||||||
|
settings.homepage_api_key = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
||||||
|
res = await client.get("/api/v1/stats/summary")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert "disabled" in res.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
res = await client.get("/api/v1/stats/summary")
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
res = await client.get(
|
||||||
|
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_empty_db(client: AsyncClient) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
res = await client.get(
|
||||||
|
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body == {
|
||||||
|
"nodes": 0,
|
||||||
|
"online": 0,
|
||||||
|
"offline": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"pending_devices": 0,
|
||||||
|
"zigbee_devices": 0,
|
||||||
|
"last_scan_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_aggregates_counts(
|
||||||
|
client: AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||||
|
db_session.add_all([
|
||||||
|
Node(type="server", label="A", status="online"),
|
||||||
|
Node(type="server", label="B", status="online"),
|
||||||
|
Node(type="server", label="C", status="offline"),
|
||||||
|
Node(type="server", label="D", status="unknown"),
|
||||||
|
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
||||||
|
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
||||||
|
PendingDevice(ip="10.0.0.1", status="pending"),
|
||||||
|
PendingDevice(ip="10.0.0.2", status="pending"),
|
||||||
|
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||||
|
ScanRun(status="success", finished_at=finished),
|
||||||
|
ScanRun(status="success",
|
||||||
|
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
||||||
|
])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.get(
|
||||||
|
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["nodes"] == 6
|
||||||
|
assert body["online"] == 4
|
||||||
|
assert body["offline"] == 1
|
||||||
|
assert body["unknown"] == 1
|
||||||
|
assert body["pending_devices"] == 2
|
||||||
|
assert body["zigbee_devices"] == 2
|
||||||
|
# SQLite returns naive datetimes; compare prefix only.
|
||||||
|
assert body["last_scan_at"] is not None
|
||||||
|
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|
||||||
@@ -216,6 +216,31 @@ async def test_ping_uses_windows_args_on_win32():
|
|||||||
assert "-c" not in captured["args"]
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- check_node target validation ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_node_rejects_flag_like_target():
|
||||||
|
"""A target starting with '-' must never reach subprocess invocation."""
|
||||||
|
from app.services.status_checker import check_node
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
||||||
|
result = await check_node("ping", "-O", None)
|
||||||
|
|
||||||
|
mock_exec.assert_not_called()
|
||||||
|
assert result["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_node_rejects_flag_like_ip():
|
||||||
|
from app.services.status_checker import check_node
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
||||||
|
result = await check_node("ping", None, "-O")
|
||||||
|
|
||||||
|
mock_exec.assert_not_called()
|
||||||
|
assert result["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -388,6 +388,150 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
|
|||||||
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
|
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
|
||||||
|
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||||
|
nodes_with_meta[0]["vendor"] = "TI"
|
||||||
|
nodes_with_meta[0]["model"] = "CC2652"
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
||||||
|
|
||||||
|
coord = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
|
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
||||||
|
# New zigbee props default to hidden — user opts in from the right panel.
|
||||||
|
assert all(p["visible"] is False for p in coord.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
"""A device already approved as a canvas Node must not reappear in pending.
|
||||||
|
|
||||||
|
Its properties must still be refreshed with the latest Vendor/Model/LQI.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
|
# Simulate: router was approved earlier → exists as a canvas Node.
|
||||||
|
approved = Node(
|
||||||
|
label="router_1",
|
||||||
|
type="zigbee_router",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address="0xR1",
|
||||||
|
services=[],
|
||||||
|
properties=[],
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["lqi"] = 250 # new LQI from re-import
|
||||||
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
# No PendingDevice row was created for the approved router.
|
||||||
|
pendings = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert pendings == []
|
||||||
|
|
||||||
|
# Node properties got refreshed.
|
||||||
|
refreshed = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||||
|
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
|
||||||
|
# Brand-new props on an existing Node start hidden.
|
||||||
|
assert all(p["visible"] is False for p in refreshed.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
|
||||||
|
"""If user has already made props visible, re-import must not flip them back."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
approved = Node(
|
||||||
|
label="router_1",
|
||||||
|
type="zigbee_router",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address="0xR1",
|
||||||
|
services=[],
|
||||||
|
properties=[
|
||||||
|
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
|
||||||
|
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
|
||||||
|
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["lqi"] = 99
|
||||||
|
bumped[1]["model"] = "CC2530"
|
||||||
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
refreshed = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||||
|
).scalar_one()
|
||||||
|
by_key = {p["key"]: p for p in refreshed.properties}
|
||||||
|
# Existing keys keep their visibility (True).
|
||||||
|
assert by_key["IEEE"]["visible"] is True
|
||||||
|
assert by_key["Vendor"]["visible"] is True
|
||||||
|
# New key arrives hidden.
|
||||||
|
assert by_key["Model"]["visible"] is False
|
||||||
|
assert by_key["LQI"]["visible"] is False
|
||||||
|
assert by_key["LQI"]["value"] == "99"
|
||||||
|
# Non-zigbee user-added prop is preserved untouched.
|
||||||
|
assert by_key["Custom"]["value"] == "kept"
|
||||||
|
assert by_key["Custom"]["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[0]["vendor"] = "TI"
|
||||||
|
bumped[0]["model"] = "CC2652"
|
||||||
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
coord = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
|
assert keys["Vendor"] == "TI"
|
||||||
|
assert keys["Model"] == "CC2652"
|
||||||
|
# Newly added keys on re-import default to hidden.
|
||||||
|
by_key = {p["key"]: p for p in coord.properties}
|
||||||
|
assert by_key["Vendor"]["visible"] is False
|
||||||
|
assert by_key["Model"]["visible"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||||
res = await client.post(
|
res = await client.post(
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.13.0",
|
"version": "2.0.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -552,9 +552,6 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentContainerNodes={nodes
|
|
||||||
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
|
||||||
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -565,9 +562,6 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
parentContainerNodes={nodes
|
|
||||||
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
|
||||||
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
type Interceptor<T> = {
|
||||||
|
fulfilled?: (v: T) => T | Promise<T>
|
||||||
|
rejected?: (e: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockInstance {
|
||||||
|
defaults: { baseURL?: string }
|
||||||
|
interceptors: {
|
||||||
|
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||||
|
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||||
|
}
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
post: ReturnType<typeof vi.fn>
|
||||||
|
patch: ReturnType<typeof vi.fn>
|
||||||
|
delete: ReturnType<typeof vi.fn>
|
||||||
|
__req: Interceptor<{ headers: Record<string, string> }>
|
||||||
|
__res: Interceptor<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] }))
|
||||||
|
const instances = hoisted.instances as MockInstance[]
|
||||||
|
|
||||||
|
vi.mock('axios', () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: (cfg: { baseURL?: string }) => {
|
||||||
|
const inst: MockInstance = {
|
||||||
|
defaults: { baseURL: cfg?.baseURL },
|
||||||
|
interceptors: {
|
||||||
|
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
||||||
|
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
||||||
|
},
|
||||||
|
get: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
post: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
__req: {},
|
||||||
|
__res: {},
|
||||||
|
}
|
||||||
|
hoisted.instances.push(inst)
|
||||||
|
return inst
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import * as clientModule from '../client'
|
||||||
|
|
||||||
|
describe('api/client', () => {
|
||||||
|
const mod = clientModule
|
||||||
|
const [api, publicApi] = instances
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||||
|
api.get.mockClear()
|
||||||
|
api.post.mockClear()
|
||||||
|
api.patch.mockClear()
|
||||||
|
api.delete.mockClear()
|
||||||
|
publicApi.get.mockClear()
|
||||||
|
publicApi.post.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates two axios instances with /api/v1 baseURL', () => {
|
||||||
|
expect(instances).toHaveLength(2)
|
||||||
|
expect(api.defaults.baseURL).toBe('/api/v1')
|
||||||
|
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports `api` matching the first created instance', () => {
|
||||||
|
expect(mod.api).toBe(api)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('request interceptor adds Authorization header when token present', () => {
|
||||||
|
useAuthStore.setState({ token: 'tok-123', isAuthenticated: true })
|
||||||
|
const cfg = { headers: {} as Record<string, string> }
|
||||||
|
const out = api.__req.fulfilled!(cfg)
|
||||||
|
expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('request interceptor leaves headers untouched when no token', () => {
|
||||||
|
const cfg = { headers: {} as Record<string, string> }
|
||||||
|
const out = api.__req.fulfilled!(cfg)
|
||||||
|
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor passes through 2xx responses', () => {
|
||||||
|
const r = { status: 200, data: { ok: true } }
|
||||||
|
expect(api.__res.fulfilled!(r)).toBe(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor calls logout on 401', async () => {
|
||||||
|
const logout = vi.spyOn(useAuthStore.getState(), 'logout')
|
||||||
|
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
||||||
|
const err = { response: { status: 401 } }
|
||||||
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||||
|
expect(logout).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor does not call logout on non-401', async () => {
|
||||||
|
const logout = vi.fn()
|
||||||
|
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
||||||
|
const err = { response: { status: 500 } }
|
||||||
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||||
|
expect(logout).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor handles error with no response object', async () => {
|
||||||
|
const logout = vi.fn()
|
||||||
|
useAuthStore.setState({ logout })
|
||||||
|
const err = { message: 'network down' }
|
||||||
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||||
|
expect(logout).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publicApi has no request/response interceptors registered', () => {
|
||||||
|
expect(publicApi.__req.fulfilled).toBeUndefined()
|
||||||
|
expect(publicApi.__res.fulfilled).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authApi.login posts to /auth/login', () => {
|
||||||
|
mod.authApi.login('u', 'p')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('canvasApi.load GETs /canvas', () => {
|
||||||
|
mod.canvasApi.load()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/canvas')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('canvasApi.save POSTs to /canvas/save with payload', () => {
|
||||||
|
const payload = { nodes: [], edges: [], viewport: {} }
|
||||||
|
mod.canvasApi.save(payload)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/canvas/save', payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nodesApi CRUD calls correct endpoints', () => {
|
||||||
|
mod.nodesApi.create({ a: 1 })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 })
|
||||||
|
mod.nodesApi.update('n1', { b: 2 })
|
||||||
|
expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 })
|
||||||
|
mod.nodesApi.delete('n1')
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/nodes/n1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edgesApi CRUD calls correct endpoints', () => {
|
||||||
|
mod.edgesApi.create({ s: 'a', t: 'b' })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' })
|
||||||
|
mod.edgesApi.delete('e1')
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/edges/e1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liveviewApi.load uses publicApi with key param', () => {
|
||||||
|
mod.liveviewApi.load('k-1')
|
||||||
|
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } })
|
||||||
|
expect(api.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scanApi endpoints route correctly', () => {
|
||||||
|
mod.scanApi.trigger()
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||||
|
mod.scanApi.pending()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/pending')
|
||||||
|
mod.scanApi.hidden()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/hidden')
|
||||||
|
mod.scanApi.runs()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/runs')
|
||||||
|
mod.scanApi.clearPending()
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/scan/pending')
|
||||||
|
mod.scanApi.approve('d1', { foo: 'bar' })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' })
|
||||||
|
mod.scanApi.hide('d1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide')
|
||||||
|
mod.scanApi.ignore('d1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
||||||
|
mod.scanApi.bulkApprove(['a', 'b'])
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
|
||||||
|
mod.scanApi.bulkHide(['a'])
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
||||||
|
mod.scanApi.restore('d1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore')
|
||||||
|
mod.scanApi.bulkRestore(['a'])
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] })
|
||||||
|
mod.scanApi.stop('run-1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop')
|
||||||
|
mod.scanApi.getConfig()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/config')
|
||||||
|
mod.scanApi.saveConfig({ ranges: ['1.0/24'] })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settingsApi get/save', () => {
|
||||||
|
mod.settingsApi.get()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/settings')
|
||||||
|
mod.settingsApi.save({ interval_seconds: 30 })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||||
|
const cfg = { mqtt_host: 'h', mqtt_port: 1883 }
|
||||||
|
mod.zigbeeApi.testConnection(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg)
|
||||||
|
mod.zigbeeApi.importNetwork(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg)
|
||||||
|
mod.zigbeeApi.importToPending(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { ProxmoxGroupNode } from '../ProxmoxGroupNode'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import type { NodeData, NodeProperty } from '@/types'
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||||
|
const fullData: NodeData = {
|
||||||
|
label: 'pve-01',
|
||||||
|
type: 'proxmox',
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
id: 'p1',
|
||||||
|
data: fullData,
|
||||||
|
selected,
|
||||||
|
type: 'proxmox',
|
||||||
|
zIndex: 0,
|
||||||
|
isConnectable: true,
|
||||||
|
xPos: 0,
|
||||||
|
yPos: 0,
|
||||||
|
dragging: false,
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
dragHandle: undefined,
|
||||||
|
parentId: undefined,
|
||||||
|
sourcePosition: undefined,
|
||||||
|
targetPosition: undefined,
|
||||||
|
} as unknown as NodeProps<Node<NodeData>>
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<ProxmoxGroupNode {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProxmoxGroupNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the node label', () => {
|
||||||
|
const { getByText } = renderNode({ label: 'My Proxmox' })
|
||||||
|
expect(getByText('My Proxmox')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders ip when provided', () => {
|
||||||
|
const { getByText } = renderNode({ ip: '192.168.1.10' })
|
||||||
|
expect(getByText('192.168.1.10')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders multiple ips when comma separated', () => {
|
||||||
|
const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' })
|
||||||
|
expect(getByText('10.0.0.1')).toBeDefined()
|
||||||
|
expect(getByText('10.0.0.2')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masks ip when hideIp is enabled in store', () => {
|
||||||
|
useCanvasStore.setState({ hideIp: true })
|
||||||
|
const { queryByText } = renderNode({ ip: '192.168.1.10' })
|
||||||
|
expect(queryByText('192.168.1.10')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders visible properties only', () => {
|
||||||
|
const properties: NodeProperty[] = [
|
||||||
|
{ key: 'CPU', value: '16 cores', icon: null, visible: true },
|
||||||
|
{ key: 'Hidden', value: 'should-not-show', icon: null, visible: false },
|
||||||
|
]
|
||||||
|
const { getByText, queryByText } = renderNode({ properties })
|
||||||
|
expect(getByText('CPU')).toBeDefined()
|
||||||
|
expect(getByText(/16 cores/)).toBeDefined()
|
||||||
|
expect(queryByText('Hidden')).toBeNull()
|
||||||
|
expect(queryByText(/should-not-show/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders status dot with title matching status', () => {
|
||||||
|
const { container } = renderNode({ status: 'offline' })
|
||||||
|
const dot = container.querySelector('[title="offline"]')
|
||||||
|
expect(dot).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
|
||||||
|
const { container } = renderNode({ container_mode: false })
|
||||||
|
// NodeResizer should not be present when not group-rendered
|
||||||
|
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container_mode default renders the group border container', () => {
|
||||||
|
const { container } = renderNode({})
|
||||||
|
// Group border div has rounded-xl border-2 classes
|
||||||
|
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cluster handles in both modes', () => {
|
||||||
|
const { container: groupC } = renderNode({})
|
||||||
|
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||||
|
const { container: nodeC } = renderNode({ container_mode: false })
|
||||||
|
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* Slider container: strip native chrome so custom track/thumb align cleanly */
|
||||||
|
.slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
height: 13px; /* match thumb height so vertical centering is the input's box center */
|
||||||
|
}
|
||||||
|
.slider-thumb:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
.slider-accent::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
background: #00d4ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.slider-accent::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
background: #00d4ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb — must offset on webkit so it centers on the 4px track */
|
||||||
|
.slider-thumb::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00d4ff;
|
||||||
|
border: 2px solid #21262d;
|
||||||
|
margin-top: -4.5px; /* (13 - 4) / 2 */
|
||||||
|
}
|
||||||
|
.slider-thumb::-moz-range-thumb {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00d4ff;
|
||||||
|
border: 2px solid #21262d;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import type { TextPosition } from '@/types'
|
import type { TextPosition } from '@/types'
|
||||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
import styles from './GroupRectModal.module.css'
|
||||||
|
|
||||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
@@ -88,6 +89,8 @@ const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
|
|||||||
{ value: 'bottom-right', label: '↘' },
|
{ value: 'bottom-right', label: '↘' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const getFontLabel = (value: string) => FONTS.find((f) => f.value === value)?.label ?? value
|
||||||
|
|
||||||
interface GroupRectModalProps {
|
interface GroupRectModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -138,8 +141,10 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Font selector">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{getFontLabel(form.font)}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{FONTS.map((f) => (
|
{FONTS.map((f) => (
|
||||||
@@ -164,6 +169,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
title={value}
|
title={value}
|
||||||
onClick={() => set('text_position', value)}
|
onClick={() => set('text_position', value)}
|
||||||
className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Text position ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -189,6 +195,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('label_position', value)}
|
onClick={() => set('label_position', value)}
|
||||||
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Label position ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -228,7 +235,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
max={100}
|
max={100}
|
||||||
value={alpha}
|
value={alpha}
|
||||||
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
className={`w-full cursor-pointer mt-2 ${styles['slider-thumb']} ${styles['slider-accent']}`}
|
||||||
title={`Opacity: ${alpha}%`}
|
title={`Opacity: ${alpha}%`}
|
||||||
/>
|
/>
|
||||||
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
@@ -250,6 +257,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('text_size', value)}
|
onClick={() => set('text_size', value)}
|
||||||
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Text size ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -277,6 +285,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
title={label}
|
title={label}
|
||||||
onClick={() => set('border_style', value)}
|
onClick={() => set('border_style', value)}
|
||||||
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Border style ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -303,6 +312,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('border_width', value)}
|
onClick={() => set('border_width', value)}
|
||||||
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Border width ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -320,7 +330,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||||
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`}>
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Z-order selector">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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 CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||||
none: 'None',
|
none: 'None',
|
||||||
@@ -53,13 +54,14 @@ interface NodeModalProps {
|
|||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
|
||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
|
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||||
@@ -91,10 +93,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredParentNodes = form.type === 'docker_container'
|
|
||||||
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
|
||||||
: parentContainerNodes
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
@@ -107,7 +105,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{/* Type + Icon on the same row */}
|
{/* Type + Icon on the same row */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
<Select value={form.type} onValueChange={(v) => {
|
||||||
|
const t = v as NodeType
|
||||||
|
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
|
||||||
|
}}>
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
||||||
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -289,57 +290,36 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check method */}
|
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
||||||
<div className="flex flex-col gap-1.5">
|
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
<div className="flex flex-col gap-1.5">
|
||||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||||
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
||||||
</SelectTrigger>
|
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
|
||||||
{CHECK_METHODS.map((m) => (
|
|
||||||
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Check target */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
|
||||||
<Input
|
|
||||||
value={form.check_target ?? ''}
|
|
||||||
onChange={(e) => set('check_target', e.target.value)}
|
|
||||||
placeholder="http://..."
|
|
||||||
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parent container */}
|
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
|
||||||
<Select
|
|
||||||
value={form.parent_id ?? 'none'}
|
|
||||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
|
||||||
<SelectValue placeholder="None (standalone)">
|
|
||||||
{form.parent_id
|
|
||||||
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
|
||||||
: 'None (standalone)'}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
{CHECK_METHODS.map((m) => (
|
||||||
{filteredParentNodes.map((n) => (
|
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
||||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Check target — hidden for zigbee nodes */}
|
||||||
|
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
||||||
|
<Input
|
||||||
|
value={form.check_target ?? ''}
|
||||||
|
onChange={(e) => set('check_target', e.target.value)}
|
||||||
|
placeholder="http://..."
|
||||||
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Container mode */}
|
{/* Container mode */}
|
||||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||||
<div className="flex items-center justify-between col-span-2 py-1">
|
<div className="flex items-center justify-between col-span-2 py-1">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
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'
|
||||||
|
|
||||||
interface PendingDevicesModalProps {
|
interface PendingDevicesModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -252,13 +253,17 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const handleApprove = async (device: PendingDevice) => {
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
try {
|
try {
|
||||||
const fallbackLabel = deviceLabel(device)
|
const fallbackLabel = deviceLabel(device)
|
||||||
|
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||||
|
const zigbee = isZigbeeType(type)
|
||||||
|
const properties = zigbee ? buildZigbeeProperties(device) : []
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
label: fallbackLabel,
|
label: fallbackLabel,
|
||||||
type: (device.suggested_type ?? 'generic') as NodeType,
|
type,
|
||||||
ip: device.ip ?? undefined,
|
ip: device.ip ?? undefined,
|
||||||
hostname: device.hostname ?? undefined,
|
hostname: device.hostname ?? undefined,
|
||||||
status: 'unknown',
|
status: zigbee ? 'online' : 'unknown',
|
||||||
services: (device.services ?? []) as ServiceInfo[],
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
|
properties,
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -266,7 +271,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: nodeData.type,
|
type: nodeData.type,
|
||||||
position: { x: 400, y: 300 },
|
position: { x: 400, y: 300 },
|
||||||
data: { ...nodeData, status: 'unknown' as const },
|
data: { ...nodeData, status: zigbee ? ('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,17 +315,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
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 zigbee = isZigbeeType(type)
|
||||||
addNode({
|
addNode({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: (d.suggested_type ?? 'generic') as NodeType,
|
type,
|
||||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||||
data: {
|
data: {
|
||||||
label: deviceLabel(d),
|
label: deviceLabel(d),
|
||||||
type: (d.suggested_type ?? 'generic') as NodeType,
|
type,
|
||||||
ip: d.ip ?? undefined,
|
ip: d.ip ?? undefined,
|
||||||
hostname: d.hostname ?? undefined,
|
hostname: d.hostname ?? undefined,
|
||||||
status: 'unknown' as const,
|
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||||
services: (d.services ?? []) as ServiceInfo[],
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
|
properties: zigbee ? buildZigbeeProperties(d) : [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { CustomStyleModal } from '../CustomStyleModal'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
describe('CustomStyleModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } })
|
||||||
|
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<CustomStyleModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders title and tabs', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Custom Style Editor')).toBeDefined()
|
||||||
|
expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined()
|
||||||
|
expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with empty selection placeholder', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText(/Select a node type/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to edges tab and shows the right placeholder', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting a node type opens the node editor', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
expect(screen.getByText(/Apply to existing/)).toBeDefined()
|
||||||
|
expect(screen.getByText('Default size')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined()
|
||||||
|
expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply-to-existing node button calls store and toasts', () => {
|
||||||
|
const applyTypeNodeStyle = vi.fn()
|
||||||
|
useCanvasStore.setState({ applyTypeNodeStyle })
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ }))
|
||||||
|
expect(applyTypeNodeStyle).toHaveBeenCalledOnce()
|
||||||
|
expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router')
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply-to-existing edge button calls store and toasts', () => {
|
||||||
|
const applyTypeEdgeStyle = vi.fn()
|
||||||
|
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||||
|
expect(applyTypeEdgeStyle).toHaveBeenCalledOnce()
|
||||||
|
expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const markUnsaved = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved })
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
|
||||||
|
expect(markUnsaved).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const markUnsaved = vi.fn()
|
||||||
|
const applyAllCustomStyles = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved, applyAllCustomStyles })
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' }))
|
||||||
|
expect(applyAllCustomStyles).toHaveBeenCalledOnce()
|
||||||
|
expect(markUnsaved).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Cancel button closes without saving', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const markUnsaved = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved })
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(markUnsaved).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing path style updates the edge draft', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
const smoothBtn = screen.getByRole('button', { name: 'Smooth' })
|
||||||
|
fireEvent.click(smoothBtn)
|
||||||
|
// The clicked button should now be styled selected (cyan border)
|
||||||
|
expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changing width input updates node draft', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
const widthInputs = screen.getAllByRole('spinbutton')
|
||||||
|
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
||||||
|
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -37,6 +37,16 @@ describe('GroupRectModal', () => {
|
|||||||
expect(submitted.z_order).toBe(1)
|
expect(submitted.z_order).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes aria-labels on grid buttons and select triggers', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByLabelText('Font selector')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Z-order selector')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Text position ↘')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Label position Inside')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Border style Solid')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Border width 1px')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onClose when Cancel is clicked', () => {
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
@@ -308,3 +318,24 @@ describe('GroupRectModal', () => {
|
|||||||
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GroupRectModal font label rendering', () => {
|
||||||
|
it('renders the human font label in the Select trigger (default inter)', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
const trigger = screen.getByLabelText('Font selector')
|
||||||
|
expect(trigger.textContent).toContain('Inter (sans-serif)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to raw value when font is unknown', () => {
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ font: 'comic-sans-9000' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const trigger = screen.getByLabelText('Font selector')
|
||||||
|
expect(trigger.textContent).toContain('comic-sans-9000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -311,47 +311,15 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
// ── Parent Container selector removed ────────────────────────────────
|
||||||
|
|
||||||
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
it('does not render the Parent Container selector', () => {
|
||||||
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
renderModal({ initial: BASE })
|
||||||
|
|
||||||
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
|
|
||||||
renderModal({
|
|
||||||
initial: { ...BASE, type },
|
|
||||||
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
|
||||||
})
|
|
||||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
|
||||||
expect(screen.getByText('Container 01')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
|
|
||||||
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
|
it('does not render Parent Container for docker_container either', () => {
|
||||||
renderModal({ initial: { ...BASE, type } })
|
renderModal({ initial: { ...BASE, type: 'docker_container' } })
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('docker_container shows only docker_host parents', () => {
|
|
||||||
renderModal({
|
|
||||||
initial: { ...BASE, type: 'docker_container' },
|
|
||||||
parentContainerNodes: [
|
|
||||||
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
|
|
||||||
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(screen.getByText('My Docker Host')).toBeDefined()
|
|
||||||
expect(screen.queryByText('My Proxmox')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('docker_container hides Parent Container when no docker_host is available', () => {
|
|
||||||
renderModal({
|
|
||||||
initial: { ...BASE, type: 'docker_container' },
|
|
||||||
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
|
||||||
})
|
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -433,4 +401,24 @@ describe('NodeModal', () => {
|
|||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
expect(slider.value).toBe('48')
|
expect(slider.value).toBe('48')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Zigbee nodes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const zigbeeTypes = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] as const
|
||||||
|
|
||||||
|
it.each(zigbeeTypes)('hides Check Method for %s type', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Check Method')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(zigbeeTypes)('hides Check Target for %s type', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Check Target')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(zigbeeTypes)('submits check_method=none for %s type', (type) => {
|
||||||
|
const { onSubmit } = renderModal({ initial: { ...BASE, type, label: 'Zigbee Node' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NodeProperty, NodeType } from '@/types'
|
||||||
|
|
||||||
|
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
||||||
|
|
||||||
|
export function isZigbeeType(type: NodeType | string | undefined | null): boolean {
|
||||||
|
return !!type && (ZIGBEE_TYPES as string[]).includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the IEEE/Vendor/Model/LQI property rows shown in the right panel.
|
||||||
|
* Matches backend `build_zigbee_properties`. */
|
||||||
|
export function buildZigbeeProperties(input: {
|
||||||
|
ieee_address?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
model?: string | null
|
||||||
|
lqi?: number | null
|
||||||
|
}): NodeProperty[] {
|
||||||
|
const props: NodeProperty[] = []
|
||||||
|
if (input.ieee_address) props.push({ key: 'IEEE', value: input.ieee_address, icon: null, visible: false })
|
||||||
|
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
|
||||||
|
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
|
||||||
|
if (input.lqi != null) props.push({ key: 'LQI', value: String(input.lqi), icon: null, visible: false })
|
||||||
|
return props
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user