Merge branch 'Pouzor:main' into main
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ async def liveview_canvas(
|
|||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, 1)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
|
custom_style=custom_style,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,14 +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.
|
||||||
|
# Without this the scheduler skips it (check_method NULL → no check).
|
||||||
|
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)
|
||||||
@@ -222,14 +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,
|
||||||
|
properties=build_zigbee_properties(
|
||||||
|
device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
|
) if _is_zigbee else (node_data.properties or []),
|
||||||
|
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:
|
||||||
@@ -60,8 +65,14 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
# ping(8) -W flag units differ by OS:
|
||||||
|
# Linux: seconds (-W 1 = 1s)
|
||||||
|
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
||||||
|
# Windows: -w in ms (-w 1000 = 1s)
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
args = ["ping", "-c", "1", "-W", "1000", host]
|
||||||
else:
|
else:
|
||||||
args = ["ping", "-c", "1", "-W", "1", host]
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
|||||||
assert nodes[0]["label"] == "Live Node"
|
assert nodes[0]["label"] == "Live Node"
|
||||||
|
|
||||||
|
|
||||||
|
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||||
|
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||||
|
settings.liveview_key = "test-key"
|
||||||
|
headers = await auth_headers()
|
||||||
|
payload = {
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||||
|
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/liveview?key=test-key")
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["viewport"].get("theme_id") == "matrix"
|
||||||
|
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||||
|
|
||||||
|
|
||||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -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,118 @@ 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
|
||||||
|
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||||
|
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
||||||
|
for n in nodes:
|
||||||
|
if n.ip:
|
||||||
|
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||||
|
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||||
|
assert node is not None
|
||||||
|
assert node.check_method == "ping"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
||||||
ids = [d.id for d in two_pending_devices]
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -169,6 +169,31 @@ async def test_ping_uses_unix_args_on_non_windows():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
assert "-n" not in captured["args"]
|
assert "-n" not in captured["args"]
|
||||||
|
# Linux: -W is in seconds; 1s is the intended timeout
|
||||||
|
w_idx = captured["args"].index("-W")
|
||||||
|
assert captured["args"][w_idx + 1] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_macos_millisecond_timeout():
|
||||||
|
"""macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-c" in captured["args"]
|
||||||
|
assert "-W" in captured["args"]
|
||||||
|
w_idx = captured["args"].index("-W")
|
||||||
|
assert captured["args"][w_idx + 1] == "1000"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -191,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
+65
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.13.0",
|
"version": "2.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.13.0",
|
"version": "2.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -2520,6 +2520,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2534,6 +2537,9 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2548,6 +2554,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2562,6 +2571,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2576,6 +2588,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2590,6 +2605,9 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2604,6 +2622,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2618,6 +2639,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2632,6 +2656,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2646,6 +2673,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2660,6 +2690,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2674,6 +2707,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2688,6 +2724,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2936,6 +2975,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2953,6 +2995,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2970,6 +3015,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2987,6 +3035,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6884,6 +6935,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6905,6 +6959,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6926,6 +6983,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6947,6 +7007,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.13.0",
|
"version": "2.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Regenerate frontend/src/data/dashboardIcons.json from the upstream
|
||||||
|
// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
|
||||||
|
//
|
||||||
|
// node scripts/fetch-dashboard-icons.mjs
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
|
||||||
|
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
|
||||||
|
|
||||||
|
const res = await fetch(TREE_URL)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`fetch failed: ${res.status} ${res.statusText}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const tree = await res.json()
|
||||||
|
const slugs = (tree.svg ?? [])
|
||||||
|
.filter((f) => f.endsWith('.svg'))
|
||||||
|
.map((f) => f.slice(0, -4))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true })
|
||||||
|
writeFileSync(OUT, JSON.stringify(slugs))
|
||||||
|
console.log(`wrote ${slugs.length} slugs → ${OUT}`)
|
||||||
+130
-18
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
|
|||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { ExportModal } from '@/components/modals/ExportModal'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
@@ -21,6 +22,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
|||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
@@ -36,10 +38,9 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
|||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -61,6 +62,7 @@ export default function App() {
|
|||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
|
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
@@ -241,6 +243,67 @@ export default function App() {
|
|||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
|
const handleAddText = useCallback((data: TextFormData) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
|
const newNode: Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
// Text lives in `label` because the API serializer only persists top-level
|
||||||
|
// node fields; text_content is not in the schema and was lost on reload.
|
||||||
|
// TextNode and the edit modal both already fall back to label.
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 250, y: 250 },
|
||||||
|
data: {
|
||||||
|
label: data.text,
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: {
|
||||||
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_size: data.text_size,
|
||||||
|
font: data.font,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
}, [addNode, snapshotHistory])
|
||||||
|
|
||||||
|
const handleUpdateText = useCallback((data: TextFormData) => {
|
||||||
|
if (!editingTextId) return
|
||||||
|
snapshotHistory()
|
||||||
|
const existing = nodes.find((n) => n.id === editingTextId)
|
||||||
|
updateNode(editingTextId, {
|
||||||
|
label: data.text,
|
||||||
|
// Clear stale text_content if present from older builds — label is the
|
||||||
|
// source of truth now.
|
||||||
|
text_content: undefined,
|
||||||
|
custom_colors: {
|
||||||
|
...existing?.data.custom_colors,
|
||||||
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_size: data.text_size,
|
||||||
|
font: data.font,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setEditingTextId(null)
|
||||||
|
}, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory])
|
||||||
|
|
||||||
|
const handleDeleteText = useCallback(() => {
|
||||||
|
if (!editingTextId) return
|
||||||
|
snapshotHistory()
|
||||||
|
deleteNode(editingTextId)
|
||||||
|
setEditingTextId(null)
|
||||||
|
}, [editingTextId, deleteNode, setEditingTextId, snapshotHistory])
|
||||||
|
|
||||||
const handleDeleteGroupRect = useCallback(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -387,16 +450,14 @@ export default function App() {
|
|||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||||
const srcType = src?.data.type as NodeData['type']
|
if (src && tgt) {
|
||||||
const tgtType = tgt?.data.type as NodeData['type']
|
const assignment = resolveVirtualEdgeParent(
|
||||||
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
{ id: src.id, type: src.data.type as NodeData['type'] },
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
|
||||||
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
)
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
if (assignment) {
|
||||||
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
|
updateNode(assignment.childId, { parent_id: assignment.parentId })
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
}
|
||||||
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
|
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
@@ -407,6 +468,11 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
||||||
|
// 'group' uses inline rename (pencil button in header). Opening the
|
||||||
|
// generic NodeModal would clobber the group's height (via the
|
||||||
|
// properties-clears-height rule in updateNode) and lose its children.
|
||||||
|
// 'groupRect' has its own onDoubleClick that already routes to GroupRectModal.
|
||||||
|
if (node.data.type === 'group' || node.data.type === 'groupRect') return
|
||||||
handleEditNode(node.id)
|
handleEditNode(node.id)
|
||||||
}, [handleEditNode])
|
}, [handleEditNode])
|
||||||
|
|
||||||
@@ -443,6 +509,7 @@ export default function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
|
onAddText={() => setAddTextOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -483,9 +550,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentContainerNodes={nodes
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
||||||
.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 */}
|
||||||
@@ -496,9 +561,25 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
parentContainerNodes={nodes
|
parentCandidates={(() => {
|
||||||
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
const descendants = new Set<string>()
|
||||||
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
if (editNodeId) {
|
||||||
|
const queue = [editNodeId]
|
||||||
|
while (queue.length) {
|
||||||
|
const id = queue.shift()!
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.data.parent_id === id && !descendants.has(n.id)) {
|
||||||
|
descendants.add(n.id)
|
||||||
|
queue.push(n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
.filter((n) => !descendants.has(n.id))
|
||||||
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
||||||
|
})()}
|
||||||
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -583,6 +664,37 @@ export default function App() {
|
|||||||
title="Edit Zone"
|
title="Edit Zone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TextModal
|
||||||
|
open={addTextOpen}
|
||||||
|
onClose={() => setAddTextOpen(false)}
|
||||||
|
onSubmit={handleAddText}
|
||||||
|
title="Add Text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextModal
|
||||||
|
key={editingTextId ?? 'text-edit'}
|
||||||
|
open={!!editingTextId}
|
||||||
|
onClose={() => setEditingTextId(null)}
|
||||||
|
onSubmit={handleUpdateText}
|
||||||
|
onDelete={handleDeleteText}
|
||||||
|
initial={(() => {
|
||||||
|
const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null
|
||||||
|
if (!n) return undefined
|
||||||
|
const rc = n.data.custom_colors ?? {}
|
||||||
|
return {
|
||||||
|
text: n.data.text_content ?? n.data.label ?? '',
|
||||||
|
font: rc.font ?? 'inter',
|
||||||
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
|
text_size: rc.text_size ?? 14,
|
||||||
|
border_color: rc.border ?? '#30363d',
|
||||||
|
border_style: (rc.border_style ?? 'none') as TextFormData['border_style'],
|
||||||
|
border_width: rc.border_width ?? 1,
|
||||||
|
background_color: rc.background ?? '#00000000',
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
title="Edit Text"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
|||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData, CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
|
|||||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const setTheme = useThemeStore((s) => s.setTheme)
|
||||||
|
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
// - standalone → always ready (localStorage, no key required)
|
// - standalone → always ready (localStorage, no key required)
|
||||||
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
|
|||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
.map((n: ApiNode) => [n.id, true])
|
||||||
)
|
)
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(
|
loadCanvas(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
|
|||||||
const detail: string = err.response.data?.detail ?? ''
|
const detail: string = err.response.data?.detail ?? ''
|
||||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitViewPending || nodes.length === 0) return
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||||
|
|
||||||
|
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
const nestedPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||||
|
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||||
|
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||||
|
expect(ctr?.parentId).toBe('host')
|
||||||
|
expect(ctr?.extent).toBe('parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||||
|
|
||||||
|
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
const styledPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||||
|
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||||
|
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||||
|
})
|
||||||
|
|
||||||
// ── No editing props passed ───────────────────────────────────────────────
|
// ── No editing props passed ───────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show any Access Denied when key is valid', async () => {
|
it('does not show any Access Denied when key is valid', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useViewport } from '@xyflow/react'
|
||||||
|
import type { Guide } from '@/utils/alignment'
|
||||||
|
|
||||||
|
interface AlignmentGuidesProps {
|
||||||
|
guides: Guide[]
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG overlay that draws alignment guide lines on top of the React Flow canvas.
|
||||||
|
* Coordinates are in canvas (flow) space; we read the viewport transform to
|
||||||
|
* project them into screen space so lines stay locked to nodes when the user
|
||||||
|
* pans or zooms.
|
||||||
|
*/
|
||||||
|
export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
|
||||||
|
const { x: vx, y: vy, zoom } = useViewport()
|
||||||
|
|
||||||
|
if (guides.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 5,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{guides.map((g, i) => {
|
||||||
|
if (g.axis === 'x') {
|
||||||
|
const x = g.position * zoom + vx
|
||||||
|
const y1 = g.start * zoom + vy
|
||||||
|
const y2 = g.end * zoom + vy
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`x-${i}-${g.position}`}
|
||||||
|
x1={x}
|
||||||
|
y1={y1}
|
||||||
|
x2={x}
|
||||||
|
y2={y2}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const y = g.position * zoom + vy
|
||||||
|
const x1 = g.start * zoom + vx
|
||||||
|
const x2 = g.end * zoom + vx
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`y-${i}-${g.position}`}
|
||||||
|
x1={x1}
|
||||||
|
y1={y}
|
||||||
|
x2={x2}
|
||||||
|
y2={y}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes'
|
|||||||
import { nodeTypes } from './nodes/nodeTypes'
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
|
import { AlignmentGuides } from './AlignmentGuides'
|
||||||
|
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -96,6 +100,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
@@ -121,6 +127,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<SearchBar onOpenPending={onOpenPending} />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
|
<AlignmentGuides guides={guides} />
|
||||||
<Controls>
|
<Controls>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onClick={() => setLassoMode((m) => !m)}
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { AlignmentGuides } from '../AlignmentGuides'
|
||||||
|
import type { Guide } from '@/utils/alignment'
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
useViewport: () => ({ x: 50, y: 100, zoom: 2 }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AlignmentGuides', () => {
|
||||||
|
it('renders nothing when no guides', () => {
|
||||||
|
const { container } = render(<AlignmentGuides guides={[]} />)
|
||||||
|
expect(container.querySelector('svg')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects an x-axis guide through the viewport transform', () => {
|
||||||
|
const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }]
|
||||||
|
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||||
|
const line = container.querySelector('line')!
|
||||||
|
// x = position * zoom + vx → 100*2 + 50 = 250
|
||||||
|
expect(line.getAttribute('x1')).toBe('250')
|
||||||
|
expect(line.getAttribute('x2')).toBe('250')
|
||||||
|
// y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500
|
||||||
|
expect(line.getAttribute('y1')).toBe('100')
|
||||||
|
expect(line.getAttribute('y2')).toBe('500')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects a y-axis guide horizontally', () => {
|
||||||
|
const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }]
|
||||||
|
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||||
|
const line = container.querySelector('line')!
|
||||||
|
// y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170
|
||||||
|
expect(line.getAttribute('y1')).toBe('200')
|
||||||
|
expect(line.getAttribute('y2')).toBe('200')
|
||||||
|
expect(line.getAttribute('x1')).toBe('70')
|
||||||
|
expect(line.getAttribute('x2')).toBe('170')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders one line per guide', () => {
|
||||||
|
const guides: Guide[] = [
|
||||||
|
{ axis: 'x', position: 100, start: 0, end: 200 },
|
||||||
|
{ axis: 'y', position: 50, start: 10, end: 60 },
|
||||||
|
]
|
||||||
|
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||||
|
expect(container.querySelectorAll('line')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -44,11 +44,13 @@ vi.mock('@/utils/nodeColors', () => ({
|
|||||||
|
|
||||||
vi.mock('@/utils/nodeIcons', () => ({
|
vi.mock('@/utils/nodeIcons', () => ({
|
||||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||||
|
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/maskIp', () => ({
|
vi.mock('@/utils/maskIp', () => ({
|
||||||
maskIp: (ip: string) => ip,
|
maskIp: (ip: string) => ip,
|
||||||
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||||
|
primaryIp: (ip: string) => ip ? ip.split(',')[0].trim() : '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/propertyIcons', () => ({
|
vi.mock('@/utils/propertyIcons', () => ({
|
||||||
@@ -169,6 +171,49 @@ describe('BaseNode — properties rendering', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BaseNode — services visibility toggle', () => {
|
||||||
|
it('does not render service toggle button on the node', () => {
|
||||||
|
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
|
||||||
|
expect(screen.queryByTitle('Show services')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders service rows when services are toggled on', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
custom_colors: { show_services: true },
|
||||||
|
services: [
|
||||||
|
{ service_name: 'nginx', port: 80, protocol: 'tcp' },
|
||||||
|
{ service_name: 'ssh', port: 22, protocol: 'tcp' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('nginx')).toBeDefined()
|
||||||
|
expect(screen.getByText('80')).toBeDefined()
|
||||||
|
expect(screen.getByText('ssh')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders clickable service links for web services', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
custom_colors: { show_services: true },
|
||||||
|
services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: /nginx/i }) as HTMLAnchorElement
|
||||||
|
expect(link.getAttribute('href')).toBe('http://192.168.1.10:80')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps non-web services as non-clickable rows', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
custom_colors: { show_services: true },
|
||||||
|
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('BaseNode — legacy hardware fallback', () => {
|
describe('BaseNode — legacy hardware fallback', () => {
|
||||||
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||||
renderBaseNode({
|
renderBaseNode({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||||
),
|
),
|
||||||
|
Handle: () => null,
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({}),
|
useReactFlow: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
@@ -179,9 +179,113 @@ function segmentMidpoints(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Endpoint dot (interactive reconnection handle pinned to handle) ──────────
|
||||||
|
|
||||||
|
interface EndpointDotProps {
|
||||||
|
edgeId: string
|
||||||
|
role: 'source' | 'target'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
position?: string
|
||||||
|
color: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
sourceHandle: string | null | undefined
|
||||||
|
targetHandle: string | null | undefined
|
||||||
|
onDrag: (pos: { x: number; y: number } | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive endpoint marker rendered above the node layer (via
|
||||||
|
* EdgeLabelRenderer). On pointerup it inspects the element under the cursor
|
||||||
|
* for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with
|
||||||
|
* the new endpoint. Drop on empty space leaves the edge unchanged.
|
||||||
|
*
|
||||||
|
* Handles are nudged 3px inward (toward the node) because React Flow's edge
|
||||||
|
* endpoint coords sit at the outer edge of the handle box, not its center.
|
||||||
|
*/
|
||||||
|
function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) {
|
||||||
|
const reconnectEdge = useCanvasStore((s) => s.reconnectEdge)
|
||||||
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
const offset = 3
|
||||||
|
let dx = 0, dy = 0
|
||||||
|
if (position === 'bottom') dy = -offset
|
||||||
|
else if (position === 'top') dy = offset
|
||||||
|
else if (position === 'left') dx = offset
|
||||||
|
else if (position === 'right') dx = -offset
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.buttons !== 1) return
|
||||||
|
onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
|
||||||
|
}, [onDrag, screenToFlowPosition])
|
||||||
|
|
||||||
|
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
// Find the topmost handle under cursor, skipping the dragged dot itself.
|
||||||
|
const stack = document.elementsFromPoint(e.clientX, e.clientY)
|
||||||
|
let handleEl: HTMLElement | null = null
|
||||||
|
for (const node of stack) {
|
||||||
|
const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null
|
||||||
|
if (h) { handleEl = h; break }
|
||||||
|
}
|
||||||
|
onDrag(null)
|
||||||
|
if (!handleEl) return // dropped on empty space → keep edge unchanged
|
||||||
|
const newHandleId = handleEl.getAttribute('data-handleid')
|
||||||
|
const newNodeId = handleEl.getAttribute('data-nodeid')
|
||||||
|
if (!newHandleId || !newNodeId) return
|
||||||
|
if (role === 'source') {
|
||||||
|
reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null })
|
||||||
|
} else {
|
||||||
|
reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId })
|
||||||
|
}
|
||||||
|
}, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${x + dx}px, ${y + dy}px)`,
|
||||||
|
width: 15,
|
||||||
|
height: 15,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
border: '2px solid #0d1117',
|
||||||
|
cursor: 'grab',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
zIndex: 1000,
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
title="Drag to reconnect"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main edge component ──────────────────────────────────────────────────────
|
// ── Main edge component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||||
|
const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const sourceX = drag?.role === 'source' ? drag.x : rawSourceX
|
||||||
|
const sourceY = drag?.role === 'source' ? drag.y : rawSourceY
|
||||||
|
const targetX = drag?.role === 'target' ? drag.x : rawTargetX
|
||||||
|
const targetY = drag?.role === 'target' ? drag.y : rawTargetY
|
||||||
|
|
||||||
|
const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||||
|
setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null)
|
||||||
|
}, [])
|
||||||
|
const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||||
|
setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
@@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Endpoint dots — visual indicators for reconnection targets */}
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<EndpointDot
|
||||||
|
edgeId={id}
|
||||||
|
role="source"
|
||||||
|
x={sourceX}
|
||||||
|
y={sourceY}
|
||||||
|
position={sourcePosition}
|
||||||
|
color={strokeColor}
|
||||||
|
source={source}
|
||||||
|
target={target}
|
||||||
|
sourceHandle={sourceHandleId}
|
||||||
|
targetHandle={targetHandleId}
|
||||||
|
onDrag={onSourceDrag}
|
||||||
|
/>
|
||||||
|
<EndpointDot
|
||||||
|
edgeId={id}
|
||||||
|
role="target"
|
||||||
|
x={targetX}
|
||||||
|
y={targetY}
|
||||||
|
position={targetPosition}
|
||||||
|
color={strokeColor}
|
||||||
|
source={source}
|
||||||
|
target={target}
|
||||||
|
sourceHandle={sourceHandleId}
|
||||||
|
targetHandle={targetHandleId}
|
||||||
|
onDrag={onTargetDrag}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Existing waypoint drag handles */}
|
{/* Existing waypoint drag handles */}
|
||||||
{selected && waypoints.map((wp, idx) => {
|
{selected && waypoints.map((wp, idx) => {
|
||||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { createElement, useEffect, useMemo } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||||
|
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -35,6 +37,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
const services = data.services ?? []
|
||||||
|
const showServices = data.custom_colors?.show_services === true
|
||||||
|
const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname
|
||||||
|
|
||||||
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
||||||
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
||||||
@@ -94,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
background: theme.colors.nodeIconBackground,
|
background: theme.colors.nodeIconBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createElement(resolvedIcon, { size: 15 })}
|
{isBrandIconKey(data.custom_icon)
|
||||||
|
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
|
||||||
|
: createElement(resolvedIcon, { size: 15 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label + IP */}
|
{/* Label + IP */}
|
||||||
@@ -138,6 +145,74 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showServices && services.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
|
{services.map((svc, idx) => {
|
||||||
|
const url = getServiceUrl(svc, serviceHost)
|
||||||
|
const row = (
|
||||||
|
<div
|
||||||
|
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: theme.colors.nodeIconBackground,
|
||||||
|
color: theme.colors.nodeSubtextColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||||
|
{/* LEFT: service name */}
|
||||||
|
<span
|
||||||
|
className="font-medium truncate"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
title={svc.service_name}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* RIGHT: path + port */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0 min-w-0">
|
||||||
|
{svc.path && (
|
||||||
|
<span
|
||||||
|
className="truncate text-[#8b949e] text-right max-w-[80px]"
|
||||||
|
title={svc.path}
|
||||||
|
>
|
||||||
|
{svc.path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="font-mono opacity-80 flex items-center gap-1">
|
||||||
|
<span>{svc.port}</span>
|
||||||
|
<ExternalLink
|
||||||
|
size={9}
|
||||||
|
className={`shrink-0 ${url ? '' : 'opacity-0'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!url) return <div key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}>{row}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block hover:opacity-85 transition-opacity"
|
||||||
|
title={url}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||||
{showLegacyHardware && (
|
{showLegacyHardware && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
|
||||||
import { Layers, Pencil, Check, X } from 'lucide-react'
|
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
import { STATUS_COLORS, type NodeData } from '@/types'
|
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||||
|
|
||||||
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const theme = THEMES[activeTheme]
|
||||||
const showBorder = data.custom_colors?.show_border !== false
|
const showBorder = data.custom_colors?.show_border !== false
|
||||||
const isVisible = showBorder || selected
|
const isVisible = showBorder || selected
|
||||||
|
|
||||||
@@ -49,6 +53,29 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
|||||||
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */}
|
||||||
|
{([
|
||||||
|
['group-top', Position.Top],
|
||||||
|
['group-right', Position.Right],
|
||||||
|
['group-bottom', Position.Bottom],
|
||||||
|
['group-left', Position.Left],
|
||||||
|
] as const).map(([hid, pos]) => (
|
||||||
|
<span key={hid}>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={pos}
|
||||||
|
id={hid}
|
||||||
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={pos}
|
||||||
|
id={`${hid}-t`}
|
||||||
|
style={{ opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||||
|
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||||
const { data, selected } = props
|
const { id, data, selected } = props
|
||||||
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
|
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
@@ -87,7 +91,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
background: theme.colors.nodeIconBackground,
|
background: theme.colors.nodeIconBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createElement(resolvedIcon, { size: 12 })}
|
{isBrandIconKey(data.custom_icon)
|
||||||
|
? <NodeIcon typeIcon={Layers} customIconKey={data.custom_icon} size={12} />
|
||||||
|
: createElement(resolvedIcon, { size: 12 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
@@ -146,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
<Handle
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
type="source"
|
const sourceId = bottomHandleId(idx)
|
||||||
position={Position.Bottom}
|
const targetId = `${sourceId}-t`
|
||||||
id="bottom"
|
return (
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
<span key={sourceId}>
|
||||||
/>
|
<Handle
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={sourceId}
|
||||||
|
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={targetId}
|
||||||
|
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Cluster handles */}
|
{/* Cluster handles */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const FONT_FAMILIES: Record<string, string> = {
|
||||||
|
inter: 'Inter, sans-serif',
|
||||||
|
mono: '"JetBrains Mono", monospace',
|
||||||
|
serif: 'Georgia, serif',
|
||||||
|
sans: 'system-ui, sans-serif',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
|
const setEditingTextId = useCanvasStore((s) => s.setEditingTextId)
|
||||||
|
|
||||||
|
const rc = data.custom_colors ?? {}
|
||||||
|
const borderColor = rc.border ?? '#30363d'
|
||||||
|
const borderStyle = rc.border_style ?? 'none'
|
||||||
|
const borderWidth = rc.border_width ?? 1
|
||||||
|
const backgroundColor = rc.background ?? 'transparent'
|
||||||
|
const textColor = rc.text_color ?? '#e6edf3'
|
||||||
|
const textSize: number = rc.text_size ?? 14
|
||||||
|
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||||
|
|
||||||
|
const content = data.text_content ?? data.label ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={40}
|
||||||
|
minHeight={20}
|
||||||
|
handleStyle={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#00d4ff',
|
||||||
|
border: '1px solid #0d1117',
|
||||||
|
}}
|
||||||
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 8,
|
||||||
|
background: backgroundColor,
|
||||||
|
border: borderStyle === 'none' ? 'none' : `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||||
|
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
cursor: 'default',
|
||||||
|
color: textColor,
|
||||||
|
fontFamily,
|
||||||
|
fontSize: textSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingTextId(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { GroupNode } from '../GroupNode'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||||
|
const fullData: NodeData = {
|
||||||
|
label: 'Group A',
|
||||||
|
type: 'group',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
id: 'g1',
|
||||||
|
data: fullData,
|
||||||
|
selected,
|
||||||
|
type: 'group',
|
||||||
|
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>
|
||||||
|
<GroupNode {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ nodes: [], hideIp: false })
|
||||||
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders label', () => {
|
||||||
|
const { getByText } = renderNode({ label: 'My Group' })
|
||||||
|
expect(getByText('My Group')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 4 source handles (one per side)', () => {
|
||||||
|
const { container } = renderNode()
|
||||||
|
expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 4 target handles (one per side)', () => {
|
||||||
|
const { container } = renderNode()
|
||||||
|
expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull()
|
||||||
|
expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('source handles carry side-specific ids', () => {
|
||||||
|
const { container } = renderNode()
|
||||||
|
expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull()
|
||||||
|
expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull()
|
||||||
|
expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull()
|
||||||
|
expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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('container mode renders bottom_handles snap points', () => {
|
||||||
|
const { container } = renderNode({ bottom_handles: 4 })
|
||||||
|
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||||
|
expect(sourceHandles.length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container mode default has single bottom handle', () => {
|
||||||
|
const { container } = renderNode({})
|
||||||
|
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||||
|
expect(sourceHandles.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { TextNode } from '../TextNode'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
function renderNode(data: Partial<NodeData> = {}) {
|
||||||
|
const fullData: NodeData = {
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'Hello',
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
id: 't1',
|
||||||
|
data: fullData,
|
||||||
|
selected: false,
|
||||||
|
type: 'text',
|
||||||
|
zIndex: 0,
|
||||||
|
isConnectable: true,
|
||||||
|
xPos: 0,
|
||||||
|
yPos: 0,
|
||||||
|
dragging: false,
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
dragHandle: undefined,
|
||||||
|
parentId: undefined,
|
||||||
|
sourcePosition: undefined,
|
||||||
|
targetPosition: undefined,
|
||||||
|
} as unknown as NodeProps<Node<NodeData>>
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<TextNode {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TextNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ editingTextId: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders text_content', () => {
|
||||||
|
const { getByText } = renderNode({ text_content: 'My label' })
|
||||||
|
expect(getByText('My label')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to label when text_content is missing', () => {
|
||||||
|
const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' })
|
||||||
|
expect(getByText('Fallback')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('double-click sets editingTextId in store', () => {
|
||||||
|
const { getByText } = renderNode({ text_content: 'Edit me' })
|
||||||
|
fireEvent.doubleClick(getByText('Edit me'))
|
||||||
|
expect(useCanvasStore.getState().editingTextId).toBe('t1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcN
|
|||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
|
import { TextNode } from './TextNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
@@ -24,6 +25,7 @@ export const nodeTypes = {
|
|||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
group: GroupNode,
|
group: GroupNode,
|
||||||
|
text: TextNode,
|
||||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
zigbee_router: ZigbeeRouterNode,
|
zigbee_router: ZigbeeRouterNode,
|
||||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
|
||||||
|
import dashboardIcons from '@/data/dashboardIcons.json'
|
||||||
|
|
||||||
|
const SLUGS: string[] = dashboardIcons as string[]
|
||||||
|
const PAGE = 120
|
||||||
|
|
||||||
|
interface BrandIconPickerProps {
|
||||||
|
value?: string
|
||||||
|
onSelect: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [limit, setLimit] = useState(PAGE)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return SLUGS
|
||||||
|
return SLUGS.filter((s) => s.includes(q))
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const visible = filtered.slice(0, limit)
|
||||||
|
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
|
||||||
|
placeholder={`Search ${SLUGS.length} brand icons...`}
|
||||||
|
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
|
||||||
|
aria-label="Brand icon search"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground/60">
|
||||||
|
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
|
||||||
|
</div>
|
||||||
|
<div className="max-h-52 overflow-y-auto pr-1">
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{visible.map((slug) => {
|
||||||
|
const selected = slug === selectedSlug
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slug}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
|
||||||
|
title={slug}
|
||||||
|
aria-label={slug}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||||
|
selected
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/10'
|
||||||
|
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={brandIconUrl(slug)}
|
||||||
|
alt={slug}
|
||||||
|
loading="lazy"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{filtered.length > limit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLimit((l) => l + PAGE)}
|
||||||
|
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
|
||||||
|
>
|
||||||
|
Load more ({filtered.length - limit} remaining)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
|||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||||
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
||||||
|
Radio, Zap, Lightbulb,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
@@ -21,7 +22,8 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
|||||||
const EDITABLE_NODE_TYPES: NodeType[] = [
|
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||||
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
||||||
'docker_container', 'generic',
|
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
||||||
|
'generic',
|
||||||
]
|
]
|
||||||
|
|
||||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
@@ -30,7 +32,9 @@ const NODE_ICONS: Record<string, LucideIcon> = {
|
|||||||
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||||
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
||||||
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||||
docker_host: Anchor, docker_container: Package, generic: Circle,
|
docker_host: Anchor, docker_container: Package,
|
||||||
|
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
||||||
|
generic: Circle,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default style for a node type (from default theme) ─────────────────────
|
// ── Default style for a node type (from default theme) ─────────────────────
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||||
|
import { BrandIconPicker } from './BrandIconPicker'
|
||||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
|
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
const 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',
|
||||||
@@ -45,22 +49,39 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParentCandidate {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: NodeType
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
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 }[]
|
parentCandidates?: ParentCandidate[]
|
||||||
|
currentNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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', parentCandidates = [], currentNodeId }: 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 [labelError, setLabelError] = useState(false)
|
const [labelError, setLabelError] = useState(false)
|
||||||
|
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||||
|
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||||
|
const hasAppearanceOverrides = Boolean(
|
||||||
|
form.custom_colors?.border
|
||||||
|
|| form.custom_colors?.background
|
||||||
|
|| form.custom_colors?.icon
|
||||||
|
)
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
@@ -74,20 +95,25 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
setLabelError(false)
|
setLabelError(false)
|
||||||
const selectedType = (form.type ?? 'generic') as NodeType
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||||
|
const validParentTypes = getValidParentTypes(selectedType)
|
||||||
|
let safeParentId = form.parent_id
|
||||||
|
if (validParentTypes.length === 0) {
|
||||||
|
safeParentId = undefined
|
||||||
|
} else if (safeParentId) {
|
||||||
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
|
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
||||||
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
|
parent_id: safeParentId,
|
||||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredParentNodes = form.type === 'docker_container'
|
|
||||||
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
|
||||||
: parentContainerNodes
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -97,7 +123,15 @@ 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) => {
|
||||||
|
const next: Partial<NodeData> = { ...f, type: t }
|
||||||
|
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||||
|
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}>
|
||||||
<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>
|
||||||
@@ -144,6 +178,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
if (isBrandIconKey(form.custom_icon)) {
|
||||||
|
const slug = brandIconSlug(form.custom_icon!)
|
||||||
|
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
|
||||||
|
}
|
||||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||||
@@ -159,6 +197,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||||
|
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={iconTab === 'generic'}
|
||||||
|
onClick={() => setIconTab('generic')}
|
||||||
|
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||||
|
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Generic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={iconTab === 'brand'}
|
||||||
|
onClick={() => setIconTab('brand')}
|
||||||
|
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||||
|
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Brand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{iconTab === 'brand' ? (
|
||||||
|
<BrandIconPicker
|
||||||
|
value={form.custom_icon}
|
||||||
|
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Input
|
<Input
|
||||||
value={iconSearch}
|
value={iconSearch}
|
||||||
onChange={(e) => setIconSearch(e.target.value)}
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
@@ -204,6 +273,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -242,77 +313,121 @@ 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parent Container */}
|
||||||
|
{(() => {
|
||||||
|
const childType = (form.type ?? 'generic') as NodeType
|
||||||
|
const validParentTypes = getValidParentTypes(childType)
|
||||||
|
if (validParentTypes.length === 0) return null
|
||||||
|
const validParents = parentCandidates.filter(
|
||||||
|
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
||||||
|
)
|
||||||
|
if (validParents.length === 0) return null
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{form.parent_id
|
||||||
|
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
|
||||||
|
: 'None'}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
<SelectItem value="none" className="text-sm">None</SelectItem>
|
||||||
|
{validParents.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||||
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
Allow other nodes to nest inside this node
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-label="Container Mode"
|
||||||
|
aria-checked={!!form.container_mode}
|
||||||
|
onClick={() => set('container_mode', !form.container_mode)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||||
|
style={{
|
||||||
|
transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service visibility */}
|
||||||
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
|
<div className="flex items-start justify-between col-span-2 py-1">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Show Services</Label>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">Display discovered services on the node card</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={!!form.container_mode}
|
aria-label="Show Services"
|
||||||
onClick={() => set('container_mode', !form.container_mode)}
|
aria-checked={showServicesEnabled}
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
onClick={() => set('custom_colors', {
|
||||||
tabIndex={0}
|
...form.custom_colors,
|
||||||
aria-label="Toggle container mode"
|
show_services: !showServicesEnabled,
|
||||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
})}
|
||||||
|
className="relative inline-flex h-5 w-9 mt-1 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
||||||
|
style={{ background: showServicesEnabled ? resolvedNodeColors.icon : '#30363d' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
className="pointer-events-none absolute top-px left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||||
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
|
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,10 +437,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<div className="flex flex-col gap-2 col-span-2">
|
<div className="flex flex-col gap-2 col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
||||||
{form.custom_colors && (
|
{hasAppearanceOverrides && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('custom_colors', undefined)}
|
onClick={() => setForm((f) => {
|
||||||
|
if (!f.custom_colors) return f
|
||||||
|
const { border, background, icon, ...rest } = f.custom_colors
|
||||||
|
void border
|
||||||
|
void background
|
||||||
|
void icon
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
custom_colors: Object.keys(rest).length > 0 ? rest : undefined,
|
||||||
|
}
|
||||||
|
})}
|
||||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw size={10} /> Reset to defaults
|
<RotateCcw size={10} /> Reset to defaults
|
||||||
@@ -359,9 +484,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!form.custom_colors && (
|
<div className="min-h-3.5">
|
||||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
{!hasAppearanceOverrides && (
|
||||||
)}
|
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Bottom connection points (not for group containers) */}
|
||||||
|
|||||||
@@ -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,272 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
|
||||||
|
export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
|
export interface TextFormData {
|
||||||
|
text: string
|
||||||
|
font: string
|
||||||
|
text_color: string
|
||||||
|
text_size: number
|
||||||
|
border_color: string
|
||||||
|
border_style: TextBorderStyle
|
||||||
|
border_width: number
|
||||||
|
background_color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [
|
||||||
|
{ value: 'none', label: 'None', preview: ' ' },
|
||||||
|
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||||
|
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||||
|
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||||
|
{ value: 'double', label: 'Double', preview: '═══' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 12, label: '12' },
|
||||||
|
{ value: 14, label: '14' },
|
||||||
|
{ value: 18, label: '18' },
|
||||||
|
{ value: 24, label: '24' },
|
||||||
|
{ value: 32, label: '32' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||||
|
{ value: 1, label: '1px' },
|
||||||
|
{ value: 2, label: '2px' },
|
||||||
|
{ value: 3, label: '3px' },
|
||||||
|
{ value: 4, label: '4px' },
|
||||||
|
{ value: 5, label: '5px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||||
|
{ value: 'mono', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
{ value: 'sans', label: 'System Sans' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_FORM: TextFormData = {
|
||||||
|
text: '',
|
||||||
|
font: 'inter',
|
||||||
|
text_color: '#e6edf3',
|
||||||
|
text_size: 14,
|
||||||
|
border_color: '#30363d',
|
||||||
|
border_style: 'none',
|
||||||
|
border_width: 1,
|
||||||
|
background_color: '#00000000',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: TextFormData) => void
|
||||||
|
onDelete?: () => void
|
||||||
|
initial?: Partial<TextFormData>
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) {
|
||||||
|
const [form, setForm] = useState<TextFormData>({ ...DEFAULT_FORM, ...initial })
|
||||||
|
|
||||||
|
const set = <K extends keyof TextFormData>(key: K, value: TextFormData[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(form)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ key: 'text_color' as const, label: 'Text' },
|
||||||
|
{ key: 'border_color' as const, label: 'Border' },
|
||||||
|
{ key: 'background_color' as const, label: 'Background' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||||
|
<textarea
|
||||||
|
value={form.text}
|
||||||
|
onChange={(e) => set('text', e.target.value)}
|
||||||
|
placeholder="Type text…"
|
||||||
|
rows={3}
|
||||||
|
className={`bg-[#21262d] border border-[#30363d] text-sm p-2 resize-y min-h-[60px] focus:outline-none focus:border-[#00d4ff] ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font (Police) */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Police</Label>
|
||||||
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
||||||
|
<SelectValue>
|
||||||
|
{FONTS.find((f) => f.value === form.font)?.label ?? form.font}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
{FONTS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colors */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{colorFields.map(({ key, label }) => {
|
||||||
|
const { hex6, alpha } = hexToRgba(form[key])
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
|
<label
|
||||||
|
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||||
|
style={{ borderColor: '#30363d' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hex6}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||||
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={alpha}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
|
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title={`Opacity: ${alpha}%`}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text size */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Size</Label>
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{TEXT_SIZES.map(({ value, label }) => {
|
||||||
|
const isSelected = form.text_size === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('text_size', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
fontSize: Math.min(value, 16),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Border style */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||||
|
const isSelected = form.border_style === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
onClick={() => set('border_style', value)}
|
||||||
|
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||||
|
<span className="text-[9px]">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Border width (only when style != none) */}
|
||||||
|
{form.border_style !== 'none' && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Width</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_WIDTHS.map(({ value, label }) => {
|
||||||
|
const isSelected = form.border_width === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('border_width', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
|
onClick={() => { onDelete(); onClose() }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
|
{title === 'Add Text' ? 'Add' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -273,53 +273,84 @@ describe('NodeModal', () => {
|
|||||||
|
|
||||||
it('toggles container_mode on click', () => {
|
it('toggles container_mode on click', () => {
|
||||||
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
||||||
fireEvent.click(screen.getByRole('switch'))
|
fireEvent.click(screen.getByRole('switch', { name: 'Container Mode' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Parent container ──────────────────────────────────────────────────
|
// ── Show services toggle (modal-only) ───────────────────────────────
|
||||||
|
|
||||||
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
it('shows Show Services toggle for regular nodes', () => {
|
||||||
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('Show Services')).toBeDefined()
|
||||||
|
expect(screen.getByRole('switch', { name: 'Show Services' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
|
it('hides Show Services toggle for groupRect', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||||
|
expect(screen.queryByText('Show Services')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits custom_colors.show_services=true when toggled on', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(data.custom_colors?.show_services).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps default colors hint visible when Show Services is toggled on', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||||
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show Appearance reset when only Show Services is set', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||||
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Parent Container selector ─────────────────────────────────────────
|
||||||
|
|
||||||
|
it('does not render Parent Container for non-child types', () => {
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type },
|
initial: BASE,
|
||||||
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render Parent Container when no valid candidates exist', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentCandidates: [],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Parent Container for docker_container when docker_host candidate exists', () => {
|
||||||
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }],
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
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) => {
|
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
|
||||||
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
|
|
||||||
renderModal({ initial: { ...BASE, type } })
|
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('docker_container shows only docker_host parents', () => {
|
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type: 'docker_container' },
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
parentContainerNodes: [
|
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
|
||||||
{ 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.getByText('Parent Container')).toBeDefined()
|
||||||
expect(screen.queryByText('My Proxmox')).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('docker_container hides Parent Container when no docker_host is available', () => {
|
it('renders Parent Container for lxc when proxmox candidate exists', () => {
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type: 'docker_container' },
|
initial: { ...BASE, type: 'lxc' },
|
||||||
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
|
||||||
})
|
})
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Appearance ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
@@ -400,4 +431,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,75 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { TextModal, type TextFormData } from '../TextModal'
|
||||||
|
|
||||||
|
describe('TextModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders form fields when open', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText('Type text…')).toBeDefined()
|
||||||
|
expect(screen.getByText('Add Text')).toBeDefined()
|
||||||
|
expect(screen.getByText('Police')).toBeDefined()
|
||||||
|
expect(screen.getByText('Border Style')).toBeDefined()
|
||||||
|
expect(screen.getByText('Size')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Edit Text title when provided', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Text" />)
|
||||||
|
expect(screen.getByText('Edit Text')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with form data on submit', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
const ta = screen.getByPlaceholderText('Type text…')
|
||||||
|
fireEvent.change(ta, { target: { value: 'Hello' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as TextFormData
|
||||||
|
expect(submitted.text).toBe('Hello')
|
||||||
|
expect(submitted.font).toBe('inter')
|
||||||
|
expect(submitted.border_style).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Border Width when style is none, shows when not', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Border Width')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByTitle('Solid'))
|
||||||
|
expect(screen.getByText('Border Width')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Delete button and calls handlers when provided', () => {
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||||
|
fireEvent.click(screen.getByText('Delete'))
|
||||||
|
expect(onDelete).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills from initial prop', () => {
|
||||||
|
render(
|
||||||
|
<TextModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ text: 'Pre-filled', text_size: 24, font: 'mono' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const ta = screen.getByPlaceholderText('Type text…') as HTMLTextAreaElement
|
||||||
|
expect(ta.value).toBe('Pre-filled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel calls onClose', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,11 +2,11 @@ import { createElement, useState } from 'react'
|
|||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
import { primaryIp } from '@/utils/maskIp'
|
import { splitIps } from '@/utils/maskIp'
|
||||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const { data } = node
|
const { data } = node
|
||||||
const services = data.services ?? []
|
const services = data.services ?? []
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
const host = data.ip ?? data.hostname
|
const ipAddresses = data.ip ? splitIps(data.ip) : []
|
||||||
|
const host = ipAddresses[0] ?? data.hostname
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
@@ -223,18 +224,31 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.ip && (
|
{ipAddresses.length > 0 && (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-start">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
|
||||||
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
{ipAddresses.map((ip, index) => (
|
||||||
</a>
|
<span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={`http://${ip}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
|
||||||
|
title={ip}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
<ExternalLink size={10} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Properties section */}
|
{/* Properties section */}
|
||||||
@@ -662,82 +676,80 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
const hasPort = svc.port != null
|
|
||||||
const portLabel = hasPort ? String(svc.port) : ''
|
|
||||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
|
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
||||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
{url ? (
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium truncate min-w-0 flex-1"
|
|
||||||
style={{ color }}
|
|
||||||
title={svc.service_name}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{svc.service_name}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="font-medium truncate min-w-0 flex-1"
|
|
||||||
style={{ color }}
|
|
||||||
title={svc.service_name}
|
|
||||||
>
|
|
||||||
{svc.service_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{pathLabel && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="truncate text-[#8b949e] max-w-[80px]"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={pathLabel}
|
|
||||||
>
|
|
||||||
{pathLabel}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">{pathLabel}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
{hasPort && (
|
|
||||||
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
|
|
||||||
)}
|
|
||||||
{url ? (
|
{url ? (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
|
className="font-medium truncate min-w-0 flex-1"
|
||||||
aria-label="Open service link"
|
style={{ color }}
|
||||||
style={{ color: 'inherit' }}
|
title={svc.service_name}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-medium truncate min-w-0 flex-1"
|
||||||
|
style={{ color }}
|
||||||
|
title={svc.service_name}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pathLabel && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[#8b949e] text-right w-16 truncate"
|
||||||
|
title={pathLabel}
|
||||||
|
>
|
||||||
|
{pathLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{svc.port != null && (
|
||||||
|
<span className="font-mono text-[#8b949e]">
|
||||||
|
{svc.port}/{svc.protocol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{url ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex w-2.5 h-2.5 items-center justify-center"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ExternalLink size={10} className="text-muted-foreground" />
|
<ExternalLink size={10} className="text-muted-foreground" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="w-2.5 shrink-0" />
|
<span className="w-2.5" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
|
||||||
title="Edit service"
|
title="Edit service"
|
||||||
>
|
>
|
||||||
<Pencil size={10} />
|
<Pencil size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5 cursor-pointer"
|
||||||
title="Remove service"
|
title="Remove service"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -7,6 +7,12 @@ import { useAuthStore } from '@/stores/authStore'
|
|||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
@@ -31,6 +37,7 @@ interface ScanRun {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
|
onAddText: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
@@ -38,7 +45,7 @@ interface SidebarProps {
|
|||||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
@@ -55,7 +62,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
@@ -145,6 +152,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||||
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
@@ -319,6 +327,7 @@ function ScanHistoryPanel() {
|
|||||||
function SettingsPanel() {
|
function SettingsPanel() {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
@@ -326,6 +335,14 @@ function SettingsPanel() {
|
|||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
|
||||||
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
|
const next = { ...alignment, ...patch }
|
||||||
|
setAlignment(next)
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -367,6 +384,41 @@ function SettingsPanel() {
|
|||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alignment.enabled}
|
||||||
|
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle alignment guides"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={2}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
value={alignment.threshold}
|
||||||
|
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||||
|
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Alignment snap threshold"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,7 +442,23 @@ describe('DetailPanel', () => {
|
|||||||
it('displays full comma-separated IP string as link text', () => {
|
it('displays full comma-separated IP string as link text', () => {
|
||||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined()
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined()
|
||||||
|
expect(screen.queryByText(',')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders separate links for semicolon-separated IPs', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10; 192.168.1.11' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders separate links for newline-separated IPs', () => {
|
||||||
|
setupStore({ ip: '192.168.1.10\n192.168.1.11' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||||
|
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -487,4 +503,27 @@ describe('DetailPanel', () => {
|
|||||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Last Seen formatting', () => {
|
||||||
|
it('renders a valid date when last_seen has +00:00 offset (no Z)', () => {
|
||||||
|
setupStore({ last_seen: '2026-05-10T17:54:38.221403+00:00' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const row = screen.getByText('Last Seen').parentElement
|
||||||
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a valid date when last_seen ends with Z', () => {
|
||||||
|
setupStore({ last_seen: '2026-05-10T17:54:38.221403Z' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const row = screen.getByText('Last Seen').parentElement
|
||||||
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats naive ISO strings as UTC', () => {
|
||||||
|
setupStore({ last_seen: '2026-05-10T17:54:38' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const row = screen.getByText('Last Seen').parentElement
|
||||||
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { resolveCustomIcon, brandIconUrl, isBrandIconKey } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
|
interface NodeIconProps {
|
||||||
|
/** Default icon for the node type (lucide). Used when no customIconKey or unknown key. */
|
||||||
|
typeIcon: LucideIcon
|
||||||
|
/** Optional override key. Legacy lucide keys or `brand:<slug>` for dashboard-icons. */
|
||||||
|
customIconKey?: string
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
/** Optional inline color (lucide only — ignored for brand icons). */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeIcon({ typeIcon, customIconKey, size = 16, className, color }: NodeIconProps) {
|
||||||
|
const resolved = resolveCustomIcon(customIconKey)
|
||||||
|
if (resolved?.kind === 'brand') {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={resolved.url}
|
||||||
|
alt={resolved.slug}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
loading="lazy"
|
||||||
|
className={className}
|
||||||
|
style={{ width: size, height: size, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon
|
||||||
|
return createElement(Icon, { size, className, color })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { brandIconUrl, isBrandIconKey }
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,122 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useReactFlow, type Node, type OnNodeDrag } from '@xyflow/react'
|
||||||
|
import { computeSnap, unionBox, type Box, type Guide } from '@/utils/alignment'
|
||||||
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
type NodeDrag = OnNodeDrag<Node<NodeData>>
|
||||||
|
|
||||||
|
function nodeBox(n: Node<NodeData>): Box | null {
|
||||||
|
// Skip parented nodes — alignment between absolute and parent-relative
|
||||||
|
// coordinates is misleading. Top-level nodes only for v1.
|
||||||
|
if (n.parentId) return null
|
||||||
|
const width = n.measured?.width ?? n.width ?? null
|
||||||
|
const height = n.measured?.height ?? n.height ?? null
|
||||||
|
if (width == null || height == null) return null
|
||||||
|
return { id: n.id, x: n.position.x, y: n.position.y, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-time alignment guides + snap (draw.io / Figma style).
|
||||||
|
*
|
||||||
|
* Snap is applied on drag stop (not during drag) to avoid racing React Flow's
|
||||||
|
* internal drag handler, which computes positions from the cursor offset
|
||||||
|
* captured at drag start. Guides update live for visual feedback.
|
||||||
|
*
|
||||||
|
* Settings (enabled, threshold) live in localStorage and propagate same-tab
|
||||||
|
* via a CustomEvent so the panel and the hook stay in sync.
|
||||||
|
*/
|
||||||
|
export function useAlignmentGuides() {
|
||||||
|
const [settings, setSettings] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const [guides, setGuides] = useState<Guide[]>([])
|
||||||
|
// ref mirror of guides so callbacks don't need it in their deps
|
||||||
|
const guidesRef = useRef<Guide[]>([])
|
||||||
|
useEffect(() => { guidesRef.current = guides }, [guides])
|
||||||
|
// latest snap deltas, applied on drag stop
|
||||||
|
const pendingSnapRef = useRef<{ deltaX: number; deltaY: number; ids: Set<string> } | null>(null)
|
||||||
|
const altDownRef = useRef(false)
|
||||||
|
const { setNodes, getNodes } = useReactFlow<Node<NodeData>>()
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setSettings), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => { if (e.altKey) altDownRef.current = true }
|
||||||
|
const up = (e: KeyboardEvent) => { if (!e.altKey) altDownRef.current = false }
|
||||||
|
window.addEventListener('keydown', down)
|
||||||
|
window.addEventListener('keyup', up)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', down)
|
||||||
|
window.removeEventListener('keyup', up)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = useCallback((patch: Partial<AlignmentSettings>) => {
|
||||||
|
setSettings((s) => {
|
||||||
|
const next = { ...s, ...patch }
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearState = useCallback(() => {
|
||||||
|
if (guidesRef.current.length > 0) setGuides([])
|
||||||
|
pendingSnapRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onNodeDrag: NodeDrag = useCallback((_event, dragNode, dragNodes) => {
|
||||||
|
if (!settings.enabled || altDownRef.current) {
|
||||||
|
clearState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const all = getNodes()
|
||||||
|
const draggedIds = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
|
||||||
|
// Restrict the snap to top-level dragged nodes. nodeBox returns null for
|
||||||
|
// parented nodes; if we kept their ids in pendingSnap, onNodeDragStop
|
||||||
|
// would shift their parent-relative position by the same delta the parent
|
||||||
|
// already moves by, double-snapping the child. Children follow the parent
|
||||||
|
// automatically; no extra shift needed.
|
||||||
|
const draggedBoxEntries = all
|
||||||
|
.filter((n) => draggedIds.has(n.id))
|
||||||
|
.map((n) => ({ id: n.id, box: nodeBox(n) }))
|
||||||
|
.filter((e): e is { id: string; box: Box } => e.box !== null)
|
||||||
|
if (draggedBoxEntries.length === 0) {
|
||||||
|
clearState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const snapIds = new Set(draggedBoxEntries.map((e) => e.id))
|
||||||
|
const boxes = draggedBoxEntries.map((e) => e.box)
|
||||||
|
const dragged = boxes.length === 1 ? boxes[0] : unionBox(boxes)
|
||||||
|
if (!dragged) return
|
||||||
|
const candidates = all
|
||||||
|
.filter((n) => !draggedIds.has(n.id))
|
||||||
|
.map(nodeBox)
|
||||||
|
.filter((b): b is Box => b !== null)
|
||||||
|
const result = computeSnap(dragged, candidates, settings.threshold)
|
||||||
|
setGuides(result.guides)
|
||||||
|
pendingSnapRef.current =
|
||||||
|
result.deltaX !== 0 || result.deltaY !== 0
|
||||||
|
? { deltaX: result.deltaX, deltaY: result.deltaY, ids: snapIds }
|
||||||
|
: null
|
||||||
|
}, [settings.enabled, settings.threshold, getNodes, clearState])
|
||||||
|
|
||||||
|
const onNodeDragStop: NodeDrag = useCallback(() => {
|
||||||
|
const pending = pendingSnapRef.current
|
||||||
|
if (pending) {
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
pending.ids.has(n.id)
|
||||||
|
? { ...n, position: { x: n.position.x + pending.deltaX, y: n.position.y + pending.deltaY } }
|
||||||
|
: n,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
clearState()
|
||||||
|
}, [setNodes, clearState])
|
||||||
|
|
||||||
|
return { guides, settings, update, onNodeDrag, onNodeDragStop }
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@
|
|||||||
to { stroke-dashoffset: 0; }
|
to { stroke-dashoffset: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
|
||||||
|
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
||||||
|
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
||||||
|
.react-flow__edgeupdater {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Homelable dark theme — always dark */
|
/* Homelable dark theme — always dark */
|
||||||
:root {
|
:root {
|
||||||
--background: #0d1117;
|
--background: #0d1117;
|
||||||
|
|||||||
@@ -27,12 +27,71 @@ describe('canvasStore', () => {
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setEditingTextId sets and clears editing text id', () => {
|
||||||
|
const { setEditingTextId } = useCanvasStore.getState()
|
||||||
|
setEditingTextId('t1')
|
||||||
|
expect(useCanvasStore.getState().editingTextId).toBe('t1')
|
||||||
|
setEditingTextId(null)
|
||||||
|
expect(useCanvasStore.getState().editingTextId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addNode supports text type with text_content and style', () => {
|
||||||
|
const { addNode } = useCanvasStore.getState()
|
||||||
|
const textNode: Node<NodeData> = {
|
||||||
|
id: 't1',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 50 },
|
||||||
|
data: {
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'Hello world',
|
||||||
|
custom_colors: {
|
||||||
|
text_color: '#ffffff',
|
||||||
|
text_size: 24,
|
||||||
|
font: 'mono',
|
||||||
|
border_style: 'dashed',
|
||||||
|
border_width: 2,
|
||||||
|
border: '#00d4ff',
|
||||||
|
background: '#00000000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
}
|
||||||
|
addNode(textNode)
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1')
|
||||||
|
expect(stored?.type).toBe('text')
|
||||||
|
expect(stored?.data.text_content).toBe('Hello world')
|
||||||
|
expect(stored?.data.custom_colors?.text_size).toBe(24)
|
||||||
|
expect(stored?.data.custom_colors?.border_style).toBe('dashed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode updates text_content and custom_colors on a text node', () => {
|
||||||
|
const { addNode, updateNode } = useCanvasStore.getState()
|
||||||
|
addNode({ ...makeNode('t1', { type: 'text', text_content: 'old' }), type: 'text' })
|
||||||
|
updateNode('t1', { text_content: 'new', custom_colors: { text_color: '#ff0000', text_size: 32 } })
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1')
|
||||||
|
expect(stored?.data.text_content).toBe('new')
|
||||||
|
expect(stored?.data.custom_colors?.text_color).toBe('#ff0000')
|
||||||
|
expect(stored?.data.custom_colors?.text_size).toBe(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteNode removes a text node', () => {
|
||||||
|
const { addNode, deleteNode } = useCanvasStore.getState()
|
||||||
|
addNode({ ...makeNode('t1', { type: 'text' }), type: 'text' })
|
||||||
|
deleteNode('t1')
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('starts empty', () => {
|
it('starts empty', () => {
|
||||||
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
|
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
|
||||||
expect(nodes).toHaveLength(0)
|
expect(nodes).toHaveLength(0)
|
||||||
@@ -438,6 +497,31 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reconnectEdge swaps source/target and normalizes handles', () => {
|
||||||
|
useCanvasStore.setState((s) => ({
|
||||||
|
edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }],
|
||||||
|
}))
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
useCanvasStore.getState().reconnectEdge('e1', {
|
||||||
|
source: 'n1',
|
||||||
|
target: 'n3',
|
||||||
|
sourceHandle: 'bottom-2-t',
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
})
|
||||||
|
const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
|
expect(edge?.target).toBe('n3')
|
||||||
|
expect(edge?.source).toBe('n1')
|
||||||
|
expect(edge?.sourceHandle).toBe('bottom-2')
|
||||||
|
expect(edge?.targetHandle).toBe('top')
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reconnectEdge snapshots history for undo', () => {
|
||||||
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] }))
|
||||||
|
useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null })
|
||||||
|
expect(useCanvasStore.getState().past.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('deleteEdge removes the edge and marks unsaved', () => {
|
it('deleteEdge removes the edge and marks unsaved', () => {
|
||||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
@@ -665,6 +749,36 @@ describe('canvasStore', () => {
|
|||||||
expect(stored?.height).toBeUndefined()
|
expect(stored?.height).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updateNode preserves height for group type when properties change (children must not vanish)', () => {
|
||||||
|
const group: Node<NodeData> = {
|
||||||
|
id: 'g1',
|
||||||
|
type: 'group',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
data: { label: 'Zone', type: 'group', status: 'unknown', services: [] },
|
||||||
|
}
|
||||||
|
useCanvasStore.setState({ nodes: [group], edges: [] })
|
||||||
|
useCanvasStore.getState().updateNode('g1', { properties: [] })
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'g1')
|
||||||
|
expect(stored?.height).toBe(240)
|
||||||
|
expect(stored?.width).toBe(360)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode preserves height for groupRect when properties change', () => {
|
||||||
|
const rect: Node<NodeData> = {
|
||||||
|
id: 'r1',
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
data: { label: 'Rect', type: 'groupRect', status: 'unknown', services: [] },
|
||||||
|
}
|
||||||
|
useCanvasStore.setState({ nodes: [rect], edges: [] })
|
||||||
|
useCanvasStore.getState().updateNode('r1', { properties: [] })
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
// ── bottom_handles edge remapping ──────────────────────────────────────────
|
// ── bottom_handles edge remapping ──────────────────────────────────────────
|
||||||
|
|
||||||
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
|
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
|
||||||
@@ -750,6 +864,7 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: [],
|
||||||
|
|||||||
@@ -44,11 +44,14 @@ interface CanvasState {
|
|||||||
updateNode: (id: string, data: Partial<NodeData>) => void
|
updateNode: (id: string, data: Partial<NodeData>) => void
|
||||||
deleteNode: (id: string) => void
|
deleteNode: (id: string) => void
|
||||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||||
|
reconnectEdge: (id: string, connection: Connection) => void
|
||||||
deleteEdge: (id: string) => void
|
deleteEdge: (id: string) => void
|
||||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||||
setNodeZIndex: (id: string, zIndex: number) => void
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
editingGroupRectId: string | null
|
editingGroupRectId: string | null
|
||||||
setEditingGroupRectId: (id: string | null) => void
|
setEditingGroupRectId: (id: string | null) => void
|
||||||
|
editingTextId: string | null
|
||||||
|
setEditingTextId: (id: string | null) => void
|
||||||
createGroup: (nodeIds: string[], name: string) => void
|
createGroup: (nodeIds: string[], name: string) => void
|
||||||
ungroup: (groupId: string) => void
|
ungroup: (groupId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
@@ -71,6 +74,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
hideIp: false,
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
@@ -206,7 +210,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
if (n.id !== id) return n
|
if (n.id !== id) return n
|
||||||
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
||||||
// When properties change, clear stored height so the node auto-sizes to fit new content
|
// When properties change, clear stored height so the node auto-sizes to fit new content
|
||||||
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect') {
|
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group') {
|
||||||
updated.height = undefined
|
updated.height = undefined
|
||||||
}
|
}
|
||||||
if ('parent_id' in data) {
|
if ('parent_id' in data) {
|
||||||
@@ -289,6 +293,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
reconnectEdge: (id, connection) =>
|
||||||
|
set((state) => ({
|
||||||
|
edges: state.edges.map((e) =>
|
||||||
|
e.id === id
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
source: connection.source ?? e.source,
|
||||||
|
target: connection.target ?? e.target,
|
||||||
|
sourceHandle: normalizeHandle(connection.sourceHandle),
|
||||||
|
targetHandle: normalizeHandle(connection.targetHandle),
|
||||||
|
}
|
||||||
|
: e
|
||||||
|
),
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
})),
|
||||||
|
|
||||||
deleteEdge: (id) =>
|
deleteEdge: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: state.edges.filter((e) => e.id !== id),
|
edges: state.edges.filter((e) => e.id !== id),
|
||||||
@@ -350,6 +372,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||||
|
|
||||||
|
setEditingTextId: (id) => set({ editingTextId: id }),
|
||||||
|
|
||||||
createGroup: (nodeIds, name) =>
|
createGroup: (nodeIds, name) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const PADDING_H = 24
|
const PADDING_H = 24
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type NodeType =
|
|||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
| 'group'
|
| 'group'
|
||||||
|
| 'text'
|
||||||
| 'zigbee_coordinator'
|
| 'zigbee_coordinator'
|
||||||
| 'zigbee_router'
|
| 'zigbee_router'
|
||||||
| 'zigbee_enddevice'
|
| 'zigbee_enddevice'
|
||||||
@@ -82,6 +83,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
border?: string
|
border?: string
|
||||||
background?: string
|
background?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
show_services?: boolean
|
||||||
// Group rectangle extras (type === 'groupRect')
|
// Group rectangle extras (type === 'groupRect')
|
||||||
text_color?: string
|
text_color?: string
|
||||||
text_position?: TextPosition
|
text_position?: TextPosition
|
||||||
@@ -98,6 +100,8 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
|
/** Text node content (type === 'text') */
|
||||||
|
text_content?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||||
@@ -139,6 +143,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
group: 'Node Group',
|
group: 'Node Group',
|
||||||
|
text: 'Text',
|
||||||
zigbee_coordinator: 'Zigbee Coordinator',
|
zigbee_coordinator: 'Zigbee Coordinator',
|
||||||
zigbee_router: 'Zigbee Router',
|
zigbee_router: 'Zigbee Router',
|
||||||
zigbee_enddevice: 'Zigbee End Device',
|
zigbee_enddevice: 'Zigbee End Device',
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeSnap, unionBox, type Box } from '../alignment'
|
||||||
|
|
||||||
|
const box = (id: string, x: number, y: number, width = 100, height = 50): Box => ({ id, x, y, width, height })
|
||||||
|
|
||||||
|
describe('computeSnap', () => {
|
||||||
|
it('returns zero deltas with no candidates', () => {
|
||||||
|
const r = computeSnap(box('a', 0, 0), [], 6)
|
||||||
|
expect(r).toEqual({ deltaX: 0, deltaY: 0, guides: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps left edges when within threshold (same-size boxes show all aligned guides)', () => {
|
||||||
|
const dragged = box('a', 102, 200) // left=102
|
||||||
|
const other = box('b', 100, 0) // left=100 → delta -2 → all three edges tie
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
expect(r.deltaX).toBe(-2)
|
||||||
|
expect(r.deltaY).toBe(0)
|
||||||
|
// Same width: left/center/right all tie at delta -2. Draw all three guides.
|
||||||
|
expect(r.guides.map((g) => g.position).sort((a, b) => a - b)).toEqual([100, 150, 200])
|
||||||
|
expect(r.guides.every((g) => g.axis === 'x')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a single guide when only one edge aligns', () => {
|
||||||
|
const dragged = box('a', 102, 200, 80, 50) // left=102, center=142, right=182
|
||||||
|
const other = box('b', 100, 0, 100, 50) // left=100, center=150, right=200
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
expect(r.deltaX).toBe(-2) // left↔left wins
|
||||||
|
expect(r.guides).toHaveLength(1)
|
||||||
|
expect(r.guides[0].position).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not snap when outside threshold', () => {
|
||||||
|
const dragged = box('a', 110, 200)
|
||||||
|
const other = box('b', 100, 0)
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
expect(r.deltaX).toBe(0)
|
||||||
|
expect(r.guides).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps centerX to centerX', () => {
|
||||||
|
const dragged = box('a', 51, 300, 100, 50) // center=101
|
||||||
|
const other = box('b', 75, 0, 50, 50) // center=100 → delta -1
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
expect(r.deltaX).toBe(-1)
|
||||||
|
expect(r.guides).toHaveLength(1)
|
||||||
|
expect(r.guides[0].position).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps right edge of dragged to left edge of candidate', () => {
|
||||||
|
const dragged = box('a', 0, 200) // right=100
|
||||||
|
const other = box('b', 102, 0) // left=102 → delta +2
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
expect(r.deltaX).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps both axes simultaneously', () => {
|
||||||
|
const dragged = box('a', 102, 53, 80, 30)
|
||||||
|
const other = box('b', 100, 50, 100, 50)
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
expect(r.deltaX).toBe(-2)
|
||||||
|
expect(r.deltaY).toBe(-3)
|
||||||
|
// Distinct sizes → exactly one X guide and one Y guide.
|
||||||
|
expect(r.guides.filter((g) => g.axis === 'x')).toHaveLength(1)
|
||||||
|
expect(r.guides.filter((g) => g.axis === 'y')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks the closest match when multiple candidates compete', () => {
|
||||||
|
const dragged = box('a', 103, 0)
|
||||||
|
const closer = box('b', 100, 0) // delta -3
|
||||||
|
const farther = box('c', 109, 0) // delta +6 — also within threshold but worse
|
||||||
|
const r = computeSnap(dragged, [closer, farther], 6)
|
||||||
|
expect(r.deltaX).toBe(-3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores the dragged box itself when present in candidates', () => {
|
||||||
|
const dragged = box('a', 102, 0)
|
||||||
|
const r = computeSnap(dragged, [dragged, box('b', 100, 0)], 6)
|
||||||
|
expect(r.deltaX).toBe(-2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no snap with non-positive threshold', () => {
|
||||||
|
const r = computeSnap(box('a', 100, 0), [box('b', 100, 0)], 0)
|
||||||
|
expect(r.guides).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('guide span covers both involved boxes', () => {
|
||||||
|
const dragged = box('a', 102, 200, 100, 50) // y range 200..250
|
||||||
|
const other = box('b', 100, 0, 100, 50) // y range 0..50
|
||||||
|
const r = computeSnap(dragged, [other], 6)
|
||||||
|
const g = r.guides[0]
|
||||||
|
expect(g.start).toBeLessThanOrEqual(0)
|
||||||
|
expect(g.end).toBeGreaterThanOrEqual(250)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unionBox', () => {
|
||||||
|
it('returns null for empty input', () => {
|
||||||
|
expect(unionBox([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the box itself for one input', () => {
|
||||||
|
const b = box('a', 10, 20, 30, 40)
|
||||||
|
expect(unionBox([b])).toMatchObject({ x: 10, y: 20, width: 30, height: 40 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes union of multiple boxes', () => {
|
||||||
|
const u = unionBox([box('a', 0, 0, 50, 50), box('b', 100, 100, 50, 50)])
|
||||||
|
expect(u).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_ALIGNMENT_SETTINGS,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '../alignmentSettings'
|
||||||
|
|
||||||
|
describe('alignmentSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns defaults when nothing stored', () => {
|
||||||
|
expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('roundtrips through localStorage', () => {
|
||||||
|
writeAlignmentSettings({ enabled: false, threshold: 10 })
|
||||||
|
expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: 10 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaults when stored value is corrupted', () => {
|
||||||
|
localStorage.setItem('homelable.alignmentGuides', '{not json')
|
||||||
|
expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills missing fields from defaults', () => {
|
||||||
|
localStorage.setItem('homelable.alignmentGuides', JSON.stringify({ enabled: false }))
|
||||||
|
expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: DEFAULT_ALIGNMENT_SETTINGS.threshold })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('notifies subscribers on write', () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
const unsubscribe = subscribeAlignmentSettings(listener)
|
||||||
|
writeAlignmentSettings({ enabled: false, threshold: 8 })
|
||||||
|
expect(listener).toHaveBeenCalledWith({ enabled: false, threshold: 8 })
|
||||||
|
unsubscribe()
|
||||||
|
writeAlignmentSettings({ enabled: true, threshold: 4 })
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
BRAND_ICON_PREFIX,
|
||||||
|
isBrandIconKey,
|
||||||
|
brandIconSlug,
|
||||||
|
brandIconUrl,
|
||||||
|
resolveCustomIcon,
|
||||||
|
ICON_MAP,
|
||||||
|
} from '../nodeIcons'
|
||||||
|
|
||||||
|
describe('brand icon helpers', () => {
|
||||||
|
it('isBrandIconKey returns true only for prefixed keys', () => {
|
||||||
|
expect(isBrandIconKey('brand:plex')).toBe(true)
|
||||||
|
expect(isBrandIconKey('plex')).toBe(false)
|
||||||
|
expect(isBrandIconKey('plug')).toBe(false)
|
||||||
|
expect(isBrandIconKey(undefined)).toBe(false)
|
||||||
|
expect(isBrandIconKey(null)).toBe(false)
|
||||||
|
expect(isBrandIconKey('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('brandIconSlug strips the prefix', () => {
|
||||||
|
expect(brandIconSlug(`${BRAND_ICON_PREFIX}home-assistant`)).toBe('home-assistant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('brandIconUrl points at jsDelivr CDN', () => {
|
||||||
|
expect(brandIconUrl('plex')).toBe(
|
||||||
|
'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveCustomIcon', () => {
|
||||||
|
it('returns null when no key', () => {
|
||||||
|
expect(resolveCustomIcon(undefined)).toBeNull()
|
||||||
|
expect(resolveCustomIcon('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves legacy lucide keys', () => {
|
||||||
|
const r = resolveCustomIcon('plug')
|
||||||
|
expect(r?.kind).toBe('lucide')
|
||||||
|
if (r?.kind === 'lucide') expect(r.icon).toBe(ICON_MAP['plug'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves brand-prefixed keys to a CDN url', () => {
|
||||||
|
const r = resolveCustomIcon('brand:plex')
|
||||||
|
expect(r?.kind).toBe('brand')
|
||||||
|
if (r?.kind === 'brand') {
|
||||||
|
expect(r.slug).toBe('plex')
|
||||||
|
expect(r.url).toContain('cdn.jsdelivr.net')
|
||||||
|
expect(r.url).toContain('/plex.svg')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unknown legacy key', () => {
|
||||||
|
expect(resolveCustomIcon('definitely-not-a-known-icon-key')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -412,3 +412,24 @@ describe('round-trip: serialize → deserialize', () => {
|
|||||||
expect(restored.height).toBe(300)
|
expect(restored.height).toBe(300)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── text nodes survive save+reload (regression for #lost text node) ──────────
|
||||||
|
describe('serializeNode — text node roundtrip', () => {
|
||||||
|
const emptyMap = new Map<string, boolean>()
|
||||||
|
|
||||||
|
it('persists text content through label so it survives reload', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
type: 'text',
|
||||||
|
data: {
|
||||||
|
label: 'Hello world',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const serialized = serializeNode(node) as ApiNode
|
||||||
|
expect(serialized.label).toBe('Hello world')
|
||||||
|
const restored = deserializeApiNode(serialized, emptyMap)
|
||||||
|
expect(restored.data.label).toBe('Hello world')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ describe('splitIps', () => {
|
|||||||
expect(splitIps('')).toEqual([])
|
expect(splitIps('')).toEqual([])
|
||||||
expect(splitIps(' ')).toEqual([])
|
expect(splitIps(' ')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('splits on semicolons', () => {
|
||||||
|
expect(splitIps('10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits on newlines', () => {
|
||||||
|
expect(splitIps('10.0.0.1\n10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits on mixed delimiters', () => {
|
||||||
|
expect(splitIps('10.0.0.1,10.0.0.2; 10.0.0.3\n10.0.0.4')).toEqual([
|
||||||
|
'10.0.0.1',
|
||||||
|
'10.0.0.2',
|
||||||
|
'10.0.0.3',
|
||||||
|
'10.0.0.4',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates repeated IPs', () => {
|
||||||
|
expect(splitIps('10.0.0.1, 10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('primaryIp', () => {
|
describe('primaryIp', () => {
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ describe('ICON_REGISTRY', () => {
|
|||||||
expect(keys).toContain('database') // DB services
|
expect(keys).toContain('database') // DB services
|
||||||
expect(keys).toContain('cctv') // IP Camera / CCTV
|
expect(keys).toContain('cctv') // IP Camera / CCTV
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('contains Smart Home / Sensors icons', () => {
|
||||||
|
const keys = ICON_REGISTRY.map((e) => e.key)
|
||||||
|
expect(keys).toContain('plug')
|
||||||
|
expect(keys).toContain('smoke')
|
||||||
|
expect(keys).toContain('door')
|
||||||
|
expect(keys).toContain('motion')
|
||||||
|
expect(keys).toContain('leak')
|
||||||
|
expect(keys).toContain('lock-smart')
|
||||||
|
expect(keys).toContain('battery-charging')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes the Smart Home / Sensors category', () => {
|
||||||
|
const categories = ICON_REGISTRY.map((e) => e.category)
|
||||||
|
expect(categories).toContain('Smart Home / Sensors')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ICON_CATEGORIES', () => {
|
describe('ICON_CATEGORIES', () => {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent'
|
||||||
|
|
||||||
|
describe('getValidParentTypes', () => {
|
||||||
|
it('returns container-mode types for lxc', () => {
|
||||||
|
expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns container-mode types for vm', () => {
|
||||||
|
expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns docker_host/lxc/vm/proxmox for docker_container', () => {
|
||||||
|
expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty list for types that cannot have a parent', () => {
|
||||||
|
expect(getValidParentTypes('server')).toEqual([])
|
||||||
|
expect(getValidParentTypes('router')).toEqual([])
|
||||||
|
expect(getValidParentTypes('proxmox')).toEqual([])
|
||||||
|
expect(getValidParentTypes('docker_host')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveVirtualEdgeParent', () => {
|
||||||
|
it('nests lxc under proxmox (container-mode parent)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'lxc1', type: 'lxc' },
|
||||||
|
{ id: 'px1', type: 'proxmox' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'lxc1', parentId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests vm under proxmox regardless of edge direction', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'px1', type: 'proxmox' },
|
||||||
|
{ id: 'vm1', type: 'vm' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'vm1', parentId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under docker_host', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'dh1', type: 'docker_host' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'dh1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under lxc (reverse direction)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'lxc1', type: 'lxc' },
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under lxc (forward direction)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'lxc1', type: 'lxc' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under vm', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'vm1', type: 'vm' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'vm1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests docker_container under proxmox (reverse direction)', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'px1', type: 'proxmox' },
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
)
|
||||||
|
expect(res).toEqual({ childId: 'dc1', parentId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when docker_container links to unsupported parent type', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'dc1', type: 'docker_container' },
|
||||||
|
{ id: 'srv1', type: 'server' },
|
||||||
|
)
|
||||||
|
expect(res).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unrelated type pairs', () => {
|
||||||
|
const res = resolveVirtualEdgeParent(
|
||||||
|
{ id: 'srv1', type: 'server' },
|
||||||
|
{ id: 'rt1', type: 'router' },
|
||||||
|
)
|
||||||
|
expect(res).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Alignment-snap math for drag-time guides (draw.io / Figma style).
|
||||||
|
//
|
||||||
|
// Given a dragged box and a set of candidate boxes, return the snap delta
|
||||||
|
// (clamped by threshold, expressed in canvas px) plus the guide segments that
|
||||||
|
// should be drawn while the snap is engaged.
|
||||||
|
//
|
||||||
|
// All coordinates are absolute canvas coordinates — callers must resolve
|
||||||
|
// child-of-parent positions before calling.
|
||||||
|
|
||||||
|
export interface Box {
|
||||||
|
id: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GuideAxis = 'x' | 'y'
|
||||||
|
|
||||||
|
export interface Guide {
|
||||||
|
axis: GuideAxis
|
||||||
|
// Position along the perpendicular axis (x for vertical guide, y for horizontal).
|
||||||
|
position: number
|
||||||
|
// Span along the parallel axis: min/max of involved boxes' opposite-axis extents.
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapResult {
|
||||||
|
deltaX: number
|
||||||
|
deltaY: number
|
||||||
|
guides: Guide[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type EdgeKind = 'min' | 'center' | 'max'
|
||||||
|
|
||||||
|
interface AxisLine {
|
||||||
|
kind: EdgeKind
|
||||||
|
pos: number
|
||||||
|
// Span of the source box along the perpendicular axis (used to extend guide segments).
|
||||||
|
spanStart: number
|
||||||
|
spanEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function xLines(box: Box): AxisLine[] {
|
||||||
|
const top = box.y
|
||||||
|
const bottom = box.y + box.height
|
||||||
|
return [
|
||||||
|
{ kind: 'min', pos: box.x, spanStart: top, spanEnd: bottom },
|
||||||
|
{ kind: 'center', pos: box.x + box.width / 2, spanStart: top, spanEnd: bottom },
|
||||||
|
{ kind: 'max', pos: box.x + box.width, spanStart: top, spanEnd: bottom },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function yLines(box: Box): AxisLine[] {
|
||||||
|
const left = box.x
|
||||||
|
const right = box.x + box.width
|
||||||
|
return [
|
||||||
|
{ kind: 'min', pos: box.y, spanStart: left, spanEnd: right },
|
||||||
|
{ kind: 'center', pos: box.y + box.height / 2, spanStart: left, spanEnd: right },
|
||||||
|
{ kind: 'max', pos: box.y + box.height, spanStart: left, spanEnd: right },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Match {
|
||||||
|
delta: number
|
||||||
|
guidePos: number
|
||||||
|
candidate: AxisLine
|
||||||
|
dragLine: AxisLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all (dragLine, candidateLine) pairs at the smallest |delta| within threshold.
|
||||||
|
// When boxes share dimensions, all three edges align simultaneously — return all.
|
||||||
|
function bestMatches(dragLines: AxisLine[], candidateLines: AxisLine[], threshold: number): Match[] {
|
||||||
|
let bestAbs = Infinity
|
||||||
|
const matches: Match[] = []
|
||||||
|
for (const d of dragLines) {
|
||||||
|
for (const c of candidateLines) {
|
||||||
|
const delta = c.pos - d.pos
|
||||||
|
const abs = Math.abs(delta)
|
||||||
|
if (abs > threshold) continue
|
||||||
|
if (abs < bestAbs - 1e-9) {
|
||||||
|
bestAbs = abs
|
||||||
|
matches.length = 0
|
||||||
|
}
|
||||||
|
if (abs <= bestAbs + 1e-9) {
|
||||||
|
matches.push({ delta, guidePos: c.pos, candidate: c, dragLine: d })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute snap delta + visible guides for a drag operation.
|
||||||
|
*
|
||||||
|
* @param dragged Bounding box of the dragged node (or selection bbox).
|
||||||
|
* @param candidates Other nodes' boxes to consider.
|
||||||
|
* @param threshold Max distance (canvas px) at which a snap engages.
|
||||||
|
*/
|
||||||
|
export function computeSnap(dragged: Box, candidates: Box[], threshold: number): SnapResult {
|
||||||
|
if (candidates.length === 0 || threshold <= 0) {
|
||||||
|
return { deltaX: 0, deltaY: 0, guides: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragX = xLines(dragged)
|
||||||
|
const dragY = yLines(dragged)
|
||||||
|
|
||||||
|
let bestXMatches: Match[] = []
|
||||||
|
let bestYMatches: Match[] = []
|
||||||
|
let xBestAbs = Infinity
|
||||||
|
let yBestAbs = Infinity
|
||||||
|
|
||||||
|
for (const cand of candidates) {
|
||||||
|
if (cand.id === dragged.id) continue
|
||||||
|
const mx = bestMatches(dragX, xLines(cand), threshold)
|
||||||
|
if (mx.length > 0) {
|
||||||
|
const abs = Math.abs(mx[0].delta)
|
||||||
|
if (abs < xBestAbs - 1e-9) {
|
||||||
|
xBestAbs = abs
|
||||||
|
bestXMatches = mx
|
||||||
|
} else if (abs <= xBestAbs + 1e-9) {
|
||||||
|
bestXMatches = bestXMatches.concat(mx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const my = bestMatches(dragY, yLines(cand), threshold)
|
||||||
|
if (my.length > 0) {
|
||||||
|
const abs = Math.abs(my[0].delta)
|
||||||
|
if (abs < yBestAbs - 1e-9) {
|
||||||
|
yBestAbs = abs
|
||||||
|
bestYMatches = my
|
||||||
|
} else if (abs <= yBestAbs + 1e-9) {
|
||||||
|
bestYMatches = bestYMatches.concat(my)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = bestXMatches[0]?.delta ?? 0
|
||||||
|
const deltaY = bestYMatches[0]?.delta ?? 0
|
||||||
|
|
||||||
|
const guides: Guide[] = []
|
||||||
|
const seenX = new Set<number>()
|
||||||
|
for (const m of bestXMatches) {
|
||||||
|
if (seenX.has(m.guidePos)) continue
|
||||||
|
seenX.add(m.guidePos)
|
||||||
|
const start = Math.min(m.dragLine.spanStart + deltaY, m.candidate.spanStart)
|
||||||
|
const end = Math.max(m.dragLine.spanEnd + deltaY, m.candidate.spanEnd)
|
||||||
|
guides.push({ axis: 'x', position: m.guidePos, start, end })
|
||||||
|
}
|
||||||
|
const seenY = new Set<number>()
|
||||||
|
for (const m of bestYMatches) {
|
||||||
|
if (seenY.has(m.guidePos)) continue
|
||||||
|
seenY.add(m.guidePos)
|
||||||
|
const start = Math.min(m.dragLine.spanStart + deltaX, m.candidate.spanStart)
|
||||||
|
const end = Math.max(m.dragLine.spanEnd + deltaX, m.candidate.spanEnd)
|
||||||
|
guides.push({ axis: 'y', position: m.guidePos, start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deltaX, deltaY, guides }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding box of multiple boxes — used for multi-selection drag. */
|
||||||
|
export function unionBox(boxes: Box[]): Box | null {
|
||||||
|
if (boxes.length === 0) return null
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||||
|
for (const b of boxes) {
|
||||||
|
if (b.x < minX) minX = b.x
|
||||||
|
if (b.y < minY) minY = b.y
|
||||||
|
if (b.x + b.width > maxX) maxX = b.x + b.width
|
||||||
|
if (b.y + b.height > maxY) maxY = b.y + b.height
|
||||||
|
}
|
||||||
|
return { id: '__union__', x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Persisted client-side settings for alignment guides.
|
||||||
|
// Kept in localStorage (per-user UI preference, not canvas data).
|
||||||
|
// Same-tab updates propagate via a CustomEvent so the drag hook and the
|
||||||
|
// settings panel can stay in sync without a global store.
|
||||||
|
|
||||||
|
export interface AlignmentSettings {
|
||||||
|
enabled: boolean
|
||||||
|
threshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ALIGNMENT_SETTINGS: AlignmentSettings = { enabled: true, threshold: 6 }
|
||||||
|
|
||||||
|
const KEY = 'homelable.alignmentGuides'
|
||||||
|
const EVENT = 'homelable:alignment-settings-changed'
|
||||||
|
|
||||||
|
export function readAlignmentSettings(): AlignmentSettings {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return DEFAULT_ALIGNMENT_SETTINGS
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AlignmentSettings>
|
||||||
|
return {
|
||||||
|
enabled: parsed.enabled ?? DEFAULT_ALIGNMENT_SETTINGS.enabled,
|
||||||
|
threshold: typeof parsed.threshold === 'number' ? parsed.threshold : DEFAULT_ALIGNMENT_SETTINGS.threshold,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ALIGNMENT_SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAlignmentSettings(s: AlignmentSettings): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(s))
|
||||||
|
window.dispatchEvent(new CustomEvent<AlignmentSettings>(EVENT, { detail: s }))
|
||||||
|
} catch {
|
||||||
|
/* quota / SSR */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAlignmentSettings(listener: (s: AlignmentSettings) => void): () => void {
|
||||||
|
const handler = (e: Event) => listener((e as CustomEvent<AlignmentSettings>).detail)
|
||||||
|
window.addEventListener(EVENT, handler)
|
||||||
|
return () => window.removeEventListener(EVENT, handler)
|
||||||
|
}
|
||||||
@@ -62,6 +62,48 @@ export const demoNodes: Node<NodeData>[] = [
|
|||||||
position: { x: 460, y: 500 },
|
position: { x: 460, y: 500 },
|
||||||
data: { label: 'Zigbee Hub', type: 'iot', ip: '192.168.2.1', status: 'pending', check_method: 'tcp', services: [] },
|
data: { label: 'Zigbee Hub', type: 'iot', ip: '192.168.2.1', status: 'pending', check_method: 'tcp', services: [] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-text-1',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 660, y: 20 },
|
||||||
|
data: {
|
||||||
|
label: 'Demo banner',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'This is demo canvas, start with fresh scan',
|
||||||
|
custom_colors: {
|
||||||
|
text_color: '#e6edf3',
|
||||||
|
text_size: 14,
|
||||||
|
font: 'inter',
|
||||||
|
border: '#00d4ff',
|
||||||
|
border_style: 'dashed',
|
||||||
|
border_width: 1,
|
||||||
|
background: '#00d4ff14',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-text-2',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: -240, y: 540 },
|
||||||
|
data: {
|
||||||
|
label: 'Demo hint',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'You can remove all nodes',
|
||||||
|
custom_colors: {
|
||||||
|
text_color: '#8b949e',
|
||||||
|
text_size: 12,
|
||||||
|
font: 'inter',
|
||||||
|
border: '#30363d',
|
||||||
|
border_style: 'dotted',
|
||||||
|
border_width: 1,
|
||||||
|
background: '#00000000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const demoEdges: Edge<EdgeData>[] = [
|
export const demoEdges: Edge<EdgeData>[] = [
|
||||||
|
|||||||
@@ -25,16 +25,17 @@ function maskSingle(ip: string): string {
|
|||||||
*/
|
*/
|
||||||
export function maskIp(ip: string): string {
|
export function maskIp(ip: string): string {
|
||||||
if (!ip) return ip
|
if (!ip) return ip
|
||||||
return ip.split(',').map(maskSingle).join(', ')
|
return splitIps(ip).map(maskSingle).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a comma-separated IP string into an array of trimmed values.
|
* Split an IP string into trimmed values. Accepts comma, semicolon, or newline
|
||||||
* Empty string returns [].
|
* delimiters (or any combination). Duplicates removed, empty entries dropped.
|
||||||
*/
|
*/
|
||||||
export function splitIps(ip: string): string[] {
|
export function splitIps(ip: string): string[] {
|
||||||
if (!ip?.trim()) return []
|
if (!ip?.trim()) return []
|
||||||
return ip.split(',').map((s) => s.trim()).filter(Boolean)
|
const parts = ip.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
return parts.filter((v, i) => parts.indexOf(v) === i)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
// Security & Auth
|
// Security & Auth
|
||||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||||
// Automation & IoT
|
// Automation & IoT
|
||||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
|
||||||
|
// Smart Home / Sensors
|
||||||
|
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
|
||||||
|
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
|
||||||
|
Snowflake, LampCeiling, Blinds, BellRing, Speaker, Joystick, Warehouse,
|
||||||
|
CircleDot, CloudSun,
|
||||||
// Transfers & sync
|
// Transfers & sync
|
||||||
Download, Upload, RefreshCw,
|
Download, Upload, RefreshCw,
|
||||||
// Containers & Dev
|
// Containers & Dev
|
||||||
@@ -19,7 +24,7 @@ import {
|
|||||||
// Communications
|
// Communications
|
||||||
Mail, MessageSquare, Phone,
|
Mail, MessageSquare, Phone,
|
||||||
// Misc devices
|
// Misc devices
|
||||||
Printer, Smartphone, Search, Filter, BookOpen, PlugZap,
|
Printer, Smartphone, Search, Filter, BookOpen, PlugZap, Type,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -97,6 +102,35 @@ export const ICON_REGISTRY: IconEntry[] = [
|
|||||||
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
|
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
|
||||||
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
|
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
|
||||||
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
|
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
|
||||||
|
{ key: 'voice', label: 'Voice Assistant', category: 'Automation', icon: BotMessageSquare },
|
||||||
|
{ key: 'webhook', label: 'Webhook / IFTTT', category: 'Automation', icon: Webhook },
|
||||||
|
|
||||||
|
// --- Smart Home / Sensors ---
|
||||||
|
{ key: 'plug', label: 'Smart Plug / Outlet', category: 'Smart Home / Sensors', icon: Plug },
|
||||||
|
{ key: 'power', label: 'Switch / Relay', category: 'Smart Home / Sensors', icon: Power },
|
||||||
|
{ key: 'battery-charging', label: 'Energy Meter / EV', category: 'Smart Home / Sensors', icon: BatteryCharging },
|
||||||
|
{ key: 'solar', label: 'Solar Panel', category: 'Smart Home / Sensors', icon: Sun },
|
||||||
|
{ key: 'door', label: 'Door / Window Sensor', category: 'Smart Home / Sensors', icon: DoorOpen },
|
||||||
|
{ key: 'lock-smart', label: 'Smart Lock', category: 'Smart Home / Sensors', icon: KeyRound },
|
||||||
|
{ key: 'smoke', label: 'Smoke Detector', category: 'Smart Home / Sensors', icon: AlarmSmoke },
|
||||||
|
{ key: 'siren', label: 'Siren / Alarm', category: 'Smart Home / Sensors', icon: Siren },
|
||||||
|
{ key: 'motion', label: 'Motion / Radar', category: 'Smart Home / Sensors', icon: Radar },
|
||||||
|
{ key: 'presence', label: 'Presence Sensor', category: 'Smart Home / Sensors', icon: PersonStanding },
|
||||||
|
{ key: 'vibration', label: 'Vibration Sensor', category: 'Smart Home / Sensors', icon: Vibrate },
|
||||||
|
{ key: 'leak', label: 'Water Leak', category: 'Smart Home / Sensors', icon: Droplet },
|
||||||
|
{ key: 'humidity', label: 'Humidity', category: 'Smart Home / Sensors', icon: Droplets },
|
||||||
|
{ key: 'air-quality', label: 'Air Quality / VOC', category: 'Smart Home / Sensors', icon: Wind },
|
||||||
|
{ key: 'air-vent', label: 'HVAC Vent', category: 'Smart Home / Sensors', icon: AirVent },
|
||||||
|
{ key: 'fan', label: 'Fan', category: 'Smart Home / Sensors', icon: Fan },
|
||||||
|
{ key: 'snowflake', label: 'AC / Cooling', category: 'Smart Home / Sensors', icon: Snowflake },
|
||||||
|
{ key: 'lamp', label: 'Smart Light', category: 'Smart Home / Sensors', icon: LampCeiling },
|
||||||
|
{ key: 'blinds', label: 'Blinds / Cover', category: 'Smart Home / Sensors', icon: Blinds },
|
||||||
|
{ key: 'doorbell', label: 'Doorbell', category: 'Smart Home / Sensors', icon: BellRing },
|
||||||
|
{ key: 'speaker', label: 'Smart Speaker', category: 'Smart Home / Sensors', icon: Speaker },
|
||||||
|
{ key: 'remote', label: 'Remote / Button', category: 'Smart Home / Sensors', icon: Joystick },
|
||||||
|
{ key: 'garage', label: 'Garage Door', category: 'Smart Home / Sensors', icon: Warehouse },
|
||||||
|
{ key: 'valve', label: 'Smart Valve', category: 'Smart Home / Sensors', icon: CircleDot },
|
||||||
|
{ key: 'weather', label: 'Weather Station', category: 'Smart Home / Sensors', icon: CloudSun },
|
||||||
|
|
||||||
// --- Containers & Dev ---
|
// --- Containers & Dev ---
|
||||||
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
||||||
@@ -142,13 +176,48 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
|||||||
generic: Circle,
|
generic: Circle,
|
||||||
group: Circle,
|
group: Circle,
|
||||||
groupRect: Circle,
|
groupRect: Circle,
|
||||||
|
text: Type,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
/** Resolve the display icon for a node — custom_icon takes priority over type default.
|
||||||
|
* Legacy: returns a LucideIcon component. Brand icons must use `resolveCustomIcon`. */
|
||||||
export function resolveNodeIcon(
|
export function resolveNodeIcon(
|
||||||
typeIcon: LucideIcon,
|
typeIcon: LucideIcon,
|
||||||
customIconKey?: string,
|
customIconKey?: string,
|
||||||
): LucideIcon {
|
): LucideIcon {
|
||||||
if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey]
|
if (customIconKey && !customIconKey.startsWith('brand:') && ICON_MAP[customIconKey]) {
|
||||||
|
return ICON_MAP[customIconKey]
|
||||||
|
}
|
||||||
return typeIcon
|
return typeIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BRAND_ICON_PREFIX = 'brand:'
|
||||||
|
|
||||||
|
export function isBrandIconKey(key: string | undefined | null): boolean {
|
||||||
|
return !!key && key.startsWith(BRAND_ICON_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandIconSlug(key: string): string {
|
||||||
|
return key.slice(BRAND_ICON_PREFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandIconUrl(slug: string): string {
|
||||||
|
return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedIcon =
|
||||||
|
| { kind: 'lucide'; icon: LucideIcon }
|
||||||
|
| { kind: 'brand'; slug: string; url: string }
|
||||||
|
|
||||||
|
/** Resolve a node's icon to either a lucide component or a brand CDN URL.
|
||||||
|
* Used by renderers that support brand icons. Backwards-compatible with legacy
|
||||||
|
* string keys (no prefix → lucide lookup). Returns null when key unknown. */
|
||||||
|
export function resolveCustomIcon(customIconKey?: string): ResolvedIcon | null {
|
||||||
|
if (!customIconKey) return null
|
||||||
|
if (isBrandIconKey(customIconKey)) {
|
||||||
|
const slug = brandIconSlug(customIconKey)
|
||||||
|
return { kind: 'brand', slug, url: brandIconUrl(slug) }
|
||||||
|
}
|
||||||
|
const icon = ICON_MAP[customIconKey] as LucideIcon | undefined
|
||||||
|
return icon ? { kind: 'lucide', icon } : null
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
text: { border: '#30363d', icon: '#e6edf3' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#21262d',
|
nodeCardBackground: '#21262d',
|
||||||
nodeIconBackground: '#161b22',
|
nodeIconBackground: '#161b22',
|
||||||
@@ -125,6 +126,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
|
text: { border: '#404040', icon: '#ffffff' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#0a0a0a',
|
nodeCardBackground: '#0a0a0a',
|
||||||
nodeIconBackground: '#111111',
|
nodeIconBackground: '#111111',
|
||||||
@@ -185,6 +187,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||||
group: { border: '#0284c7', icon: '#0284c7' },
|
group: { border: '#0284c7', icon: '#0284c7' },
|
||||||
|
text: { border: '#cbd5e1', icon: '#1f2328' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#ffffff',
|
nodeCardBackground: '#ffffff',
|
||||||
nodeIconBackground: '#f0f6ff',
|
nodeIconBackground: '#f0f6ff',
|
||||||
@@ -245,6 +248,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||||
group: { border: '#00ffff', icon: '#00ffff' },
|
group: { border: '#00ffff', icon: '#00ffff' },
|
||||||
|
text: { border: '#3a3a6a', icon: '#ffffff' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#0f0f2a',
|
nodeCardBackground: '#0f0f2a',
|
||||||
nodeIconBackground: '#0a0a1a',
|
nodeIconBackground: '#0a0a1a',
|
||||||
@@ -305,6 +309,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#006600', icon: '#006600' },
|
generic: { border: '#006600', icon: '#006600' },
|
||||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||||
group: { border: '#00ff41', icon: '#00ff41' },
|
group: { border: '#00ff41', icon: '#00ff41' },
|
||||||
|
text: { border: '#003311', icon: '#00ff41' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#001100',
|
nodeCardBackground: '#001100',
|
||||||
nodeIconBackground: '#002200',
|
nodeIconBackground: '#002200',
|
||||||
@@ -365,6 +370,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
text: { border: '#30363d', icon: '#e6edf3' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#21262d',
|
nodeCardBackground: '#21262d',
|
||||||
nodeIconBackground: '#161b22',
|
nodeIconBackground: '#161b22',
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
export type NodeType = NodeData['type']
|
||||||
|
|
||||||
|
const CONTAINER_MODE_TYPES = new Set<NodeType>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
const DOCKER_CONTAINER_PARENT_TYPES = new Set<NodeType>(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||||
|
|
||||||
|
export interface VirtualEdgeEndpoint {
|
||||||
|
id: string
|
||||||
|
type: NodeType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentAssignment {
|
||||||
|
childId: string
|
||||||
|
parentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidParentTypes(childType: NodeType): NodeType[] {
|
||||||
|
if (childType === 'lxc' || childType === 'vm') {
|
||||||
|
return ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
|
}
|
||||||
|
if (childType === 'docker_container') {
|
||||||
|
return ['docker_host', 'lxc', 'vm', 'proxmox']
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVirtualEdgeParent(
|
||||||
|
source: VirtualEdgeEndpoint,
|
||||||
|
target: VirtualEdgeEndpoint,
|
||||||
|
): ParentAssignment | null {
|
||||||
|
const { type: srcType, id: srcId } = source
|
||||||
|
const { type: tgtType, id: tgtId } = target
|
||||||
|
|
||||||
|
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||||
|
return { childId: srcId, parentId: tgtId }
|
||||||
|
}
|
||||||
|
if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||||
|
return { childId: tgtId, parentId: srcId }
|
||||||
|
}
|
||||||
|
if (srcType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(tgtType)) {
|
||||||
|
return { childId: srcId, parentId: tgtId }
|
||||||
|
}
|
||||||
|
if (tgtType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(srcType)) {
|
||||||
|
return { childId: tgtId, parentId: srcId }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -28,7 +28,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8000',
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:8000',
|
target: 'ws://localhost:8000',
|
||||||
ws: true,
|
ws: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user