Compare commits

...

24 Commits

Author SHA1 Message Date
Pouzor 994ed9d77a feat(api): stats endpoint for gethomepage widget (closes #131)
Adds GET /api/v1/stats/summary, gated by HOMEPAGE_API_KEY env var and
X-API-Key header (hmac.compare_digest, matches the liveview pattern).

Payload:
  nodes / online / offline / unknown
  pending_devices (status='pending')
  zigbee_devices  (Node.ieee_address IS NOT NULL)
  last_scan_at    (max ScanRun.finished_at)

Disabled by default — endpoint returns 403 unless HOMEPAGE_API_KEY is set.
README documents activation and ships a ready-to-paste gethomepage
`customapi` widget snippet.

Tests cover: disabled-by-default, missing header, wrong key, empty DB,
and full aggregation across nodes/pending/zigbee/scan-runs.
2026-05-14 18:11:43 +02:00
Rémy 4d4befa916 Merge pull request #106 from findthelorax/fix/zone-styling
fix: zone styling
2026-05-14 15:58:45 +02:00
Pouzor 8f741691d0 fix(zone-modal): center slider thumb on track, drop dead code, cover a11y
- Center webkit slider thumb on 4px track (margin-top: -4.5px) so the
  opacity bullet no longer sits below the bar.
- Drop redundant inline accentColor; CSS module owns track color.
- Remove dead ::-ms-* prefix rules.
- Tests: assert aria-labels on grid/select triggers, cover getFontLabel
  happy path + unknown-value fallback via the Font selector DOM.
2026-05-14 15:35:19 +02:00
Pouzor c21fbac599 Merge remote-tracking branch 'origin/main' into fix/zone-styling 2026-05-14 14:03:46 +02:00
Rémy e9c66fceda Merge pull request #148 from Pouzor/feat/zigbee-properties
feat(zigbee): IEEE/Vendor/Model/LQI properties + security + tests
2026-05-14 13:47:12 +02:00
Pouzor 373960f6ea test(frontend): cover api client, ProxmoxGroupNode, CustomStyleModal 2026-05-14 13:25:41 +02:00
Pouzor aa17edf1d0 chore(security): drop passlib for direct bcrypt + reject CLI-flag check targets
- Replace passlib.CryptContext with bcrypt 4.2.1 directly (passlib is
  unmaintained and only emitted warnings when paired with bcrypt 4+).
  hash_password / verify_password now call bcrypt.checkpw / .hashpw, and
  verify_password is hardened against empty inputs without raising.
  scripts/hash_password.py + tests/conftest.py updated to match.
- status_checker.check_node() now rejects targets starting with '-' before
  any subprocess invocation, defending ping/tcp paths against arg-injection
  if an admin sets a check_target like '-O'.

Tests added:
- test_auth: expired JWT, malformed JWT, wrong-secret JWT, empty password
  vs empty server hash, verify_password edge cases.
- test_scan: _background_scan failure path marks ScanRun failed, leaves
  non-running terminal status alone, success path invokes run_scan.
- test_status_checker: ping/tcp invocations are bypassed when target or
  ip starts with '-'.

Backend coverage 89% → 90%.
2026-05-14 12:11:40 +02:00
Pouzor 5f87c64dcf fix(zigbee): default IEEE/Vendor/Model/LQI props to hidden, preserve user visibility
- New zigbee props (approve + first-time re-import) ship with visible=false
  so the canvas card stays clean. User opts in from the right panel.
- On re-import of an already-approved node, merge instead of overwrite:
  keys already present keep their visible flag (and any user-edited value
  is replaced with the freshly imported one), brand-new keys are appended
  hidden. Non-zigbee custom properties are preserved untouched.
2026-05-14 01:10:45 +02:00
Pouzor 5822a1483a fix(zigbee): inject IEEE/Vendor/Model/LQI props into approved nodes client-side
Backend already writes the properties on approve, but the frontend's local
addNode() call rebuilt NodeData without them — so newly approved zigbee
nodes showed an empty right panel until a full canvas reload. Build the
property list on the client from the PendingDevice fields so the canvas
state matches the DB row immediately. Single + bulk approve flows updated.
2026-05-14 00:45:58 +02:00
Pouzor 3a303a1376 feat(zigbee): populate IEEE/Vendor/Model/LQI properties on approve and re-import
- Approval (single + bulk) of zigbee pending devices now writes IEEE, Vendor,
  Model, LQI into Node.properties so they show in the right panel.
- Zigbee re-import refreshes properties on existing canvas Nodes and skips
  creating a pending row when the device was already approved — keeps
  approved devices out of pending/hidden modals.
- Coordinator Node also receives the same properties on first creation and
  on re-import.
- Remove the Parent Container selector from the add/edit node modal.
2026-05-14 00:17:13 +02:00
Rémy f5e0e68806 Update README.md 2026-05-13 11:49:14 +02:00
Pouzor 8541922386 bump: version 2.0.3 2026-05-13 11:37:06 +02:00
Rémy 3ed9cb0d4f Merge pull request #146 from Pouzor/fix/zigbee-no-check-method
fix(zigbee): hide check method in modal, force none/online for zigbee nodes
2026-05-13 11:35:10 +02:00
Pouzor fff11a4b6a fix(zigbee): hide check method in modal, force none/online for zigbee nodes
Zigbee nodes have no IP-based check — hide Check Method and Check Target
fields in NodeModal for all three zigbee types. Default and force
check_method='none' so the scheduler marks them always online.
Backend approve and zigbee import routes also set status='online' and
check_method='none' for zigbee node types.
2026-05-13 11:14:35 +02:00
Brett Ferrante 890463373a Merge branch 'Pouzor:main' into fix/zone-styling 2026-05-12 14:40:41 -04:00
Brett Ferrante c90538b1d0 Refactor Select component for font selection 2026-05-04 20:26:05 -04:00
Brett Ferrante 8110ee075d Merge branch 'Pouzor:main' into fix/zone-styling 2026-05-03 19:34:44 -04:00
Brett Ferrante 2994f8653a Fix import statement for GroupRectModal.module.css 2026-05-03 19:16:49 -04:00
Brett Ferrante d5f4a9f729 cleaned up file structure 2026-04-26 23:13:25 -04:00
findthelorax 8859893e42 fixed font casing and increased slider thumb size 2026-04-24 21:47:07 -04:00
findthelorax 93b98f760c changed font casing, revamped opacity slider and improved accessibility with aria labels and keyboard navigation 2026-04-24 21:08:34 -04:00
Brett Ferrante 30ed78c240 Merge branch 'Pouzor:main' into main 2026-04-24 20:41:48 -04:00
Brett Ferrante 69436c438c Merge branch 'Pouzor:main' into main 2026-04-24 13:27:12 -04:00
Brett Ferrante 8c9f1a23e0 Merge branch 'Pouzor:main' into main 2026-04-23 19:40:14 -04:00
32 changed files with 1387 additions and 139 deletions
+6
View File
@@ -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=
+60 -3
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
2.0.2 2.0.3
+18 -8
View File
@@ -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()
+64
View File
@@ -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,
}
+29 -2
View File
@@ -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)
) )
+5
View File
@@ -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"
+6 -6
View File
@@ -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
View File
@@ -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")
+5
View File
@@ -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:
+48
View File
@@ -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 = {
+1 -2
View File
@@ -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
+3 -5
View File
@@ -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"))
+2 -4
View File
@@ -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
View File
@@ -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
+170
View File
@@ -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."""
+100
View File
@@ -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")
+25
View File
@@ -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
+144
View File
@@ -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(
+2 -2
View File
@@ -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 -1
View File
@@ -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",
-6
View File
@@ -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
+210
View File
@@ -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]">
+31 -51
View File
@@ -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')
})
}) })
+23
View File
@@ -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
}